lovewebconnection 0.0.0-stage → 0.2.0

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 +1915 -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
package/dist/chain.js ADDED
@@ -0,0 +1,419 @@
1
+ import { createPublicClient, defineChain, formatGwei, http, } from "viem";
2
+ /**
3
+ * Built-in chains. A preset supplies the chain id, endpoint, and native
4
+ * currency; LWC_RPC_URL, LWC_CHAIN_NAME, and LWC_NATIVE_SYMBOL still override
5
+ * individual values, so a preset is a starting point rather than a lock-in.
6
+ */
7
+ export const CHAIN_PRESETS = {
8
+ xlayer: {
9
+ chainId: 196,
10
+ name: "xlayer",
11
+ rpcUrl: "https://xlayer.drpc.org",
12
+ // OKX's own endpoint, a different operator from the primary.
13
+ fallbackRpcUrl: "https://rpc.xlayer.tech",
14
+ nativeSymbol: "OKB",
15
+ },
16
+ base: {
17
+ chainId: 8453,
18
+ name: "base",
19
+ rpcUrl: "https://mainnet.base.org",
20
+ fallbackRpcUrl: "https://base-rpc.publicnode.com",
21
+ nativeSymbol: "ETH",
22
+ },
23
+ ethereum: {
24
+ chainId: 1,
25
+ name: "ethereum",
26
+ rpcUrl: "https://ethereum-rpc.publicnode.com",
27
+ fallbackRpcUrl: "https://cloudflare-eth.com",
28
+ nativeSymbol: "ETH",
29
+ },
30
+ arbitrum: {
31
+ chainId: 42_161,
32
+ name: "arbitrum",
33
+ rpcUrl: "https://arb1.arbitrum.io/rpc",
34
+ fallbackRpcUrl: "https://arbitrum-one-rpc.publicnode.com",
35
+ nativeSymbol: "ETH",
36
+ },
37
+ optimism: {
38
+ chainId: 10,
39
+ name: "optimism",
40
+ rpcUrl: "https://mainnet.optimism.io",
41
+ fallbackRpcUrl: "https://optimism-rpc.publicnode.com",
42
+ nativeSymbol: "ETH",
43
+ },
44
+ polygon: {
45
+ chainId: 137,
46
+ name: "polygon",
47
+ // polygon-rpc.com answers 401 now; this endpoint was probed live.
48
+ rpcUrl: "https://polygon-bor-rpc.publicnode.com",
49
+ fallbackRpcUrl: "https://polygon.drpc.org",
50
+ // POL since the chain renamed its token from MATIC.
51
+ nativeSymbol: "POL",
52
+ },
53
+ };
54
+ export function chainPresetNames() {
55
+ return Object.keys(CHAIN_PRESETS);
56
+ }
57
+ /** Find a preset by name (case-insensitive) or by its chain id. */
58
+ export function resolveChainPreset(selector) {
59
+ const key = selector.trim().toLowerCase();
60
+ const byName = CHAIN_PRESETS[key];
61
+ if (byName)
62
+ return byName;
63
+ if (!/^\d+$/.test(key))
64
+ return null;
65
+ const chainId = Number(key);
66
+ return (Object.values(CHAIN_PRESETS).find((preset) => preset.chainId === chainId) ?? null);
67
+ }
68
+ export class ChainConfigError extends Error {
69
+ constructor(message) {
70
+ super(message);
71
+ this.name = "ChainConfigError";
72
+ }
73
+ }
74
+ export class ChainMismatchError extends Error {
75
+ expected;
76
+ actual;
77
+ source;
78
+ constructor(expected, actual, source = "RPC") {
79
+ super(`${source} answered chainId ${actual}, but LWC_CHAIN_ID is ${expected}. Refusing to use a mismatched endpoint.`);
80
+ this.name = "ChainMismatchError";
81
+ this.expected = expected;
82
+ this.actual = actual;
83
+ this.source = source;
84
+ }
85
+ }
86
+ const DEFAULT_TIMEOUT_MS = 10_000;
87
+ const MIN_TIMEOUT_MS = 250;
88
+ const MAX_TIMEOUT_MS = 120_000;
89
+ function requireEnv(env, key) {
90
+ const value = env[key]?.trim();
91
+ if (!value) {
92
+ throw new ChainConfigError(`${key} is required. Copy .env.example to .env and fill it in.`);
93
+ }
94
+ return value;
95
+ }
96
+ function validateRpcUrl(raw, key) {
97
+ let endpoint;
98
+ try {
99
+ endpoint = new URL(raw);
100
+ }
101
+ catch {
102
+ throw new ChainConfigError(`${key} is not a valid URL: "${raw}".`);
103
+ }
104
+ if (endpoint.protocol !== "https:" && endpoint.protocol !== "http:") {
105
+ throw new ChainConfigError(`${key} must use http or https, got "${endpoint.protocol}".`);
106
+ }
107
+ }
108
+ function parseChainId(raw) {
109
+ if (!/^\d+$/.test(raw)) {
110
+ throw new ChainConfigError(`LWC_CHAIN_ID must be a positive integer, got "${raw}".`);
111
+ }
112
+ const chainId = Number(raw);
113
+ if (!Number.isSafeInteger(chainId) || chainId < 1) {
114
+ throw new ChainConfigError(`LWC_CHAIN_ID is out of range: "${raw}".`);
115
+ }
116
+ return chainId;
117
+ }
118
+ /**
119
+ * Build the chain config from a preset name, a chain id, or explicit
120
+ * LWC_CHAIN_ID/LWC_RPC_URL values. An explicit selector (the --chain flag)
121
+ * wins over LWC_CHAIN.
122
+ */
123
+ export function parseChainConfig(env, selector) {
124
+ const explicit = selector?.trim() || null;
125
+ const chosen = explicit ?? (env.LWC_CHAIN?.trim() || null);
126
+ const preset = chosen === null ? null : resolveChainPreset(chosen);
127
+ const numericSelector = chosen !== null && /^\d+$/.test(chosen) && preset === null ? chosen : null;
128
+ if (chosen !== null && preset === null && numericSelector === null) {
129
+ throw new ChainConfigError(`unknown chain "${chosen}". Known presets: ${chainPresetNames().join(", ")}. ` +
130
+ "Use a preset name or chain id, or set LWC_CHAIN_ID and LWC_RPC_URL for a custom chain.");
131
+ }
132
+ const envChainId = env.LWC_CHAIN_ID?.trim() || null;
133
+ const presetConflictsWithEnv = preset !== null &&
134
+ envChainId !== null &&
135
+ parseChainId(envChainId) !== preset.chainId;
136
+ if (presetConflictsWithEnv && explicit === null) {
137
+ throw new ChainConfigError(`LWC_CHAIN is ${chosen} (${preset.chainId}), but LWC_CHAIN_ID is ${envChainId}. ` +
138
+ `Remove LWC_CHAIN_ID or set it to ${preset.chainId}.`);
139
+ }
140
+ let chainId;
141
+ if (preset !== null) {
142
+ chainId = preset.chainId;
143
+ }
144
+ else if (envChainId !== null) {
145
+ chainId = parseChainId(envChainId);
146
+ }
147
+ else if (numericSelector !== null) {
148
+ chainId = parseChainId(numericSelector);
149
+ }
150
+ else {
151
+ throw new ChainConfigError("LWC_CHAIN_ID is required when LWC_CHAIN is not set. Use a preset " +
152
+ `(${chainPresetNames().join(", ")}) or copy .env.example to .env and fill it in.`);
153
+ }
154
+ // The env endpoint is the override for a custom chain and for a preset whose
155
+ // configured chain id matches it. An explicit --chain preset switches
156
+ // networks, so an endpoint configured for another chain is not inherited.
157
+ const envRpcUrl = env.LWC_RPC_URL?.trim() || null;
158
+ const rpcUrl = (presetConflictsWithEnv ? null : envRpcUrl) || preset?.rpcUrl || null;
159
+ if (rpcUrl === null) {
160
+ throw new ChainConfigError("LWC_RPC_URL is required for a custom chain. Set it, or pick a preset with " +
161
+ `LWC_CHAIN (${chainPresetNames().join(", ")}).`);
162
+ }
163
+ validateRpcUrl(rpcUrl, "LWC_RPC_URL");
164
+ const authHeader = env.LWC_RPC_AUTH?.trim() || null;
165
+ if (authHeader && /[\r\n]/.test(authHeader)) {
166
+ throw new ChainConfigError("LWC_RPC_AUTH must be a single line and must not contain newlines.");
167
+ }
168
+ const rawTimeout = env.LWC_TIMEOUT_MS?.trim();
169
+ const timeoutMs = rawTimeout ? Number(rawTimeout) : DEFAULT_TIMEOUT_MS;
170
+ if (!Number.isInteger(timeoutMs) ||
171
+ timeoutMs < MIN_TIMEOUT_MS ||
172
+ timeoutMs > MAX_TIMEOUT_MS) {
173
+ throw new ChainConfigError(`LWC_TIMEOUT_MS must be an integer between ${MIN_TIMEOUT_MS} and ${MAX_TIMEOUT_MS}.`);
174
+ }
175
+ // Cosmetic overrides follow the same rule as the endpoint: they describe the
176
+ // chain configured in .env, not a chain chosen explicitly with --chain.
177
+ const name = (presetConflictsWithEnv ? null : env.LWC_CHAIN_NAME?.trim()) ||
178
+ preset?.name ||
179
+ `lwc-${chainId}`;
180
+ const symbol = (presetConflictsWithEnv ? null : env.LWC_NATIVE_SYMBOL?.trim()) ||
181
+ preset?.nativeSymbol ||
182
+ "ETH";
183
+ // The backup is a preset default that LWC_FALLBACK_RPC_URL replaces, so a
184
+ // custom chain (or a private endpoint in .env) can name its own second URL.
185
+ // Like LWC_RPC_URL it describes the chain configured in .env, not one chosen
186
+ // explicitly with --chain, and it never repeats the primary.
187
+ const envFallback = presetConflictsWithEnv
188
+ ? null
189
+ : env.LWC_FALLBACK_RPC_URL?.trim() || null;
190
+ if (envFallback !== null)
191
+ validateRpcUrl(envFallback, "LWC_FALLBACK_RPC_URL");
192
+ const fallbackCandidate = envFallback ?? preset?.fallbackRpcUrl ?? null;
193
+ const fallbackRpcUrl = fallbackCandidate !== null && fallbackCandidate !== rpcUrl
194
+ ? fallbackCandidate
195
+ : null;
196
+ return {
197
+ chainId,
198
+ name,
199
+ rpcUrl,
200
+ fallbackRpcUrl,
201
+ nativeCurrency: { name: symbol, symbol, decimals: 18 },
202
+ authHeader,
203
+ timeoutMs,
204
+ preset: preset?.name ?? null,
205
+ };
206
+ }
207
+ function isLoopbackHost(hostname) {
208
+ return (hostname === "localhost" ||
209
+ hostname === "127.0.0.1" ||
210
+ hostname === "::1" ||
211
+ hostname === "[::1]");
212
+ }
213
+ const KEY_LIKE_SEGMENT = /^[A-Za-z0-9_-]{24,}$/;
214
+ /**
215
+ * A log-safe endpoint label: origin plus path, with query, fragment, and any
216
+ * key-like path segment removed. Provider attribution needs the host, not the
217
+ * credential that happens to sit in the URL.
218
+ */
219
+ export function publicEndpoint(rpcUrl) {
220
+ let url;
221
+ try {
222
+ url = new URL(rpcUrl);
223
+ }
224
+ catch {
225
+ return "<invalid>";
226
+ }
227
+ const path = url.pathname
228
+ .split("/")
229
+ .map((segment) => (KEY_LIKE_SEGMENT.test(segment) ? "<redacted>" : segment))
230
+ .join("/");
231
+ const origin = `${url.protocol}//${url.host}`;
232
+ return path === "" || path === "/" ? origin : `${origin}${path}`;
233
+ }
234
+ /** Non-fatal advisories about the configured endpoint. */
235
+ export function connectionWarnings(config) {
236
+ const warnings = [];
237
+ const endpoints = [["LWC_RPC_URL", config.rpcUrl]];
238
+ if (config.fallbackRpcUrl) {
239
+ endpoints.push(["LWC_FALLBACK_RPC_URL", config.fallbackRpcUrl]);
240
+ }
241
+ for (const [key, url] of endpoints) {
242
+ const endpoint = new URL(url);
243
+ if (endpoint.protocol === "http:" && !isLoopbackHost(endpoint.hostname)) {
244
+ warnings.push(`${key} uses plaintext http to ${endpoint.hostname}; RPC traffic is unencrypted. Use https.`);
245
+ }
246
+ }
247
+ if (config.authHeader) {
248
+ warnings.push("LWC_RPC_AUTH is being sent to this endpoint; only use it with an operator you trust.");
249
+ if (config.fallbackRpcUrl) {
250
+ warnings.push("LWC_RPC_AUTH pins this connection to its endpoint, so the backup endpoint is not used.");
251
+ }
252
+ }
253
+ return warnings;
254
+ }
255
+ export function buildClient(config) {
256
+ const chain = defineChain({
257
+ id: config.chainId,
258
+ name: config.name,
259
+ nativeCurrency: config.nativeCurrency,
260
+ rpcUrls: { default: { http: [config.rpcUrl] } },
261
+ });
262
+ return createPublicClient({
263
+ chain,
264
+ transport: http(config.rpcUrl, {
265
+ timeout: config.timeoutMs,
266
+ retryCount: 1,
267
+ fetchOptions: config.authHeader
268
+ ? { headers: { authorization: config.authHeader } }
269
+ : undefined,
270
+ }),
271
+ });
272
+ }
273
+ /**
274
+ * Read live state from the endpoint and verify it is the chain we expect.
275
+ * Throws ChainMismatchError when the RPC reports a different chainId.
276
+ */
277
+ export async function probe(client, config) {
278
+ const startedAt = performance.now();
279
+ // allSettled, not all: a dead endpoint fails several calls at once, and the
280
+ // rejections Promise.all discards would surface as unhandled rejections.
281
+ const [chainIdCall, blockCall, gasPriceCall, versionCall] = await Promise.allSettled([
282
+ client.getChainId(),
283
+ client.getBlockNumber(),
284
+ client.getGasPrice(),
285
+ client.request({ method: "web3_clientVersion" }),
286
+ ]);
287
+ if (chainIdCall.status === "rejected")
288
+ throw chainIdCall.reason;
289
+ if (blockCall.status === "rejected")
290
+ throw blockCall.reason;
291
+ const chainId = chainIdCall.value;
292
+ const blockNumber = blockCall.value;
293
+ const gasPriceWei = gasPriceCall.status === "fulfilled" ? gasPriceCall.value : null;
294
+ const clientVersion = versionCall.status === "fulfilled" && typeof versionCall.value === "string"
295
+ ? versionCall.value
296
+ : null;
297
+ const block = await client.getBlock({ blockNumber });
298
+ const latencyMs = Math.round(performance.now() - startedAt);
299
+ if (chainId !== config.chainId) {
300
+ throw new ChainMismatchError(config.chainId, chainId);
301
+ }
302
+ const nowSeconds = Math.floor(Date.now() / 1000);
303
+ return {
304
+ chainId,
305
+ expectedChainId: config.chainId,
306
+ blockNumber,
307
+ blockTimestamp: block.timestamp,
308
+ blockAgeSeconds: Math.max(0, nowSeconds - Number(block.timestamp)),
309
+ gasPriceWei,
310
+ clientVersion,
311
+ latencyMs,
312
+ rpcHost: new URL(config.rpcUrl).host,
313
+ };
314
+ }
315
+ function describeError(error) {
316
+ // viem's own message spans several lines; its one-line summary keeps the
317
+ // failover note readable in a single table row.
318
+ const short = error?.shortMessage;
319
+ if (typeof short === "string" && short.length > 0)
320
+ return short;
321
+ return error instanceof Error ? error.message : String(error);
322
+ }
323
+ /**
324
+ * Probe the configured endpoint, falling back to the preset's second endpoint
325
+ * when the first cannot be used: unreachable, timing out, or answering with the
326
+ * wrong chain id. A configured auth header pins the probe to the primary, since
327
+ * sending someone's credentials to another operator would be a leak.
328
+ */
329
+ export async function probeWithFallback(clientFor, config) {
330
+ try {
331
+ const health = await probe(clientFor(config), config);
332
+ return {
333
+ health,
334
+ config,
335
+ usedFallback: false,
336
+ primaryEndpoint: null,
337
+ primaryError: null,
338
+ };
339
+ }
340
+ catch (error) {
341
+ const fallback = config.fallbackRpcUrl?.trim() || null;
342
+ if (fallback === null || fallback === config.rpcUrl || config.authHeader !== null) {
343
+ throw error;
344
+ }
345
+ const backupConfig = { ...config, rpcUrl: fallback, fallbackRpcUrl: null };
346
+ try {
347
+ const health = await probe(clientFor(backupConfig), backupConfig);
348
+ return {
349
+ health,
350
+ config: backupConfig,
351
+ usedFallback: true,
352
+ primaryEndpoint: publicEndpoint(config.rpcUrl),
353
+ primaryError: describeError(error),
354
+ };
355
+ }
356
+ catch (fallbackError) {
357
+ throw new ChainConfigError(`both endpoints failed for ${config.name} (${config.chainId}): ` +
358
+ `${publicEndpoint(config.rpcUrl)} — ${describeError(error)}; ` +
359
+ `${publicEndpoint(fallback)} — ${describeError(fallbackError)}`);
360
+ }
361
+ }
362
+ }
363
+ function formatAge(seconds) {
364
+ if (seconds < 60)
365
+ return `${seconds}s`;
366
+ if (seconds < 3600)
367
+ return `${Math.floor(seconds / 60)}m`;
368
+ if (seconds < 86_400)
369
+ return `${Math.floor(seconds / 3600)}h`;
370
+ return `${Math.floor(seconds / 86_400)}d`;
371
+ }
372
+ export function formatHealth(health, config, warnings = []) {
373
+ const gas = health.gasPriceWei === null
374
+ ? "unavailable"
375
+ : `${formatGwei(health.gasPriceWei)} gwei`;
376
+ const lines = [
377
+ "lwc — chain connection",
378
+ "",
379
+ " status connected",
380
+ ` chain ${config.name} (${health.chainId})`,
381
+ ` rpc ${health.rpcHost}`,
382
+ ` client ${health.clientVersion ?? "unavailable"}`,
383
+ ` block ${health.blockNumber} (${formatAge(health.blockAgeSeconds)} old)`,
384
+ ` gas ${gas}`,
385
+ ` latency ${health.latencyMs} ms`,
386
+ ];
387
+ if (warnings.length > 0) {
388
+ lines.push("", ...warnings.map((warning) => ` warning: ${warning}`));
389
+ }
390
+ return lines.join("\n");
391
+ }
392
+ export function toJsonPayload(health, config, warnings, outcome) {
393
+ return {
394
+ ok: true,
395
+ chain: {
396
+ id: config.chainId,
397
+ name: config.name,
398
+ nativeSymbol: config.nativeCurrency.symbol,
399
+ preset: config.preset,
400
+ },
401
+ rpc: {
402
+ host: new URL(config.rpcUrl).host,
403
+ auth: config.authHeader ? "configured" : "none",
404
+ fallbackHost: config.fallbackRpcUrl
405
+ ? new URL(config.fallbackRpcUrl).host
406
+ : null,
407
+ usedFallback: outcome?.usedFallback ?? false,
408
+ primaryHost: outcome?.primaryEndpoint ?? null,
409
+ primaryError: outcome?.primaryError ?? null,
410
+ },
411
+ health: {
412
+ ...health,
413
+ blockNumber: health.blockNumber.toString(),
414
+ blockTimestamp: health.blockTimestamp.toString(),
415
+ gasPriceWei: health.gasPriceWei === null ? null : health.gasPriceWei.toString(),
416
+ },
417
+ warnings,
418
+ };
419
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};