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
package/README.md CHANGED
@@ -1,3 +1,1938 @@
1
- # Temporary Holding Version
1
+ # lwc — lovewebconnection
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ <!-- Status badges for emeraldjewels4-rgb/lovewebconnection-cli, the public
4
+ repository these workflows run in. The lovewebconnection slug is a
5
+ different, private project, so keep this one distinct; change the slug in
6
+ all three lines if the repository moves. -->
7
+ [![Tests](https://github.com/emeraldjewels4-rgb/lovewebconnection-cli/actions/workflows/tests.yml/badge.svg)](https://github.com/emeraldjewels4-rgb/lovewebconnection-cli/actions/workflows/tests.yml)
8
+ [![Migrations](https://github.com/emeraldjewels4-rgb/lovewebconnection-cli/actions/workflows/migrations.yml/badge.svg)](https://github.com/emeraldjewels4-rgb/lovewebconnection-cli/actions/workflows/migrations.yml)
9
+ [![Preset health](https://github.com/emeraldjewels4-rgb/lovewebconnection-cli/actions/workflows/presets.yml/badge.svg)](https://github.com/emeraldjewels4-rgb/lovewebconnection-cli/actions/workflows/presets.yml)
10
+
11
+ A read-only EVM chain connection kit. Point it at any EVM-compatible JSON-RPC
12
+ endpoint, prove the connection is live, and build on the same client.
13
+
14
+ It holds **no private keys, signs nothing, and sends no transactions**. It reads
15
+ chain state and refuses to talk to an endpoint that reports the wrong chain id.
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ npm install -g lovewebconnection # installs the `lwc` command
21
+ npx lovewebconnection chain # or run it without installing
22
+ ```
23
+
24
+ The npm package is `lovewebconnection` and the command it installs is `lwc`;
25
+ the name `lwc` on npm belongs to Lightning Web Components. The package ships
26
+ compiled JavaScript in `dist/`, built from the TypeScript sources with
27
+ `npm run build`.
28
+
29
+ ## Requirements
30
+
31
+ - Node.js 24+ (the source runs directly via Node's built-in TypeScript support)
32
+ - npm
33
+
34
+ ## Setup
35
+
36
+ ```bash
37
+ npm install
38
+ cp .env.example .env # then edit .env
39
+ ```
40
+
41
+ `.env` is gitignored. Nothing secret belongs in the repo. The template starts on
42
+ the `xlayer` preset, so a fresh copy works without editing anything.
43
+
44
+ `npm install` also installs the pre-commit hook: its `prepare` script points
45
+ `core.hooksPath` at `.githooks`, so every commit runs `npm run typecheck` and
46
+ `npm test` first. Expect it to add 15–25 seconds to a commit. The hook also
47
+ runs the preset gate described below under **Presets**: it blocks a commit when
48
+ the recorded history fails its uptime gate, and skips until a history file
49
+ exists.
50
+
51
+ Skip it when you need to:
52
+
53
+ ```bash
54
+ git commit --no-verify # once
55
+ npm test # ...then run the checks yourself
56
+ git config --unset core.hooksPath # until the next npm install
57
+ ```
58
+
59
+ One test in `npm test` needs a browser: `src/explorer-browser.test.ts` drives
60
+ the explorer page in headless Chrome and skips itself when none is installed.
61
+ Set `CHROME_PATH` to a Chrome, Edge or Chromium binary to run it, or
62
+ `LWC_SKIP_BROWSER=1 npm test` to skip only that file. See [CI and the browser
63
+ test](#ci-and-the-browser-test) for what the workflow does with it.
64
+
65
+ ## Connect
66
+
67
+ ```bash
68
+ npm run chain # human-readable health report
69
+ npm run chain:json # same data as JSON
70
+ lwc chain --chain base # one preset, without touching .env
71
+ ```
72
+
73
+ Example output:
74
+
75
+ ```
76
+ lwc — chain connection
77
+
78
+ status connected
79
+ chain base (8453)
80
+ rpc mainnet.base.org
81
+ client Geth/v1.14.11-stable/linux-amd64/go1.21.13
82
+ block 21654321 (3s old)
83
+ gas 0.005 gwei
84
+ latency 118 ms
85
+ ```
86
+
87
+ `npm run chain:json` emits `chain`, `rpc`, `health`, and `warnings` with bigints
88
+ as decimal strings, so the output is safe for `jq` and logs. The auth header is
89
+ never printed, and `chain.preset` names the built-in chain in use (or `null`).
90
+ `rpc.host` is the endpoint that answered, while `rpc.usedFallback`,
91
+ `rpc.primaryHost`, and `rpc.primaryError` record a failover to the backup.
92
+
93
+ ## Chains
94
+
95
+ Six chains are built in, so checking them needs no configuration:
96
+
97
+ | Preset | Chain id | Native | Default RPC | Backup RPC |
98
+ | --- | --- | --- | --- | --- |
99
+ | `xlayer` | 196 | OKB | `https://xlayer.drpc.org` | `https://rpc.xlayer.tech` |
100
+ | `base` | 8453 | ETH | `https://mainnet.base.org` | `https://base-rpc.publicnode.com` |
101
+ | `ethereum` | 1 | ETH | `https://ethereum-rpc.publicnode.com` | `https://cloudflare-eth.com` |
102
+ | `arbitrum` | 42161 | ETH | `https://arb1.arbitrum.io/rpc` | `https://arbitrum-one-rpc.publicnode.com` |
103
+ | `optimism` | 10 | ETH | `https://mainnet.optimism.io` | `https://optimism-rpc.publicnode.com` |
104
+ | `polygon` | 137 | POL | `https://polygon-bor-rpc.publicnode.com` | `https://polygon.drpc.org` |
105
+
106
+ Every preset has a backup endpoint from a different operator. When the default
107
+ endpoint is unreachable, times out, or answers with the wrong chain id, `lwc
108
+ chain` and `lwc presets` retry against the backup and report which one answered
109
+ instead of failing outright. A configured `LWC_RPC_AUTH` disables the failover:
110
+ a credential is only ever sent to the operator it was issued for.
111
+
112
+ A custom chain, or a private endpoint configured in `.env`, names its own backup
113
+ with `LWC_FALLBACK_RPC_URL`. For a preset it replaces the preset's backup, and it
114
+ follows the same rule as `LWC_RPC_URL`: it describes the chain configured in
115
+ `.env`, so `--chain` switching to another network does not inherit it.
116
+
117
+ ```bash
118
+ lwc chain --chain xlayer
119
+ lwc chain --chain base
120
+ lwc chain --chain ethereum # any preset works the same way
121
+ lwc chain --chain 196 # a preset can also be named by its chain id
122
+ LWC_CHAIN=xlayer npm run chain
123
+ ```
124
+
125
+ `lwc presets` probes all six endpoints in parallel and reports latency and head
126
+ for each one, so a dead endpoint is visible without trying them one at a time.
127
+ It exits non-zero if any preset is unhealthy, which makes it usable as a
128
+ connectivity check in CI:
129
+
130
+ ```bash
131
+ lwc presets
132
+ lwc presets --json # machine-readable; exits 1 on any failure
133
+ lwc presets --timeout 5000 # per-endpoint budget in ms (250-120000)
134
+ lwc presets --history .lwc-history/presets.jsonl # append this run
135
+ lwc presets-history .lwc-history/presets.jsonl # trend across runs
136
+ lwc presets-history .lwc-history/presets.jsonl --since 7d # this week only
137
+ lwc presets-history .lwc-history/presets.jsonl --limit 20 # the newest 20
138
+ lwc presets-history .lwc-history/presets.jsonl --since 7d --until 1d
139
+ lwc presets-history .lwc-history/presets.jsonl --since 7d --csv ./presets.csv
140
+ lwc presets-history .lwc-history/presets.jsonl --since 7d --timeline ./timeline.csv
141
+ lwc presets-history .lwc-history/presets.jsonl --since 7d --compare 14d..7d
142
+ lwc presets-history .lwc-history/presets.jsonl --since 30d --compare 60d..30d
143
+ lwc presets-history .lwc-history/presets.jsonl --since 7d --fail-under 99
144
+ lwc presets-history .lwc-history/presets.jsonl --since 7d --check --fail-under 99
145
+ lwc presets-history mine.jsonl --merge laptop.jsonl --out combined.jsonl --retain 180d
146
+ ```
147
+
148
+ `--history` appends one JSON line per run, and `presets-history` reads them back
149
+ into per-preset uptime, failovers, p50/max latency, and the last failure, so a
150
+ provider that fails one run in ten is visible instead of averaged away by a
151
+ single healthy probe.
152
+
153
+ `--since` takes an ISO timestamp or a span measured back from now (`30m`, `24h`,
154
+ `7d`), and `--limit` keeps the newest N runs after that cutoff, so a recent
155
+ incident reads on its own instead of against the whole file. A narrowed view
156
+ says `runs 2 of 30` and prints the window it applied; `--json` carries
157
+ `totalRuns`, `since`, `until` and `limit` so a report can state its own scope.
158
+
159
+ `--until` closes the other end with the same syntax, which is what judges a
160
+ finished incident: `--since 7d --until 1d` is the week before yesterday, so the
161
+ runs since the fix do not dilute the ones that failed. A window that ends before
162
+ it starts is rejected rather than silently matching nothing.
163
+
164
+ `--csv <path>` (or `-` for stdout) exports one row per preset for the selected
165
+ window — runs, healthy, failed, failovers, uptime, p50 and max latency, and the
166
+ last run and failure — so the window can be charted or pasted into a
167
+ spreadsheet. The filters apply before the export, and `--fail-under` still gates
168
+ on the same window when a CSV is written.
169
+
170
+ `--timeline <path>` (or `-`) exports the same window at per-run granularity —
171
+ one row per preset per run, oldest first, with `ok`, `via`, latency, block,
172
+ host and both error fields — so uptime and latency can be plotted as a time
173
+ series instead of collapsed into a window summary. `--csv` and `--timeline` are
174
+ separate exports and cannot be combined.
175
+
176
+ `--compare <from>..<to>` puts an earlier window next to the current one, which
177
+ is what judges a provider change: both sides are read in the same syntax as
178
+ `--since`/`--until`, and each preset reports its runs and uptime on both sides
179
+ plus the percentage-point change, worst regression first. `--since` marks where
180
+ the current window starts, and the comparison must end at or before it, so the
181
+ two windows never overlap; a preset that only appears in one of them shows `—`
182
+ for the missing side and no change instead of a fake delta. `--json` carries
183
+ both full summaries under `before` and `after`, and `--compare` is mutually
184
+ exclusive with `--limit`, `--csv`, and `--timeline`. It can be combined with
185
+ `--fail-under`: the gate still judges the current window, while the comparison
186
+ is reported beside it. The report also states the file's `covers` span and
187
+ declared `retained` window, and when the earlier window starts before the
188
+ retained history it says so: the delta there covers only the runs that remain,
189
+ so a shrinking before side reads as a trim rather than a provider fading out.
190
+
191
+ `--fail-under <pct>` turns the view into a gate: it exits 1 when any preset's
192
+ uptime inside the window is below the percentage, naming each offender on stderr
193
+ while the table still goes to stdout. Because it judges the window, pair it with
194
+ `--since`/`--limit` to hold a recent period to a standard rather than all of
195
+ history — a provider that failed two of the last twenty runs is caught even when
196
+ the newest run was healthy. An empty window fails the gate, since there is no
197
+ run to clear it.
198
+
199
+ `--check` bundles that gate with the file's own facts for a local run: one
200
+ command prints the coverage, the declared retention, the window it judged, any
201
+ retention note, and the PASS/FAIL verdict with the presets below the threshold,
202
+ then exits on the verdict — no CI job, run summary or annotation needed. It
203
+ requires `--fail-under` (the check is the gate, so the threshold is not
204
+ guessed) and cannot be combined with `--csv`, `--timeline` or `--compare`, since
205
+ the report is the output. `--json` emits the same fields as one document, which
206
+ is what a pre-commit hook or a local dashboard reads: `gate` carries the
207
+ threshold, verdict and shortfalls, `coverage` the file's span and recorded runs,
208
+ `retention` its declared span and cutoff, and `maxAge`/`stale` the freshness
209
+ bound and the age that broke it.
210
+
211
+ `--max-age <span>` adds freshness to the check: the gate fails when the newest
212
+ run in the file is older than the span, because a healthy week nobody appended
213
+ to says nothing about today. Exactly at the bound still passes. Unlike
214
+ retention, which is anchored to the newest run so a paused schedule cannot prune
215
+ its own evidence, age is measured against the clock, so it can only be used
216
+ where `--check` asks for a gate. The pre-commit wrapper defaults it to
217
+ `LWC_MAX_AGE` or `7d` and passes `--max-age` through, so a history that stopped
218
+ updating cannot keep passing commits. The failure report lists the newest five
219
+ runs; `--freshness-rows <n>` (or `LWC_FRESHNESS_ROWS`) changes how many.
220
+
221
+ The same exit code is what a pre-commit hook blocks on, and
222
+ `scripts/pre-commit-presets.mjs` wraps the check for git: silent when the gate
223
+ passes, the report on stderr when it fails, a clean skip when the history file
224
+ does not exist yet (`--strict-missing` makes that a failure instead), and a
225
+ refusal to merge rather than gate a file other than the one it names. Exit
226
+ codes are 0 for a pass or skip, 1 for a failed gate and 2 for a misconfigured
227
+ hook; `--json` writes one document to stdout in every outcome, with the
228
+ explanation kept on stderr so stdout stays parseable. A stale history makes the
229
+ failure report list its newest runs with their age, failures and failovers, so
230
+ the rows that are past the bound are visible instead of summarized in one line. The path and threshold
231
+ can come from `LWC_PRESETS_HISTORY`, `LWC_FAIL_UNDER`, `LWC_MAX_AGE` and
232
+ `LWC_FRESHNESS_ROWS`, which is how the repository hook sets them:
233
+
234
+ ```sh
235
+ node scripts/pre-commit-presets.mjs --since 7d --fail-under 95
236
+ ```
237
+
238
+ That is the line `.githooks/pre-commit` adds after the typecheck and tests, so
239
+ recording runs is enough to start blocking commits on a failing gate — no
240
+ further configuration, and no gate until the first run is recorded.
241
+
242
+ `--merge <file>` (repeatable) folds other history files into the one positional
243
+ file before anything reads it, and `--out <path>` writes the combined JSONL — so
244
+ a cache copy and a published copy become one record without a separate script.
245
+ `--retain <span>` caps that union at a window measured back from its newest
246
+ run, pruning older runs as it writes — the workflow publishes six months — and
247
+ because the window follows the newest run rather than the clock, a paused
248
+ schedule cannot prune the record it stopped updating. Keep the span longer than
249
+ any window you still want to read: when `--since` or `--compare` reaches past
250
+ the retained history, the CLI warns on stderr, and CI turns a `RETAIN_WINDOW`
251
+ shorter than its gate or comparison window into a
252
+ `::warning title=Retention window::` annotation — a pruned run is not a
253
+ provider that went dark. A written history records the span it was trimmed to in
254
+ a leading `lwc_history` header line, so a later read of that file warns even
255
+ without `--retain` on the command line: the file itself says which runs are
256
+ simply gone. The table header reports both numbers — `covers` is the span the
257
+ file actually holds and `retained` the span it was trimmed to — so a windowed or
258
+ trimmed view is visible without reading stderr; `--json` carries the same fields
259
+ as `availableFrom`, `availableTo` and `retainMs`.
260
+
261
+ The union is deduplicated by the whole run record: two copies of a run collapse,
262
+ while runs recorded at the same instant with different results both stay. Every
263
+ filter applies to the union, so `--since`, `--fail-under`, `--compare` and the
264
+ exports all judge the combined history. Merge notes go to stderr, so `--json` on
265
+ stdout remains one parseable document.
266
+
267
+ The scheduled **Preset health** workflow publishes this table to the run summary,
268
+ annotates every failover, and restores its history file from the Actions cache so
269
+ the trend builds up across runs. It also enforces the gate: every run judges the
270
+ last week of history against `--fail-under` (95% by default, settable when
271
+ starting the workflow by hand), writes a PASS/FAIL section naming any preset
272
+ below it, and fails the job — so a provider that has been flaky all week turns
273
+ the schedule red even when that morning's probe happened to answer. An empty or
274
+ unreadable window fails too, rather than passing unchecked, and so does a file
275
+ whose newest run is older than a week: `MAX_AGE` bounds that freshness the way
276
+ `--since` bounds the window, the summary lists the newest runs with their age in
277
+ a freshness table with each run's preset, failed and failover counts
278
+ (`FRESHNESS_ROWS` sets how many rows, five by default), and
279
+ the run log annotates the age, so a schedule that stopped writing cannot keep
280
+ passing on last week's evidence. Pass `--max-age` or `--freshness-rows` directly
281
+ to `scripts/presets-gate.mjs` to tighten the bound or change the table. Each run also
282
+ publishes a week-over-week comparison: the same presets over the seven days
283
+ before the gate's window, with the percentage-point change per preset and the
284
+ regressions called out in the headline, so a provider switch shows whether it
285
+ helped on the day it takes effect. Every regressed preset also gets a
286
+ `::warning title=Preset regression::` annotation in the run log, and every
287
+ provider host where those presets' primary endpoints live gets a
288
+ `::warning title=Provider regression::` line, counting a failover against the
289
+ provider. A host that serves several chains is named once, so the log shows
290
+ which upstream got worse — not just which chains — without opening the summary.
291
+
292
+ The history outlives the cache. Each run restores `.lwc-history/presets.jsonl`
293
+ from the Actions cache and the same file from a `preset-history` release asset,
294
+ then merges the two rather than keeping whichever copy holds more runs: runs
295
+ either copy still has are kept once, oldest first, and the union is what the
296
+ probe appends to and the archive job publishes back after the gate. Restoring
297
+ the union matters because a fresh runner starts with no cache and an entry idle
298
+ for a week is evicted, so the shorter file is not always the staler one.
299
+
300
+ Combining copies is the same operation away from CI, which is how two machines'
301
+ record becomes one:
302
+
303
+ ```bash
304
+ lwc presets-history mine.jsonl --merge laptop.jsonl --merge server.jsonl \
305
+ --out combined.jsonl --since 30d
306
+ ```
307
+
308
+ The workflow does this through `scripts/presets-history-merge.mjs`, which skips
309
+ a missing release instead of failing the restore and passes `--retain 180d`, so
310
+ the asset holds six months rather than growing without limit and records that
311
+ window in the file it writes. Cache entries
312
+ expire after a week of no use and a paused schedule would lose the trend; the
313
+ release keeps the whole record, which is what makes month-over-month windows
314
+ possible away from CI:
315
+
316
+ ```bash
317
+ gh release download preset-history --pattern presets.jsonl \
318
+ -O .lwc-history/presets.jsonl --clobber
319
+ lwc presets-history .lwc-history/presets.jsonl --since 30d --compare 60d..30d
320
+ ```
321
+
322
+ The probe job itself stays read-only; only the archive job is allowed to write,
323
+ and only to that release.
324
+
325
+ `--chain` is global, so one shell can work across networks without editing
326
+ `.env`:
327
+
328
+ ```bash
329
+ lwc balances 0xYourAddress --chain base --token 0xToken
330
+ lwc watch --chain xlayer --interval 5
331
+ ```
332
+
333
+ `LWC_CHAIN` selects the default preset and `--chain` overrides it per command,
334
+ including in a project whose `.env` is pinned to another chain: an explicit
335
+ preset uses its own endpoint and does not inherit a `LWC_CHAIN_ID` or
336
+ `LWC_RPC_URL` configured for a different network. When the two agree, the env
337
+ values still win over the preset's defaults — `LWC_RPC_URL` swaps in your
338
+ endpoint (add `LWC_RPC_AUTH` if it needs a bearer token), and `LWC_CHAIN_NAME`
339
+ and `LWC_NATIVE_SYMBOL` relabel output. Any other network is a custom chain: set
340
+ `LWC_CHAIN_ID` and `LWC_RPC_URL`, or pass the id to `--chain` and keep only
341
+ `LWC_RPC_URL`. The chain-id check applies either way, and `LWC_CHAIN` paired
342
+ with a conflicting `LWC_CHAIN_ID` is rejected instead of silently preferring
343
+ one.
344
+
345
+ ## Balances
346
+
347
+ Read the native balance and any ERC-20s you name:
348
+
349
+ ```bash
350
+ npm run balances -- 0xYourAddress
351
+ npm run balances -- 0xYourAddress --token 0xTokenA --token 0xTokenB
352
+ npm run balances -- 0xYourAddress --token "0xTokenA:Tether USD" --json
353
+ npm run balances -- 0xYourAddress --tokens 0xTokenA,0xTokenB --json
354
+ npm run balances -- 0xYourAddress --tokens-file ./examples/xlayer.tokens.json
355
+ ```
356
+
357
+ ```
358
+ lwc — balances
359
+
360
+ owner 0x000000000000000000000000000000000000dEaD
361
+ chain xlayer (196)
362
+ block 75416262
363
+
364
+ native 1.2345 OKB
365
+ token 2.5 USDC (0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913)
366
+ token 7 FAKE (0x…1234) — symbol is bytes32 (FAKE), not a string
367
+
368
+ flagged 1 of 2 addresses does not look like a working ERC-20
369
+ ```
370
+
371
+ Symbols and decimals are read from the token contract itself (`symbol`,
372
+ `decimals`), never assumed. A genuine ERC-20 must also return `totalSupply`, so
373
+ the owner's balance is checked against it: `balanceOf exceeds totalSupply` is
374
+ flagged, as are `totalSupply reverted` and `totalSupply is not a uint256`. An
375
+ address that answers but is **not a real ERC-20** is flagged in place with the
376
+ reason, while whatever did decode is still shown: `no contract code (every call
377
+ returned no data)` for an EOA or empty contract, `reverted on decimals, symbol,
378
+ balanceOf and totalSupply (not an ERC-20)` for a contract that rejects all four
379
+ methods, `symbol is bytes32 (…)` for pre-standard tokens that return a `bytes32`
380
+ instead of a `string`, plus `decimals is not a uint8`, `implausible decimals (…)`,
381
+ `symbol is empty`, `balanceOf is not a uint256`, and `totalSupply is not a
382
+ uint256`.
383
+ A bad metadata field never hides a balance that did decode: the row degrades to
384
+ `<n> raw` when decimals are unknown and `unreadable` when `balanceOf` fails.
385
+ `--json` carries the same `issue` string per token, `null` for clean ones.
386
+
387
+ Reads are batched through Multicall3 `aggregate3` in **parallel chunks of 12
388
+ tokens**: the native balance and each token's `decimals`, `symbol`, `balanceOf`,
389
+ and `totalSupply` ride together, so thirty tokens cost three simultaneous round
390
+ trips instead of 120. When every token's metadata is cached in `balances`, `scan`, or a watch
391
+ portfolio, chunks grow to 24 tokens because only `balanceOf` and `totalSupply`
392
+ remain, keeping the same sub-call budget. An explicit `--batch-size` always
393
+ wins. A chunk that reverts — a gas-burning contract, or no Multicall3 at all —
394
+ only re-reads its own tokens individually while the other chunks keep their
395
+ results, and the failure is reported once as `batch 2/3: …`. Precompile
396
+ addresses (0x…01–0x…0a) are answered locally and shown as unreadable: they have
397
+ no code, can never be ERC-20s, and 0x…05 (modexp) would read a 4-byte selector
398
+ as an enormous operand length and revert its chunk by exhausting the gas.
399
+
400
+ `--batch-size <n>` sets how many tokens share one Multicall3 request (range
401
+ 1–100). It defaults to 12, or 24 for a fully cached `balances`/`scan` list; an
402
+ explicit value overrides that. Lower it when the RPC enforces a stricter
403
+ `eth_call` gas cap.
404
+
405
+ `--max-subcalls <n>` caps the `aggregate3` sub-calls in one request instead
406
+ (range 1–1000), so the request stays the same size whether metadata is cached
407
+ or not: a cached token costs two sub-calls, an uncached one four, and the first
408
+ request also carries the native balance. Use one of `--batch-size` or
409
+ `--max-subcalls`, not both. A single token still gets its own request.
410
+ A recovered chunk is not a warning, but the failure and its retries stay
411
+ visible: the report adds a `recovery` line whenever a first request failed,
412
+ for example `1 failed chunk, 2 retry requests, 1 recovered`. `--json` carries
413
+ the same numbers as `recovery.failedChunks`, `retryRequests`, and
414
+ `recoveredChunks`.
415
+
416
+ In `watch`, every portfolio sample that needed that work also emits a
417
+ `chunk-recovery` event before the sample's `portfolio` event, in both the
418
+ `--json` stream and the NDJSON log: `chunk`, `chunks`, `tokens`, `status`
419
+ (`recovered`, `partial`, or `failed`), the chunk's `error`, and the `individual`
420
+ addresses that were re-read on their own. Chunks that batched cleanly emit
421
+ nothing, so the log stays quiet on a healthy RPC.
422
+
423
+ If a chunk still fails, it is retried once as two smaller sub-batches before its
424
+ remaining tokens are re-read individually, so a recovered chunk carries no
425
+ warning and only a genuinely failed one costs its own tokens. `scan` and `watch`
426
+ accept the same flag.
427
+
428
+ `--fail-on-retry` turns any of that recovery work into a non-zero exit (status
429
+ 1), which makes flaky RPC batching visible to CI even when every balance was
430
+ ultimately read: it triggers when a chunk's first Multicall3 request failed,
431
+ whether the retry recovered it or its tokens fell back to individual reads.
432
+ Output is unchanged — the table or `--json` still prints — so the exit code is
433
+ the only signal. `balances` and `scan` both accept it:
434
+
435
+ ```bash
436
+ npm run balances -- 0xOwner --tokens-file ./examples/xlayer.tokens.json --fail-on-retry
437
+ npm run scan -- 0xOwner --tokens-file ./examples/xlayer.tokens.json --fail-on-retry
438
+ ```
439
+
440
+ `--verbose` prints one line per Multicall3 chunk to stderr as it settles,
441
+ including the retry outcome and how many tokens fell back to individual reads:
442
+
443
+ ```
444
+ chunk 0x0000…dEaD 1/3 (12 tokens) ok
445
+ chunk 0x0000…dEaD 2/3 (12 tokens) retried, recovered
446
+ chunk 0x0000…dEaD 3/3 (4 tokens) failed, 4 individual
447
+ ```
448
+
449
+ It is off by default, and `--json`/`--csv` stdout stays clean. `--verbose` and
450
+ `--fail-on-retry` can be combined: the progress explains why a run exits 1.
451
+
452
+ Tokens are optional because JSON-RPC cannot enumerate an address's holdings; you
453
+ list the contracts you care about. Addresses are checksummed, duplicates
454
+ collapsed, and `--json` returns the same data with bigints as decimal strings,
455
+ plus the `batchSize` selected for the read and the resulting `chunkCount`.
456
+
457
+ ### Metadata cache
458
+
459
+ `decimals` and `symbol` are cached per chain in `.lwc-cache/token-metadata.json`
460
+ and reused: a repeat check sends two sub-calls per known token (`balanceOf`,
461
+ `totalSupply`) instead of four, `scan` reuses the same entries for every owner
462
+ instead of re-reading metadata per owner, and fully cached `balances`/`scan`
463
+ lists pack 24 tokens per chunk instead of 12. Supply is deliberately never
464
+ cached because minting and burning change it. Only clean metadata is stored — a
465
+ token whose symbol or decimals did not decode is left out of the cache so it is
466
+ re-examined next time instead of remembered as broken. Each entry records a
467
+ `cachedAt` epoch timestamp; `MetadataCache.ageMs()` reports how old it is.
468
+ Version-1 entries remain readable and report no age until refreshed.
469
+
470
+ `balances` and `scan` mark each token whose metadata came from the cache with
471
+ its age, for example `[cached 3d]`, and `--json` includes `cacheAgeMs` per token
472
+ (null for freshly read metadata or a legacy entry without a timestamp), so a
473
+ stale symbol or decimals value is visible before it is refreshed. `--no-cache`
474
+ and `--refresh-cache` therefore show no age for the affected tokens.
475
+
476
+ `--max-cache-age <duration>` refreshes entries automatically once they are older
477
+ than the given age: `30m`, `12h`, `7d`, or `2w` (the `s`, `m`, `h`, `d`, and `w`
478
+ suffixes are required, so a bare number is never guessed). Stale entries are
479
+ dropped before the read, count as cache misses, and are fetched again; undated
480
+ version-1 entries are refreshed too because their age cannot be checked. The
481
+ flag takes a space or `=`, and works for both commands:
482
+
483
+ ```bash
484
+ npm run balances -- 0xYourAddress --token 0xTokenA --max-cache-age 7d
485
+ npm run scan -- 0xAddrA 0xAddrB --tokens-file ./examples/xlayer.tokens.json --max-cache-age=12h
486
+ ```
487
+
488
+ `--stale-after <duration>` is the warning-only counterpart: it never refreshes
489
+ or drops an entry, but each cached token older than the threshold prints a
490
+ warning to stderr while still showing its `[cached …]` age in the table:
491
+
492
+ ```
493
+ warning: USDT (0x1E4a…D41d) metadata is 3d old, stale after 1d
494
+ ```
495
+
496
+ It takes the same durations as `--max-cache-age` and works in `balances` and
497
+ `scan`. Combine the two to refresh very old entries while still warning about
498
+ merely old ones:
499
+
500
+ ```bash
501
+ npm run balances -- 0xYourAddress --token 0xTokenA --stale-after 7d --max-cache-age 30d
502
+ ```
503
+
504
+ ```bash
505
+ npm run balances -- 0xYourAddress --token 0xTokenA --no-cache # skip the cache
506
+ npm run balances -- 0xYourAddress --token 0xTokenA --refresh-cache # re-read metadata
507
+ npm run balances -- 0xYourAddress --token 0xTokenA --cache-file ./tmp/tokens.json
508
+ LWC_CACHE_FILE=./tmp/tokens.json npm run scan 0xAddrA 0xAddrB
509
+ ```
510
+
511
+ `--refresh-cache` drops the requested tokens' cached entries before reading, so
512
+ a rebranded token's new symbol is fetched and stored. `--no-cache` disables the
513
+ cache entirely, `--cache-file <path>` (or `LWC_CACHE_FILE`) relocates it, and
514
+ the default file is git-ignored. JSON output from `balances` and `scan` includes
515
+ `cache: { "hits": n, "misses": n }`; hits are unique ERC-20 tokens served from
516
+ the cache, misses were read again, and precompiles are excluded. `--no-cache`
517
+ reports zero for both. The cache is keyed by chain id, so pointing
518
+ `LWC_RPC_URL` at another network never mixes metadata. A missing or damaged
519
+ cache file is ignored, and if the file cannot be written the read still succeeds
520
+ — the warning goes to stderr.
521
+
522
+ ### Inspecting and clearing cached metadata
523
+
524
+ `cache` lists the metadata entries for the configured chain, oldest first, with
525
+ their age:
526
+
527
+ ```bash
528
+ npm run cache
529
+ npm run cache -- --json
530
+ npm run cache -- --cache-file ./tmp/tokens.json
531
+ ```
532
+
533
+ ```
534
+ lwc — cache
535
+
536
+ file .lwc-cache/token-metadata.json
537
+ chain xlayer (196)
538
+ entries 2
539
+
540
+ token 0x1E4a…D41d USDT (6 decimals) cached 3d
541
+ token 0xe538…9b2b WOKB (18 decimals) cached 1h
542
+ ```
543
+
544
+ `--clear <address>` removes selected entries and writes the cache; repeat it or
545
+ pass a comma-separated list. Addresses that are not cached are reported as
546
+ warnings, and the remaining entries are printed afterwards:
547
+
548
+ ```bash
549
+ npm run cache -- --clear 0xTokenA
550
+ npm run cache -- --clear 0xTokenA,0xTokenB --json
551
+ ```
552
+
553
+ `--json` returns `entries` and `removed` arrays with `address`, `symbol`,
554
+ `decimals`, `cachedAt` (epoch milliseconds, or `null` for a legacy entry), and
555
+ `ageMs` (`null` when the age is unknown), plus `warnings`. `--no-cache` is
556
+ rejected because there is no cache to inspect. `--clear` only affects the file
557
+ and never re-reads anything on chain.
558
+
559
+ ### Saving a token list
560
+
561
+ `tokens` writes the list format `--tokens-file` reads, from addresses you pass on
562
+ the command line:
563
+
564
+ ```bash
565
+ npm run tokens -- 0xTokenA 0xTokenB --out ./examples/my.tokens.json
566
+ npm run tokens -- --tokens 0xTokenA,0xTokenB --out - # print instead of write
567
+ npm run tokens -- "0xTokenA=Tether USD" 0xTokenB --out ./examples/my.tokens.json
568
+ ```
569
+
570
+ ```
571
+ wrote 2 tokens → ./examples/my.tokens.json
572
+ ```
573
+
574
+ Addresses are checksummed, duplicates collapse, and order is preserved. An inline
575
+ label — `0xAddress=Label`, or `0xAddress:Label`, with the same 64-character limit
576
+ as elsewhere — is kept in the written file, so the list reads back with its
577
+ labels:
578
+
579
+ ```json
580
+ [
581
+ { "address": "0x1E4a5963aBFD975d8c9021ce480b42188849D41d", "label": "Tether USD" },
582
+ "0xe538905cf8410324e03A5A23C1c177a474D59b2b"
583
+ ]
584
+ ```
585
+
586
+ An unlabeled address stays a plain string. When a repeated address carries a
587
+ different label, the last one wins and a warning goes to stderr.
588
+
589
+ Each address is read on chain before the list is written — `decimals`, `symbol`
590
+ and `totalSupply` through Multicall3 — and anything that does not answer like an
591
+ ERC-20 is reported without stopping the write:
592
+
593
+ ```
594
+ warning: token 0x0000…dEaD is not a readable ERC-20: no contract code (every call returned no data)
595
+ ```
596
+
597
+ Warnings go to stderr, so `--out -` and `--json` stay clean. An existing file is
598
+ never replaced unless you pass `--force`, the parent directory is created if
599
+ needed, and `--out -` prints the JSON array (which `--json` turns into
600
+ `{ ok, path, tokens, chunkCount, maxSubCalls }`, with `tokens` in the same
601
+ string-or-object shape as the file). `chunkCount` is the number of Multicall3
602
+ verification requests sent, and `maxSubCalls` is the cap used, or `null` when
603
+ the default token batch size applied.
604
+
605
+ `--max-subcalls <n>` caps each verification request at a fixed number of
606
+ sub-calls (three per token) rather than a token count, so strict RPCs get
607
+ smaller calls; a single token always gets a request of its own. It accepts 1–1000
608
+ and applies only to the on-chain check, never to the written list:
609
+
610
+ ```bash
611
+ npm run tokens -- --tokens 0xTokenA,0xTokenB --out - --max-subcalls 30
612
+ ```
613
+
614
+ Pass `--merge` to add to a list that already exists instead of replacing it:
615
+
616
+ ```bash
617
+ npm run tokens -- "0xNewToken=New Token" --out ./examples/my.tokens.json --merge
618
+ ```
619
+
620
+ The file's own entries keep their order and labels, and addresses already there
621
+ are not repeated — only genuinely new ones are appended, then the merged list is
622
+ written back. A label passed for an address already in the file replaces the old
623
+ one and warns on stderr when the two differ; an unlabeled duplicate keeps the
624
+ existing label. `--merge` creates the file when it is missing and does not need
625
+ `--force`, but it needs a real path, so `--out -` is rejected.
626
+
627
+ `--tokens-file <path>` reads that list from disk so routine checks do not repeat
628
+ addresses: a JSON array of addresses, or an object with a `tokens` array. Each
629
+ entry may also be an object with a `label`, which is shown next to the symbol so
630
+ two similarly named tokens stay distinguishable:
631
+
632
+ ```json
633
+ [
634
+ { "address": "0x1E4a5963aBFD975d8c9021ce480b42188849D41d", "label": "Tether USD" },
635
+ { "address": "0xe538905cf8410324e03A5A23C1c177a474D59b2b", "label": "Wrapped OKB" },
636
+ "0x000000000000000000000000000000000000dEaD"
637
+ ]
638
+ ```
639
+
640
+ Ad-hoc `--token` flags accept an inline label too: `0xAddress:Label` (or
641
+ `0xAddress=Label`), for example `--token "0xTokenA:Tether USD"`. The same
642
+ 64-character limit applies, and `--tokens` accepts the syntax as well. Labels
643
+ are cosmetic: they appear in the table (`0.028275 USDT [Tether USD] (0x1E4a…)`)
644
+ and in `--json` as a `label` field, but never change what is read on chain and
645
+ are not cached. Inline `--token`/`--tokens` labels override a conflicting label
646
+ from `--tokens-file`; when the two differ, a warning naming both is printed to
647
+ stderr. Conflicting repeated labels from the same source keep the last one and
648
+ warn. JSON stdout stays clean. `scan` uses the label in its column headers and
649
+ JSON tokens. The flag can be repeated and combines with `--token`/`--tokens`,
650
+ still deduped by address. `examples/xlayer.tokens.json` starts you off with USDT
651
+ and WOKB. An unreadable file, invalid JSON, or a bad entry fails with a usage
652
+ error naming the file and the entry number, before any network call.
653
+
654
+ ## Comparing RPC endpoints
655
+
656
+ `compare` probes two endpoints in the same rounds, in parallel, and reports which
657
+ one answers faster and which one has the fresher head:
658
+
659
+ ```bash
660
+ npm run compare -- drpc=https://xlayer.drpc.org okx=https://xlayerrpc.okx.com --samples 5 --interval 1
661
+ ```
662
+
663
+ ```
664
+ lwc — compare
665
+
666
+ chain xlayer (196)
667
+ samples 5 every 1s
668
+ new blocks 5
669
+
670
+ endpoint p50 p95 head lag behind wins delay age err
671
+ drpc 142ms 310ms 75419519 1 5/5 3 0ms 0s 0
672
+ okx 168ms 402ms 75419520 0 0/5 2 34ms 0s 0
673
+
674
+ fastest drpc (p50 142 ms)
675
+ freshest okx (head 75419520)
676
+ ```
677
+
678
+ `p50`/`p95` are the round-trip times for `eth_blockNumber`, measured per endpoint
679
+ with the cache disabled so every round hits the network. `lag` is how many blocks
680
+ behind the freshest endpoint it finished; `behind` is how many rounds it was
681
+ behind at all, which separates a consistently stale endpoint from one that merely
682
+ lost the race once. `age` is the age of the head block in seconds.
683
+
684
+ `wins` counts the heads this endpoint reported before any other endpoint, and
685
+ `delay` is the median time after the first report of the same head, so an endpoint
686
+ that answers quickly but is late to serve a new block shows up here instead of
687
+ looking good on latency alone. `new blocks` is the number of distinct heads that
688
+ appeared after the baseline round; when two endpoints serve the same head in one
689
+ round, `delay` still orders them by their response times. `--json` adds the
690
+ `observed` and `misses` counts for each endpoint's `delivery`.
691
+
692
+ `--samples` (default 5) and `--interval` (default 1s) control the rounds.
693
+
694
+ Prefix an endpoint with `label=` to name it in the table, warnings, and JSON,
695
+ and give providers separate keys with one `--endpoint-auth "label=Bearer …"`
696
+ per endpoint:
697
+
698
+ ```bash
699
+ npm run compare -- drpc=https://xlayer.drpc.org okx=https://xlayerrpc.okx.com \
700
+ --endpoint-auth "drpc=Bearer $DRPC_KEY" --endpoint-auth "okx=Bearer $OKX_KEY"
701
+ ```
702
+
703
+ An endpoint without its own header inherits `LWC_RPC_AUTH`, and
704
+ `--endpoint-auth` also accepts a full URL as the key. Labels must be unique and
705
+ use letters, digits, dot, dash, or underscore.
706
+
707
+ A labeled endpoint also picks up its key automatically from
708
+ `LWC_RPC_AUTH_<LABEL>`: the label is uppercased and any dot, dash, or underscore
709
+ becomes an underscore, so `drpc=` reads `LWC_RPC_AUTH_DRPC` and `my-node=` reads
710
+ `LWC_RPC_AUTH_MY_NODE`. Keep one key per provider in `.env` and the command
711
+ stays short:
712
+
713
+ ```bash
714
+ # .env
715
+ # LWC_RPC_AUTH_DRPC=Bearer …
716
+ # LWC_RPC_AUTH_OKX=Bearer …
717
+ npm run compare -- drpc=https://xlayer.drpc.org okx=https://xlayerrpc.okx.com
718
+ ```
719
+
720
+ Values are trimmed and must be a single line; an unset or empty variable leaves
721
+ the endpoint on `LWC_RPC_AUTH`. An explicit `--endpoint-auth` always wins, and
722
+ an unlabeled endpoint has no name to look up, so it only uses `LWC_RPC_AUTH` or
723
+ `--endpoint-auth`.
724
+
725
+ Write `env:NAME` as the value to read the header from environment variable
726
+ `NAME` at parse time, so the key never enters argv, shell history, or process
727
+ listings:
728
+
729
+ ```bash
730
+ npm run compare -- drpc=https://xlayer.drpc.org okx=https://xlayerrpc.okx.com \
731
+ --endpoint-auth drpc=env:DRPC_KEY --endpoint-auth okx=env:OKX_KEY
732
+ ```
733
+
734
+ `NAME` must be a valid environment variable name and must be set and non-empty
735
+ when `compare` starts; a missing value fails with an error naming the variable
736
+ before any request is made, and the resolved header is still rejected if it
737
+ contains a newline. Inline values such as `drpc=Bearer …` remain supported but
738
+ are visible in process listings.
739
+
740
+ `--json` reports the same numbers with bigints as strings, and the exit code is
741
+ `1` only if an endpoint failed a round or reported the wrong chain id — being a
742
+ block behind is reported, not treated as a failure. Both RPCs are pinned to
743
+ `LWC_CHAIN_ID`, so pointing one at another network shows up as
744
+ `answered chain 1, expected 196` instead of a bogus latency comparison.
745
+
746
+ ### Logging comparisons
747
+
748
+ `--log <path>` appends one JSON line per run, so repeated comparisons show which
749
+ provider wins over time:
750
+
751
+ ```bash
752
+ npm run compare -- drpc=https://xlayer.drpc.org okx=https://xlayerrpc.okx.com \
753
+ --log ./compare.ndjson
754
+ ```
755
+
756
+ ```json
757
+ {"session":"m3k2-9f1a2c","event":"compare","at":"2026-11-09T12:00:00.000Z","chain":{"id":196,"name":"xlayer"},"samples":5,"intervalMs":1000,"newBlocks":5,"fastest":"https://xlayer.drpc.org","freshest":"https://xlayerrpc.okx.com","endpoints":[{"url":"https://xlayer.drpc.org","label":"drpc","ok":true,"p50":142,"p95":310,"head":"75419519","headLag":1,"roundsBehind":1,"headAgeSeconds":0,"delivery":{"wins":3,"observed":5,"misses":0,"p50Ms":0,"p95Ms":24},"errors":0,"chainMismatch":false,"error":null}]}
758
+ ```
759
+
760
+ Every line carries a `session` id, the `fastest` and `freshest` URLs, and each
761
+ endpoint's p50/p95, head, lag, delivery (`wins`/`observed`/`misses` and p50/p95
762
+ delay), and error count, so
763
+ `jq -r '[.session, .fastest] | @tsv' compare.ndjson` lists the winner over time.
764
+ Endpoint URLs are recorded as origin and path with query strings, fragments, and
765
+ userinfo removed, and URLs inside error messages are sanitized the same way, so
766
+ an API key in an endpoint URL never reaches the log; `--endpoint-auth` values and
767
+ auth headers are never written at all. `--log-max-size` (same sizes as `watch`)
768
+ rotates the file to `<log>.1` past the limit and requires `--log`.
769
+
770
+ `compare-history <compare.ndjson>` summarizes the log across runs without
771
+ contacting an endpoint, so it needs no `LWC_RPC_URL` or auth setup:
772
+
773
+ ```bash
774
+ npm run compare-history -- ./compare.ndjson
775
+ ```
776
+
777
+ ```
778
+ lwc — compare history
779
+
780
+ log ./compare.ndjson
781
+ runs 3 (2026-11-09T12:00:00.000Z → 2026-11-09T12:10:00.000Z)
782
+ new blocks 15
783
+ chain xlayer (196)
784
+
785
+ provider runs wins win% delay last last seen
786
+ drpc 3 11 73% 4ms 0ms 11-09 12:10
787
+ okx 3 4 27% 31ms 34ms 11-09 12:10
788
+ ```
789
+
790
+ `wins` totals the heads each provider delivered first, `win%` is those wins as a
791
+ share of the new blocks in the runs where it appeared, and `delay` is the median
792
+ across runs of each run's own median head delay; `last` is the newest of those
793
+ medians. Malformed lines are counted, not fatal, and other event types in a
794
+ mixed log are ignored. `--json` adds the per-run series under `history` so one
795
+ file can be charted over time.
796
+
797
+ `--csv <path>` writes one row per run and provider for spreadsheet analysis,
798
+ repeating the run's session, chain, and sample counts on each row so the file
799
+ pivots by provider, run, or time without parsing nested JSON:
800
+
801
+ ```bash
802
+ npm run compare-history -- ./compare.ndjson --csv ./providers.csv
803
+ ```
804
+
805
+ ```
806
+ session,at,chain,chain_id,samples,new_blocks,provider,provider_url,wins,observed,misses,delay_ms,errors,ok
807
+ s-mw8la2ze,2026-11-09T12:00:00.000Z,xlayer,196,5,4,drpc,https://xlayer.drpc.org,3,4,0,0,0,true
808
+ s-mw8la2ze,2026-11-09T12:00:00.000Z,xlayer,196,5,4,okx,https://xlayerrpc.okx.com,1,4,0,40,0,true
809
+ ```
810
+
811
+ `provider` is the label, or the sanitized URL when the run had none;
812
+ `delay_ms` is empty when that provider saw no new heads in the run. `--csv -`
813
+ prints the CSV instead of writing it, the parent directory is created as needed,
814
+ and `--json` still returns the nested report when no CSV is requested.
815
+
816
+ ## Checking many addresses
817
+
818
+ `scan` reads the same token set for a list of owners, and can write the result as
819
+ CSV:
820
+
821
+ ```bash
822
+ npm run scan -- 0xAddrA 0xAddrB --tokens-file ./examples/xlayer.tokens.json
823
+ npm run scan -- --owners-file ./owners.json --tokens-file ./examples/xlayer.tokens.json --csv ./balances.csv
824
+ ```
825
+
826
+ ```
827
+ lwc — scan
828
+
829
+ chain xlayer (196)
830
+ block 75418547
831
+ owners 2
832
+ tokens 2
833
+
834
+ owner 0x000000000000000000000000000000000000dEaD
835
+ native 61.997998034799592649 OKB
836
+ USDT (0x1E4a…D41d) 0.028275
837
+ WOKB (0xe538…9b2b) 280.072887730740496648
838
+ ```
839
+
840
+ Owners come from positional addresses, `--owner`, or `--owners-file` (a JSON
841
+ array, or an object with an `owners` or `addresses` array); tokens from
842
+ `--token`, `--tokens`, `--tokens-file`, with the same checksumming and dedupe as
843
+ `balances`. Each owner still costs one Multicall3 call, four owners at a time, so
844
+ a hundred addresses are a hundred calls rather than thousands. The `block` line
845
+ is the head recorded at the start of the scan.
846
+
847
+ `--csv <path>` writes a header of `owner`, `native (<symbol>)`, then one column
848
+ per token labelled `<symbol> (<0x…>)`, a `labels` column mapping labelled tokens
849
+ to their names, and a final `flag` column listing every flagged token and its
850
+ issue (empty when none; multiple flags are separated by ` | `), with one row per
851
+ owner. Amounts are decimal, not raw wei; a token that could not be read leaves
852
+ an empty cell rather than a wrong number. Fields containing a comma, quote, or
853
+ newline are quoted and internal quotes doubled, so the file opens cleanly in a
854
+ spreadsheet. `--csv -` prints the CSV instead of writing a file, and `--csv`
855
+ cannot be combined with `--json`.
856
+
857
+ Each Multicall3 chunk also reports its outcome on stderr as it completes, keeping
858
+ `--json` and `--csv` stdout clean:
859
+
860
+ ```
861
+ chunk 0x0000…dEaD 1/3 (12 tokens) ok
862
+ chunk 0x0000…dEaD 2/3 (12 tokens) retried, partial, 6 individual
863
+ ```
864
+
865
+ `--quiet` suppresses those per-chunk lines while keeping everything else: the
866
+ final table (including warnings and the fallback list), `--csv`, and `--json`
867
+ are unchanged.
868
+
869
+ `--fail-on-retry` exits with status 1 when any chunk needed recovery — its first
870
+ Multicall3 request failed, whether or not the retry or individual fallback
871
+ recovered the tokens. It composes with `--quiet`, `--csv`, and `--json`, which
872
+ all keep their normal output, so a CI job can gate on the exit code while still
873
+ archiving the report.
874
+
875
+ If a chunk still cannot be batched, the final report lists exactly which tokens
876
+ were read individually, with the owner and chunk they came from:
877
+
878
+ ```
879
+ fallbacks 1 token read individually
880
+ 0x0000…dEaD chunk 2/3 0x1E4a5963aBFD975d8c9021ce480b42188849D41d USDT
881
+ ```
882
+
883
+ `--json` exposes the same detail as `chunks` (status, retry outcome, per-chunk
884
+ individual addresses) and `fallbacks` (owner, chunk, token, symbol, label), plus
885
+ the selected `batchSize` and total `chunkCount` across owners. The
886
+ `recovery` line and JSON object count first-attempt failures, retry requests,
887
+ and fully recovered chunks across all owners, so a chunk rescued by its retry
888
+ is still visible in the summary and per-owner chunk status.
889
+
890
+ ### Charting balance history
891
+
892
+ `history` turns the `portfolio` events in a watch log into a CSV time series,
893
+ one row per asset per sample, ready for a spreadsheet or chart:
894
+
895
+ ```bash
896
+ npm run watch -- --interval 30 --owner 0xYourAddress \
897
+ --tokens-file ./examples/xlayer.tokens.json --log ./watch.ndjson
898
+ npm run history -- ./watch.ndjson --csv ./balances.csv
899
+ ```
900
+
901
+ ```
902
+ session,at,block,asset,address,symbol,label,decimals,balance,delta,usd,usd_total
903
+ mw8la2ze-bc6f92,2026-11-09T12:31:05.000Z,75418547,native,,OKB,,18,1.25,,124.21,126.71
904
+ mw8la2ze-bc6f92,2026-11-09T12:31:05.000Z,75418547,token,0x1E4a…D41d,USDT,Tether USD,6,2.5,1.5,2.50,126.71
905
+ ```
906
+
907
+ `at` is ISO 8601 in UTC, `balance` and `delta` are decimal amounts scaled by the
908
+ token's `decimals` (raw base units when it could not be read), and `delta` is
909
+ empty for the first sample of a session. Long format keeps the columns stable as
910
+ the token set changes: pivot on `at` with `symbol` as the series if your chart
911
+ tool wants one column per asset. `usd` is the asset's value at the sample's
912
+ prices and `usd_total` repeats the sample total; both are empty unless the log
913
+ came from `watch --usd`.
914
+
915
+ `--csv -` prints the CSV instead of writing it, `--session <id>` keeps one run
916
+ from a log that holds several, and without `--csv` the rows are printed as a
917
+ table. Logs are read locally — no RPC call — and a portfolio sample that failed
918
+ is counted in the header rather than written as blank rows. `--json` emits the
919
+ same rows with bigints as decimal strings.
920
+
921
+ `--wide` pivots the same log the other way: one row per portfolio sample and one
922
+ column per asset, for tools that expect a single time series per column.
923
+
924
+ ```bash
925
+ npm run history -- ./watch.ndjson --csv ./balances-wide.csv --wide
926
+ npm run history -- ./watch.ndjson --csv ./balances-wide-usd.csv --wide --usd
927
+ npm run history -- ./watch.ndjson --csv ./balances-wide-usd-only.csv --wide --usd-only
928
+ ```
929
+
930
+ ```
931
+ session,at,block,OKB,USDT,WOKB,usd_total
932
+ mw8la2ze-bc6f92,2026-11-09T12:31:05.000Z,75418547,1.25,2.5,280.07,126.71
933
+ mw8la2ze-bc6f92,2026-11-09T12:31:35.000Z,75418548,1.2,2.5,281.57,123.93
934
+ ```
935
+
936
+ Columns follow the order the assets first appear, named by symbol; two assets
937
+ sharing a ticker gain a short address (or `native`) suffix so each keeps its own
938
+ series. An asset absent from a sample leaves its cell empty rather than
939
+ repeating an old value, `session`, `at`, and `block` stay as the first three
940
+ columns so a multi-session log remains distinguishable, and amounts are decimal
941
+ like the long format. `usd_total` is the sample's portfolio total, empty unless
942
+ the log came from `watch --usd`. Add `--usd` to append a `SYMBOL (USD)` column
943
+ for every asset after the balance columns; an unpriced asset leaves its USD cell
944
+ empty. `--usd-only` keeps just those USD columns and drops the raw balances, so
945
+ every asset column is a dollar series, with `usd_total` last:
946
+
947
+ ```
948
+ session,at,block,OKB (USD),USDT (USD),WOKB (USD),usd_total
949
+ mw8la2ze-bc6f92,2026-11-09T12:31:05.000Z,75418547,113.83,2.50,10.38,126.71
950
+ ```
951
+
952
+ `--wide` requires `--csv` (`--csv -` prints the pivot), `--usd` and `--usd-only`
953
+ require `--wide`, and `--json` continues to emit the long-format rows.
954
+
955
+ ### Rendering the USD total in the terminal
956
+
957
+ `plot` reads either history CSV — the long table or the `--wide` pivot — and
958
+ draws the portfolio's USD total as a sparkline, so a quick look needs no
959
+ spreadsheet or chart tool:
960
+
961
+ ```bash
962
+ npm run history -- ./watch.ndjson --csv ./balances.csv
963
+ npm run plot -- ./balances.csv
964
+ ```
965
+
966
+ ```
967
+ lwc — portfolio USD total
968
+
969
+ csv ./balances.csv
970
+ samples 3
971
+ window 2026-11-09T12:00:01.000Z → 2026-11-09T12:01:01.000Z
972
+ total $101.50 → $93.00 (-$8.50, -8.37%)
973
+ range $93.00 – $101.50
974
+
975
+ $101.50 ┤█▅▄
976
+ $93.00 ┤
977
+ └─ 12:00:01 → 12:01:01 UTC
978
+ ```
979
+
980
+ Each portfolio sample becomes one point even though the long CSV repeats
981
+ `usd_total` on every asset row, so a three-asset sample is not charted three
982
+ times. When `usd_total` is empty — a partly priced sample — the row-level `usd`
983
+ values are summed instead, and a sample with no priced holdings at all is
984
+ counted as `unpriced` rather than drawn as zero. `--width <n>` sets the
985
+ sparkline width (10–200, default 48); a longer series is averaged into that many
986
+ buckets, and a flat series draws level. `--json` returns the series (`session`,
987
+ `at`, `usdTotal`), first/last/min/max, change, and the chart string.
988
+
989
+ The CSV can be either format written by `history --csv`: the long table, or the
990
+ `--wide` pivot, whose `usd_total` column is charted directly — including the
991
+ `--usd-only` pivot, where every asset column is already a dollar value. A file
992
+ with no `usd_total` column at all is rejected, and `watch` must have run with
993
+ `--usd` for samples to be priced.
994
+
995
+ ## Typed contract calls
996
+
997
+ `readTyped` checks the function name and arguments against the ABI at compile
998
+ time and derives the return type from the function's outputs, so neither the
999
+ function name nor the result needs a cast:
1000
+
1001
+ Typical use, with the client from `buildClient`:
1002
+
1003
+ ```ts
1004
+ import { erc20Abi } from "viem";
1005
+ import { readTyped } from "./src/index.ts";
1006
+
1007
+ const balance = await readTyped(
1008
+ client,
1009
+ { address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", abi: erc20Abi },
1010
+ "balanceOf",
1011
+ "0xYourAddress",
1012
+ );
1013
+ // balance is bigint — inferred from the ABI, no cast needed
1014
+ ```
1015
+
1016
+ `createReader(client, { address, abi })` binds the contract once and returns a
1017
+ callable for repeated reads:
1018
+
1019
+ ```ts
1020
+ const usdc = createReader(client, { address: USDC, abi: erc20Abi });
1021
+ const symbol = await usdc("symbol"); // string
1022
+ const decimals = await usdc("decimals"); // number
1023
+ ```
1024
+
1025
+ ### Running every view function
1026
+
1027
+ `views` lists every `view`/`pure` function in an ABI file and calls the ones it
1028
+ can against a contract:
1029
+
1030
+ ```bash
1031
+ npm run views -- 0xToken
1032
+ npm run views -- 0xToken --args '["0xYourAddress"]'
1033
+ npm run views -- 0xToken --args '{"allowance":["0xOwner","0xSpender"]}'
1034
+ npm run views -- 0xToken --abi ./examples/erc20.abi.json
1035
+ npm run views -- 0xToken --block 75416693
1036
+ ```
1037
+
1038
+ ```
1039
+ lwc — view calls
1040
+
1041
+ contract 0x1E4a5963aBFD975d8c9021ce480b42188849D41d
1042
+ abi built-in ERC-20
1043
+ functions 6 view/pure: 4 called, 0 failed, 2 skipped
1044
+ block 75421067
1045
+
1046
+ name() Tether USD
1047
+ symbol() USDT
1048
+ decimals() 6
1049
+ totalSupply() 123456789000000
1050
+ balanceOf(address) skipped: needs 1 argument(s)
1051
+ allowance(address, address) skipped: needs 2 argument(s)
1052
+ ```
1053
+
1054
+ `--abi` is optional: without it the built-in ERC-20 ABI is used, which covers
1055
+ `name`, `symbol`, `decimals`, `totalSupply`, `balanceOf`, and `allowance`.
1056
+ Pass `--abi` for any other contract.
1057
+
1058
+ Zero-argument functions run immediately. A function that needs arguments is
1059
+ listed as skipped unless `--args` supplies them: a JSON array is applied to
1060
+ every function whose arity matches (zero-argument functions still run), while an
1061
+ object keyed by function name targets exactly that function — use a full
1062
+ signature such as `balanceOf(address)` or `set((bool,bytes))` to pick one
1063
+ overload, and whitespace in a
1064
+ key is ignored. A revert or a bad
1065
+ argument type fails only that row, `--json` carries the same rows with bigints as
1066
+ strings, and the exit code is `1` if any call failed. Non-`view` functions in the
1067
+ ABI are listed nowhere and never called.
1068
+
1069
+ Every callable function is packed into a single Multicall3 `aggregate3` request
1070
+ pinned to the block in the header, so a wide ABI costs one round trip instead of
1071
+ one per getter. `allowFailure` keeps a reverting getter in its own `error:` row —
1072
+ a standard `revert("…")` reason is decoded — while its neighbours still return
1073
+ values. If the aggregate itself cannot be used (no Multicall3 on the chain, or
1074
+ an RPC that rejects it), the same calls are retried individually, so the output
1075
+ is identical, just slower.
1076
+
1077
+ `--max-subcalls <n>` caps each `aggregate3` request at `n` sub-calls (1–1000),
1078
+ splitting a wide ABI across several requests pinned to the same block. Each
1079
+ request keeps `allowFailure`, and only a group whose batch fails falls back to
1080
+ individual reads. The header reports the request count when the flag is used,
1081
+ and `--json` adds `chunkCount` and `maxSubCalls`:
1082
+
1083
+ ```bash
1084
+ npm run views -- 0xToken --max-subcalls 10
1085
+ ```
1086
+
1087
+ `--block` pins the run to a block number (or `latest`, the default), so repeating
1088
+ the command against the same block returns the same values: the header reports
1089
+ that block, every read is made at it, and the head is never looked up.
1090
+
1091
+ ### Listing callable signatures
1092
+
1093
+ `signatures` prints every callable (`view`/`pure`) signature in an ABI, sorted
1094
+ and ready to copy into `views --args` or a `call` function argument:
1095
+
1096
+ ```bash
1097
+ npm run signatures -- ./examples/erc20.abi.json
1098
+ npm run signatures # built-in ERC-20 ABI
1099
+ npm run signatures -- --json
1100
+ ```
1101
+
1102
+ ```
1103
+ lwc — callable signatures
1104
+
1105
+ abi examples/erc20.abi.json
1106
+ signatures 6 view/pure
1107
+
1108
+ allowance(address, address)
1109
+ balanceOf(address)
1110
+ decimals()
1111
+ name()
1112
+ symbol()
1113
+ totalSupply()
1114
+ ```
1115
+
1116
+ Tuple components are expanded, so overloads that differ only inside a tuple
1117
+ produce distinct lines, exactly as they must be written in `--args`.
1118
+
1119
+ ### Example call
1120
+
1121
+ ```bash
1122
+ npm run example
1123
+ npm run example -- --json
1124
+ ```
1125
+
1126
+ Reads three view functions from the canonical Multicall3 deployment
1127
+ (`0xcA11bde05977b3631167028862bE2a173976CA11`), which exists on most EVM chains:
1128
+
1129
+ ```
1130
+ lwc — example contract call
1131
+
1132
+ contract Multicall3 (0xcA11bde05977b3631167028862bE2a173976CA11)
1133
+ chain xlayer (196)
1134
+ latency 412 ms
1135
+
1136
+ getChainId() 196 matches LWC_CHAIN_ID
1137
+ getBlockNumber() 75416480
1138
+ getCurrentBlockTimestamp() 1794185600 (4s old)
1139
+ ```
1140
+
1141
+ The example is self-verifying: `getChainId()` must equal `LWC_CHAIN_ID`, so the
1142
+ contract's answer cross-checks the RPC's. If Multicall3 is not deployed on your
1143
+ chain, the command says so explicitly instead of returning a decode error.
1144
+
1145
+ ## Watch mode
1146
+
1147
+ ```bash
1148
+ npm run watch
1149
+ npm run watch -- --interval 3 --stall-after 30
1150
+ npm run watch -- --samples 5 # stop after 5 polls
1151
+ npm run watch -- --json # newline-delimited JSON events
1152
+ npm run watch -- --owner 0xYourAddress --tokens-file ./examples/xlayer.tokens.json
1153
+ ```
1154
+
1155
+ Polls block height, gas price, and chain id, then reports what changed:
1156
+
1157
+ ```
1158
+ lwc — watching xlayer (196) every 3s, stall threshold 30s, gas surge +50%
1159
+
1160
+ 12:00:00 block 75416693 (+0) 0.021 gwei 794 ms
1161
+ 12:00:03 block 75416695 (+2) 0.021 gwei 187 ms
1162
+ 12:00:06 block 75416695 (unchanged 3s) 0.021 gwei 165 ms
1163
+ 12:00:39 STALL: block 75416695 has not advanced for 33s
1164
+ 12:00:42 RECOVERED: 75416695 → 75416698 after 36s
1165
+ 12:00:45 CHAIN DRIFT: expected 196 (xlayer), RPC answered 1
1166
+ 12:00:48 GAS SURGE: 0.063 gwei, +200% over 0.021 gwei baseline
1167
+ 12:01:10 GAS NORMAL: 0.022 gwei (baseline 0.021 gwei)
1168
+ ```
1169
+
1170
+ Events: `advance` (new block), `hold` (unchanged, below threshold), `stall`
1171
+ (unchanged past the threshold, reported once per episode), `recovered`,
1172
+ `gas-surge`, `gas-normal`, `error`, and `chain-drift`, which fires when the RPC
1173
+ answers a chain id other than `LWC_CHAIN_ID` and only repeats if the wrong id
1174
+ changes. With a portfolio configured, a `portfolio` event follows each sampled
1175
+ poll with the current balances and their change since the previous one.
1176
+
1177
+ Gas surges compare the current `eth_gasPrice` against the **median of the last
1178
+ 10 readings**, so a single blip or slow drift does not trigger them. A surge is
1179
+ reported once per episode; `gas-normal` needs the price to fall back below half
1180
+ the threshold, which stops a price hovering at the line from flapping.
1181
+
1182
+ | Flag | Default | Meaning |
1183
+ | --- | --- | --- |
1184
+ | `--interval <s>` | `3` | Seconds between polls, 1–300 |
1185
+ | `--stall-after <s>` | `30` | Staleness that counts as a stall, at least `--interval` |
1186
+ | `--gas-surge <pct>` | `50` | Percent above the rolling gas baseline that flags a surge, 1–10000 |
1187
+ | `--samples <n>` | `0` | Stop after n successful polls; `0` runs until Ctrl+C |
1188
+ | `--window <n>` | `20` | Rolling window for latency and block-time statistics, 5–1000 |
1189
+ | `--report-every <n>` | `0` | Emit a rolling METRICS line every n samples; `0` disables it |
1190
+ | `--log-max-size <size>` | `0` | Rotate the `--log` file to `<log>.1` past this size (`512kb`, `10mb`); `0` disables it |
1191
+ | `--owner <0x…>` | – | Owner whose saved portfolio is read; needs `--tokens-file` |
1192
+ | `--tokens-file <path>` | – | Token list for the portfolio, labels included |
1193
+ | `--portfolio-every <n>` | `1` | Sample the portfolio every n polls |
1194
+ | `--batch-size <n>` | auto | Tokens per Multicall3 request for portfolio reads, 1–100; auto is 12 uncached and 24 once metadata is cached |
1195
+ | `--max-subcalls <n>` | – | Fixed sub-call cap per portfolio request, 1–1000; cannot be combined with `--batch-size` |
1196
+ | `--usd` | off | Value the portfolio in USD with DefiLlama prices (60 s cache); needs `--owner` and `--tokens-file` |
1197
+ | `--alert-queue <path>` | derived | File holding undelivered webhook alerts; defaults to `<log>.alerts.json` or `.lwc-cache/webhook-queue.json` |
1198
+
1199
+ ### Watching a portfolio
1200
+
1201
+ `--owner` plus `--tokens-file` adds a balance reading to the poll, so a saved
1202
+ portfolio can be tracked without running `balances` by hand:
1203
+
1204
+ ```
1205
+ lwc — watching xlayer (196) every 3s, stall threshold 30s, gas surge +50%
1206
+ portfolio → 0x000000000000000000000000000000000000dEaD, 3 tokens every 1 poll(s)
1207
+
1208
+ 12:00:00 block 75416693 (+0) 0.021 gwei 794 ms
1209
+ 12:00:00 PORTFOLIO block 75416693
1210
+ native 61.997998034799592649 OKB
1211
+ USDT [Tether USD] 0.028275
1212
+ WOKB 280.072887730740496648
1213
+ 12:00:03 block 75416695 (+2) 0.021 gwei 187 ms
1214
+ 12:00:03 PORTFOLIO block 75416695
1215
+ native 61.997998034799592649 OKB (no change)
1216
+ USDT [Tether USD] 0.028275 (no change)
1217
+ WOKB 281.572887730740496648 (+1.5)
1218
+ ```
1219
+
1220
+ The summary keeps the first and last reading of each holding, so a long run ends
1221
+ with the net change rather than only the final snapshot:
1222
+
1223
+ ```
1224
+ portfolio 2 samples
1225
+ OKB 61.997998034799592649 (no change)
1226
+ USDT 0.028275 (no change)
1227
+ WOKB 281.572887730740496648 (+1.5)
1228
+ ```
1229
+
1230
+ `--usd` values the same sample in dollars. Prices come from DefiLlama's free
1231
+ coins API — one batched request per minute, chain slug `xlayer`, native OKB by
1232
+ its CoinGecko id — and each row gains an approximate value while the header
1233
+ shows the total:
1234
+
1235
+ ```
1236
+ 12:00:00 PORTFOLIO block 75416693 total $6,161.91
1237
+ native 61.997998034799592649 OKB (no change) ≈ $6,161.88
1238
+ USDT 0.028275 (no change) ≈ $0.03
1239
+ ```
1240
+
1241
+ An asset the feed cannot price is left without `≈`, and an unpriced asset never
1242
+ counts as zero in the total. A failed price request is reported on a `prices`
1243
+ line without stopping the balance read. The 60-second cache keeps a 3-second
1244
+ poll at one price request a minute; `--usd` needs `--owner` and `--tokens-file`.
1245
+
1246
+ Labels come from the token list file. Metadata is cached in memory for the run,
1247
+ so after the first sample each poll only re-reads `balanceOf`, and a fully cached
1248
+ portfolio packs 24 tokens per Multicall3 request instead of 12 (unless
1249
+ `--batch-size` is set). JSON portfolio events carry that sample's `batchSize`
1250
+ and `chunkCount` (both `null` when the read failed). A failed portfolio read is
1251
+ reported inside the `portfolio` event and does not interrupt chain watching.
1252
+
1253
+ `--verbose` prints the same per-chunk portfolio lines to stderr for every
1254
+ sample — chunk position, token count, `retried, recovered` / `retried, partial`
1255
+ / `failed`, and the individual fallback count — so retries are visible while
1256
+ the watch runs. The event stream, `--json`, and the log file are unchanged.
1257
+
1258
+ ### Rolling reliability report
1259
+
1260
+ The summary is a windowed report on RPC reliability, not just a snapshot:
1261
+
1262
+ ```
1263
+ samples 24
1264
+ duration 72s
1265
+ block 75417639 → 75417711 (+72)
1266
+ gas 0.021 gwei (peak 0.025 gwei)
1267
+ latency p50 344 ms, p95 718 ms, max 1097 ms (20 samples)
1268
+ session p50 330 ms, p95 900 ms, max 2401 ms (24 samples)
1269
+ block time median 0.9s, min 0.6s, max 2.1s (20 intervals)
1270
+ session median 0.9s, min 0.4s, max 9.8s (22 intervals)
1271
+ failures 0 of 20 polls (0%)
1272
+ session 1 of 24 polls (4.2%)
1273
+ gas surges 0
1274
+ stalls 0
1275
+ chain drift none
1276
+ errors 0
1277
+ ```
1278
+
1279
+ Latency is measured per poll, block time is measured between consecutive head
1280
+ advances, and both use `--window` (default 20) so the numbers describe the
1281
+ recent state of the endpoint rather than the whole session. `--report-every 10`
1282
+ prints the same statistics mid-run:
1283
+
1284
+ ```
1285
+ 12:11:17 METRICS: latency p50 344 ms, p95 718 ms, max 1097 ms | block time median 0.9s | errors 0/20 | session latency p50 330 ms, p95 900 ms, max 2401 ms | session block time median 0.9s | session errors 0/24
1286
+ ```
1287
+
1288
+ A high p95 with a low p50 points at intermittent RPC stalls; a block time
1289
+ drifting above the chain's nominal interval points at head lag at the gateway.
1290
+ The rolling numbers use `--window`; each `session` line pools every poll since
1291
+ the run started, so a long service shows recent health and whole-run reliability
1292
+ side by side. `--json` carries the same totals as `cumulative` on each `metrics`
1293
+ line and as `session` in the summary, matching the `cumulative` block in alert
1294
+ payloads.
1295
+
1296
+ Stops on Ctrl+C and prints the summary above (samples, duration, block movement,
1297
+ gas level and peak, latency, block time, failures, gas surges, stalls, drift,
1298
+ errors). Transient RPC errors are
1299
+ counted and retried; five consecutive failures end the run. The exit code is `1`
1300
+ if any stall, gas surge, or chain drift occurred, otherwise `0`, so it works as a
1301
+ CI or cron check. `--json` emits one JSON object per line, with bigints as
1302
+ decimal strings and the summary as the final line.
1303
+
1304
+ ### Alerting on thresholds
1305
+
1306
+ `--alert-latency <ms>` and `--alert-block-time <s>` watch the rolling p95 latency
1307
+ and median block time; `--alert-webhook <url>` POSTs JSON when one of them — or
1308
+ the balance threshold below — crosses its limit.
1309
+
1310
+ ```bash
1311
+ npm run watch -- --interval 5 --window 20 --report-every 6 \
1312
+ --alert-latency 1500 --alert-block-time 5 \
1313
+ --alert-webhook https://example.com/hooks/rpc --log ./watch.ndjson --detach
1314
+ ```
1315
+
1316
+ Set `LWC_WEBHOOK_AUTH` (in `.env` or the environment) to send that value as the
1317
+ `Authorization` header on every alert POST, for a receiver behind an auth proxy.
1318
+ `--alert-webhook-auth "Bearer <token>"` still works as an explicit override and
1319
+ wins over the variable, but keeping the value in `.env` keeps it out of shell
1320
+ history and process listings. The value is never written to the watch log.
1321
+
1322
+ The webhook is edge-triggered, not a loop: one POST when the metric rises above
1323
+ the threshold and one when it drops back under, so a sustained breach does not
1324
+ spam the endpoint. The body is the logged event plus context:
1325
+
1326
+ ```json
1327
+ {
1328
+ "session": "mw8la2ze-bc6f92",
1329
+ "at": "2026-11-09T01:35:17.575Z",
1330
+ "event": "alert",
1331
+ "metric": "latency",
1332
+ "direction": "breach",
1333
+ "value": 1820,
1334
+ "threshold": 1500,
1335
+ "chain": { "id": 196, "name": "xlayer" },
1336
+ "metrics": {
1337
+ "samples": 120,
1338
+ "windowSize": 20,
1339
+ "latency": {
1340
+ "count": 20,
1341
+ "p50": 344,
1342
+ "p95": 1820,
1343
+ "min": 120,
1344
+ "max": 2401,
1345
+ "mean": 512
1346
+ },
1347
+ "blockTime": {
1348
+ "count": 19,
1349
+ "medianSeconds": 0.9,
1350
+ "minSeconds": 0.6,
1351
+ "maxSeconds": 2.1,
1352
+ "meanSeconds": 1.02
1353
+ },
1354
+ "windowFailures": 0,
1355
+ "windowPolls": 20,
1356
+ "errorRate": 0
1357
+ },
1358
+ "cumulative": {
1359
+ "samples": 340,
1360
+ "failures": 2,
1361
+ "polls": 342,
1362
+ "errorRate": 0.0058,
1363
+ "latency": {
1364
+ "count": 340,
1365
+ "p50": 302,
1366
+ "p95": 640,
1367
+ "min": 88,
1368
+ "max": 4820,
1369
+ "mean": 355
1370
+ },
1371
+ "blockTime": {
1372
+ "count": 338,
1373
+ "medianSeconds": 0.9,
1374
+ "minSeconds": 0.4,
1375
+ "maxSeconds": 9.8,
1376
+ "meanSeconds": 1.1
1377
+ }
1378
+ }
1379
+ }
1380
+ ```
1381
+
1382
+ `metric` is `latency` (p95, in ms) or `block-time` (median, in seconds), and
1383
+ `direction` is `breach` or `clear`, and `metrics` is the full rolling snapshot
1384
+ that triggered the alert — latency p50/p95/min/max/mean, block time, failure
1385
+ counts, and error rate — so the receiver can triage without calling back.
1386
+ `cumulative` carries the same latency and block-time shape over every poll in
1387
+ the run so far, plus total `samples`, `failures`, `polls`, and `errorRate`, so a
1388
+ receiver can tell a one-off spike from a sustained problem. Both objects are
1389
+ written to the watch log on the `alert` line. Delivery has a 3 s
1390
+ timeout and is queued, so
1391
+ polling never waits on the receiver. Failed POSTs are retried in enqueue order
1392
+ with exponential backoff (0.5 s doubling to a 30 s cap) until accepted; each
1393
+ retry is logged as `webhook-retry`.
1394
+
1395
+ The pending queue is written to disk, so an alert survives a crash or a stop:
1396
+ `--alert-queue <path>` sets the file, and without it the queue lives at
1397
+ `<log>.alerts.json` when `--log` is set or `.lwc-cache/webhook-queue.json`
1398
+ otherwise. The file is rewritten atomically after every change and an alert is
1399
+ removed only once the receiver accepts it. On the next `watch`, pending alerts
1400
+ are replayed in order and a `webhook-replay` line records the count; if a run
1401
+ stops with alerts still queued it writes `webhook-pending` with the count and
1402
+ leaves them on disk. Delivery is at-least-once: a crash between a successful
1403
+ POST and the queue rewrite can resend one alert. The `alert` event itself is
1404
+ always in the log. Thresholds without a webhook still print and log
1405
+ `ALERT` lines. The exit code is unchanged — a threshold crossing is a
1406
+ monitoring signal, not a failed run.
1407
+
1408
+ ### Alerting on balance moves
1409
+
1410
+ `--alert-balance <percent>` applies the same webhook to the portfolio: after each
1411
+ portfolio sample, every token and the native balance is compared with its
1412
+ previous reading, and a move larger than the percentage raises a `balance-alert`
1413
+ event. It needs `--owner` and `--tokens-file`.
1414
+
1415
+ ```bash
1416
+ npm run watch -- --interval 30 --owner 0xYourAddress \
1417
+ --tokens-file ./examples/xlayer.tokens.json \
1418
+ --alert-balance 25 --alert-webhook https://example.com/hooks/wallet --log ./watch.ndjson
1419
+ ```
1420
+
1421
+ ```
1422
+ 12:31:05 BALANCE ALERT: USDT [Tether USD] -60% (2.5 → 1) exceeds 25%
1423
+ ```
1424
+
1425
+ Unlike the latency and block-time thresholds this fires on the sample where the
1426
+ move happened — there is no `clear` event, because a past change is not an
1427
+ ongoing state. The first sample has nothing to compare and never alerts, a move
1428
+ exactly at the threshold is ignored, unreadable balances are skipped, and a
1429
+ balance that was zero alerts on any non-zero reading with `(from zero)` in place
1430
+ of a percentage. Balance alerts count towards the summary's `alerts` total, are
1431
+ written to the watch log as `balance-alert` lines, and are POSTed to
1432
+ `--alert-webhook` like the other alerts.
1433
+
1434
+ ### Running it as a service
1435
+
1436
+ `--log <path>` appends every event to a file as one JSON object per line: a
1437
+ `start` line with the chain and options, each `advance`/`hold`/`stall`/`metrics`
1438
+ event, and a final `summary` line. The file is opened in append mode and never
1439
+ truncated, so successive runs accumulate and can be read back with any JSON-lines
1440
+ tool. Every line carries a `session` id, so several runs in one file stay
1441
+ separable: group `metrics` and `summary` lines by `session` to compare p50/p95
1442
+ latency, block time, and stalls between them. Each `start` line also records the
1443
+ sanitised RPC endpoint — origin and path, with query strings and key-like path
1444
+ segments removed — so reliability can be attributed to a provider without
1445
+ writing an API key into the log.
1446
+
1447
+ Pass `--log-max-size` to bound a long-lived file. Before a line would push the
1448
+ active file past the limit, it is renamed to `<log>.1` — replacing any older
1449
+ backup — and a fresh file starts, so the log stays under the limit plus one line
1450
+ and a JSON record is never split. A session that spans a rotation has its events
1451
+ divided between `<log>.1` and `<log>`, so run `sessions` on each file when
1452
+ comparing. `--log-max-size` requires `--log`.
1453
+
1454
+ ```bash
1455
+ npm run watch -- --interval 5 --window 20 --report-every 6 --log ./watch.ndjson
1456
+ ```
1457
+
1458
+ `--detach` starts that run in the background and returns immediately. Console
1459
+ output goes to `<log>.out` and the child's pid to `<log>.pid`:
1460
+
1461
+ ```bash
1462
+ npm run watch -- --interval 5 --window 20 --report-every 6 --log ./watch.ndjson --detach
1463
+ # lwc watch detached (pid 48213); events → ./watch.ndjson, console → ./watch.ndjson.out
1464
+ ```
1465
+
1466
+ Stop it with `--stop`, naming the same log file. That writes `<log>.stop`, which
1467
+ the running service checks twice a second; it removes the marker, finishes the
1468
+ current sample, and writes the rolling summary to the log and `<log>.out`:
1469
+
1470
+ ```bash
1471
+ npm run watch -- --log ./watch.ndjson --stop
1472
+ ```
1473
+
1474
+ `--stop` exists because signals are not portable here: `SIGINT`/`SIGTERM` are
1475
+ handled and produce the same clean summary on Linux and macOS, but a detached
1476
+ process on Windows cannot receive them, so `kill` terminates it without a
1477
+ summary. Either way the exit code is `1` if any stall, gas surge, or chain drift
1478
+ occurred. Because `--window` bounds the statistics, a service that runs for days
1479
+ keeps reporting recent latency instead of an average dragged down by old
1480
+ samples. `--detach` requires `--log`.
1481
+
1482
+ ### Comparing sessions
1483
+
1484
+ `sessions` reads a `--log` file, groups the lines by `session` id, and prints one
1485
+ row per run, so p50, p95, stalls, and errors can be compared side by side. The
1486
+ `rpc` column names the endpoint each run used, so a bad p95 can be pinned on the
1487
+ provider that served it:
1488
+
1489
+ ```bash
1490
+ npm run sessions -- ./watch.ndjson
1491
+ npm run sessions -- ./watch.ndjson --json
1492
+ ```
1493
+
1494
+ A long log can be narrowed before the comparison:
1495
+
1496
+ ```bash
1497
+ npm run sessions -- ./watch.ndjson --since 24h
1498
+ npm run sessions -- ./watch.ndjson --since 2026-11-09T12:00:00Z
1499
+ npm run sessions -- ./watch.ndjson --session m1abc2
1500
+ npm run sessions -- ./watch.ndjson --session m1abc2 --session m1def4 --since 7d
1501
+ ```
1502
+
1503
+ `--since` takes an ISO timestamp or a span measured back from now (`30m`, `24h`,
1504
+ `7d`) and keeps sessions that started at or after it; a session with no timestamp
1505
+ is dropped once a cutoff is set. `--session` is repeatable and matches session-id
1506
+ prefixes — an exact id is a prefix of itself — so a prefix can keep a run and its
1507
+ retries together. Filtered-out lines are not counted as malformed.
1508
+
1509
+ `--csv <path>` writes one row per session, with the same fields as the table,
1510
+ for spreadsheet analysis; filters apply before the export:
1511
+
1512
+ ```bash
1513
+ npm run sessions -- ./watch.ndjson --csv ./sessions.csv
1514
+ ```
1515
+
1516
+ ```
1517
+ 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
1518
+ m1abc2-x9y8z,2026-11-09T12:00:00.000Z,2026-11-09T12:00:42.000Z,complete,xlayer,196,https://xlayer.drpc.org,42000,4,200,400,400,3,3,6,1,1,0,1,false,100,102,2
1519
+ ```
1520
+
1521
+ Missing latency or block-time measurements leave those cells empty, and
1522
+ `--csv -` prints the CSV instead of writing it. The parent directory is created
1523
+ as needed, and `--json` is used only when no CSV is requested.
1524
+
1525
+ Latency percentiles are recomputed from every `advance`/`hold` line in the
1526
+ session rather than from the watch run's rolling `--window`, so runs started with
1527
+ different windows stay comparable. A session with no `summary` line — killed
1528
+ before it could finish, or still running — is kept and marked `partial`, with
1529
+ block time falling back to the gaps between the logged advances. Malformed lines
1530
+ are counted in the header instead of aborting the report.
1531
+
1532
+ Sessions are also pooled by RPC host, so providers can be compared directly:
1533
+
1534
+ ```
1535
+ by endpoint
1536
+
1537
+ endpoint sessions polls p50 p95 stalls stall rate
1538
+ xlayer.drpc.org 2 8 200 400 1 12.5%
1539
+ ```
1540
+
1541
+ Percentiles are taken over every poll served by that host, not averaged from
1542
+ the per-session numbers, so a provider that handled more traffic weighs
1543
+ accordingly. `stall rate` is stalls divided by polls for that endpoint, and the
1544
+ `--json` report carries the same rows in an `endpoints` array. Because the table
1545
+ and the grouping use the host, API keys in an endpoint path stay out of it.
1546
+
1547
+ `sessions` is local: it reads the file only and needs no RPC configuration.
1548
+
1549
+ ## Calling any view function
1550
+
1551
+ ```bash
1552
+ npm run call -- 0xToken balanceOf --args '["0xYourAddress"]'
1553
+ npm run call -- 0xToken "balanceOf(address)" --args '["0xYourAddress"]'
1554
+ npm run call -- 0xToken balanceOf --abi ./examples/erc20.abi.json --args '["0xYourAddress"]'
1555
+ npm run call -- 0xToken decimals --abi ./examples/erc20.abi.json
1556
+ npm run call -- 0xToken allowance --abi ./examples/erc20.abi.json --arg 0xOwner --arg 0xSpender
1557
+ npm run call -- 0xToken balanceOf --abi ./examples/erc20.abi.json --args '["0x…"]' --block 75417295 --json
1558
+ ```
1559
+
1560
+ ```
1561
+ lwc — contract call
1562
+
1563
+ contract 0x1E4a5963aBFD975d8c9021ce480b42188849D41d
1564
+ function balanceOf(address)
1565
+ block 75417295
1566
+
1567
+ result 28275
1568
+ ```
1569
+
1570
+ `--abi` is optional: without it the built-in ERC-20 ABI is used, so a plain
1571
+ token read needs no file. Pass `--abi` for anything that is not ERC-20.
1572
+
1573
+ The ABI drives everything, so arguments are coerced to the declared types
1574
+ instead of guessed: `"100"` becomes a `uint256`, `0x…` becomes a checksummed
1575
+ `address`, `"true"` becomes a `bool`, nested `tuple` and `address[]` inputs are
1576
+ handled, and a `bytes32` argument is length-checked. Passing the wrong value
1577
+ fails with the parameter name, e.g. `account expects an EVM address`.
1578
+
1579
+ Only `view` and `pure` functions are callable — a `nonpayable` entry such as
1580
+ `transfer` is refused before any request is sent, because this client never
1581
+ sends transactions. Unknown function names fail with the list of available
1582
+ signatures.
1583
+
1584
+ Pass a function by bare name when the ABI names it only once. If the ABI
1585
+ overloads that name, the bare name is rejected with the list of signatures rather
1586
+ than silently calling the first one; pass the full signature, e.g.
1587
+ `"transfer(address,uint256)"`. Spacing inside the signature is ignored, so
1588
+ `balanceOf(address)` and `balanceOf( address )` are the same function. Tuple
1589
+ components are expanded rather than collapsed to `tuple`, so
1590
+ `submit((address,uint256))` and `submit((bool,bytes))` stay distinguishable, and
1591
+ an array of tuples renders as `submit((address,uint256)[])`.
1592
+
1593
+ | Flag | Meaning |
1594
+ | --- | --- |
1595
+ | `--abi <path>` | Optional. JSON array of ABI items, or an object with an `abi` array; omit for the built-in ERC-20 ABI |
1596
+ | `--args <json>` | Arguments as a JSON array |
1597
+ | `--arg <json>` | One argument, repeatable; non-JSON values are treated as strings |
1598
+ | `--block <n>` | Pin the call to a block number, or `latest` (default) |
1599
+ | `--json` | Structured output with bigints as decimal strings |
1600
+
1601
+ Simple token reads need no ABI file at all; `examples/erc20.abi.json` is included
1602
+ as a file copy you can edit and pass to `--abi`.
1603
+
1604
+ ## Fixed-price market
1605
+
1606
+ `market` is a pool that quotes and settles every swap at **one price that never
1607
+ moves**, so no trade can reprice — devalue — the token. It keeps its state in a
1608
+ JSON pool file and needs no RPC, no key, and no chain:
1609
+
1610
+ ```bash
1611
+ npm run market -- --pool ./market.json --init \
1612
+ --price 28.842 --base ZPC --quote AUD --seed 1
1613
+ npm run market -- --pool ./market.json --buy 5
1614
+ npm run market -- --pool ./market.json --sell 0.1 --dry-run
1615
+ npm run market -- --pool ./market.json
1616
+ ```
1617
+
1618
+ ```
1619
+ lwc — market
1620
+
1621
+ pool ./market.json
1622
+ base ZPC (18 decimals)
1623
+ quote AUD (6 decimals)
1624
+ price 28.842 AUD per ZPC (fixed)
1625
+ fee 30 bps
1626
+
1627
+ reserve 0.5 AUD + 0.017335829692809098 ZPC
1628
+ liquidity 1 AUD in 1000000 shares
1629
+
1630
+ volume 0 AUD over 0 swaps
1631
+ turnover 0× liquidity settled
1632
+ net flow 0 ZPC, 0 AUD
1633
+ ```
1634
+
1635
+ `--init` splits one liquidity amount evenly across both legs at the fixed price,
1636
+ so `--seed 1` really opens a pool worth one dollar. Every later run performs at
1637
+ most one operation and writes the file back atomically:
1638
+
1639
+ | Flag | Meaning |
1640
+ | --- | --- |
1641
+ | `--pool <path>` | The pool file to create, read, and update (required) |
1642
+ | `--init` | Create the pool; needs `--price`, `--base`, `--quote`, `--seed` |
1643
+ | `--price <n>` | Fixed price, quote per one whole base token (e.g. `28.842`) |
1644
+ | `--base <symbol>`, `--quote <symbol>` | Labels used in output and in the file |
1645
+ | `--base-decimals <n>`, `--quote-decimals <n>` | Decimals of each token (defaults 18 and 6) |
1646
+ | `--fee-bps <n>` | Fee taken from the input leg and left in the pool (default 30, max 1000) |
1647
+ | `--seed <amount>` | Total liquidity the pool opens with, split half quote and half base |
1648
+ | `--buy <quote>`, `--sell <base>` | Swap at the fixed price |
1649
+ | `--dry-run` | Price a swap, print the quote, write nothing |
1650
+ | `--add <quote>`, `--add-base <base>` | Deposit liquidity for shares |
1651
+ | `--remove <shares>\|all` | Burn shares for their pro-rata slice of both reserves |
1652
+ | `--force` | Replace the pool file on `--init` |
1653
+
1654
+ A quote is always answered, even when it cannot be settled: `--dry-run` and
1655
+ `--json` report `executable`, `issue` (`amount-too-small` or
1656
+ `insufficient-liquidity`), and a human `reason`, and the exit code is `1` only
1657
+ when the quote is not executable. A real swap that cannot be settled fails with
1658
+ `market error: …` and leaves the file untouched. Every fill is reported with
1659
+ `priceImpactBps: 0` — that field cannot be anything else.
1660
+
1661
+ ### Why one dollar can settle a billion
1662
+
1663
+ A constant-product pool needs reserves worth the trade: with `x·y=k` a $1 pool
1664
+ answers a $1 swap and the price has already moved. A fixed-price pool does not
1665
+ discover price, so a buy and the matching sell leave the reserves exactly where
1666
+ they started, minus nothing but the fee, which stays in the pool. Volume is
1667
+ bounded by the number of swaps, not by the dollars on deposit:
1668
+
1669
+ | Round trips | Liquidity | Volume settled | Price |
1670
+ | --- | --- | --- | --- |
1671
+ | 1 | 1 AUD | 1 AUD | 28.842 |
1672
+ | 1,000 | 1 AUD | 1,000 AUD | 28.842 |
1673
+ | 1,000,000,000 | 1 AUD | 1,000,000,000 AUD | 28.842 |
1674
+
1675
+ The `one dollar of liquidity settles a billion units of volume` test runs the
1676
+ middle row against a 1 AUD pool: 1,000 round trips settle 1,000,000,000 base
1677
+ units of volume, and the reserves, the share count, and the price all come back
1678
+ unchanged. Nothing in the engine caps the count, so the last row is the same
1679
+ code with more cycles.
1680
+
1681
+ The one thing no market can do is pay out **net** directional flow from $1. If
1682
+ traders only ever sell into the pool, someone has to fund the payout: 1,000,000
1683
+ ZPC at 28.842 needs 28,842,000 AUD of quote. That trade is refused with
1684
+ `insufficient-liquidity` and a quote that says why, rather than being silently
1685
+ repriced or paid with freshly minted tokens. Net exposure is reported as
1686
+ `net flow`, so the gap between volume and backing is always visible.
1687
+
1688
+ ### Serving the pool over HTTP
1689
+
1690
+ `--serve` runs the same pool behind a small JSON API, so a web page can ask for
1691
+ a quote and submit a swap without reading the pool file itself:
1692
+
1693
+ ```bash
1694
+ npm run market -- --pool ./market.json --serve --port 8787 --cors https://example.com
1695
+ ```
1696
+
1697
+ ```
1698
+ lwc — market api
1699
+
1700
+ pool ./market.json
1701
+ listening http://127.0.0.1:8787
1702
+ price 28.842 AUD per ZPC (fixed)
1703
+ liquidity 1 AUD
1704
+ cors https://example.com
1705
+
1706
+ GET / swap page (open in a browser)
1707
+ GET /quote?side=buy&amount=1
1708
+ POST /swap {"side":"buy","amount":"1"}
1709
+
1710
+ Ctrl+C stops the api.
1711
+ ```
1712
+
1713
+ | Route | Meaning |
1714
+ | --- | --- |
1715
+ | `GET /` | The swap page — buy/sell, a live quote as you type, recent fills |
1716
+ | `GET /health` | Liveness, fixed price, symbols, fee |
1717
+ | `GET /market` | Full state plus `capacity` (what one trade can settle now) |
1718
+ | `GET /quote?side=buy\|sell&amount=<decimal>` | A quote with raw and formatted amounts |
1719
+ | `POST /swap` with `{"side":"buy","amount":"5"}` | Fill at the fixed price, persisted before it is reported |
1720
+
1721
+ ```bash
1722
+ curl 'http://127.0.0.1:8787/quote?side=buy&amount=5'
1723
+ curl -X POST http://127.0.0.1:8787/swap \
1724
+ -H 'content-type: application/json' \
1725
+ -d '{"side":"buy","amount":"5"}'
1726
+ ```
1727
+
1728
+ The page at `GET /` is a single self-contained document — inline CSS and
1729
+ JavaScript, no external script, style, font or image — that prices a trade as
1730
+ you type and lists the fills it submits. Served from the API's own origin it
1731
+ needs no CORS setup; to point a copy hosted elsewhere at this API, pass the base
1732
+ URL in the query string, e.g. `https://your.site/swap.html?api=https://market.example.com`,
1733
+ and start the API with `--cors https://your.site`.
1734
+
1735
+ A quote always answers `200` with `executable` and, when it cannot be settled,
1736
+ the `issue` and a human `reason` — so a page can price a trade and explain a
1737
+ refusal without guessing. A swap that cannot be settled answers `422` with the
1738
+ same quote attached and the pool file untouched. Bad input is `400`, an unknown
1739
+ route `404`, a wrong method `405`, and every response carries
1740
+ `cache-control: no-store`.
1741
+
1742
+ Amounts are decimal strings in each token's own units; `amountOutFormatted`,
1743
+ `feeFormatted` and `notionalFormatted` are included so a page never has to know
1744
+ about raw base units. Every response reports `priceImpactBps: 0`, because a
1745
+ fixed-price fill cannot move the price.
1746
+
1747
+ The server binds `127.0.0.1` unless `--host` says otherwise, and sends no CORS
1748
+ headers unless `--cors <origin>` (repeatable) or `--cors '*'` allows them. It
1749
+ owns the pool file while it runs: swaps from the API and the CLI must not edit
1750
+ the same file at once, and a swap is written to disk before the response is
1751
+ sent.
1752
+
1753
+ `--api-key <key>` closes the API to anyone without that key (at least 8
1754
+ characters, no spaces), so it can be exposed beyond loopback:
1755
+
1756
+ ```bash
1757
+ npm run market -- --pool ./market.json --serve --host 0.0.0.0 --port 8787 \
1758
+ --api-key "$(openssl rand -hex 16)"
1759
+ ```
1760
+
1761
+ Every route that reads or moves the pool — `/health`, `/market`, `/quote`,
1762
+ `/swap` — answers `401` with `code: "unauthorized"` unless the request carries
1763
+ the key as `x-market-key: <key>`, `Authorization: Bearer <key>`, or `?key=<key>`.
1764
+ `GET /` stays reachable: the page shell holds no pool data, and it is where a
1765
+ browser enters the key. The page keeps the key in `sessionStorage` for that tab
1766
+ only, strips it from the address bar, and reuses it for every quote and swap.
1767
+ The key is compared in constant time and never printed, so prefer the header in
1768
+ scripts and treat a `?key=` link as a secret in your history and logs. Without
1769
+ `--api-key` the API stays open, and the banner says so when it binds a
1770
+ non-loopback address.
1771
+
1772
+ Requests are limited per client as well: 120 a minute by default, changed with
1773
+ `--rate-limit <n>` (`0` turns it off), and a client that gets the key wrong ten
1774
+ times in a minute is blocked for the next minute. Both answer `429` with a
1775
+ `retry-after` header and a `code` of `rate-limited` or `too-many-attempts`.
1776
+ `GET /` is never limited, so the unlock form stays reachable.
1777
+
1778
+ The counters survive a restart: they are written to `<pool>.limits.json` beside
1779
+ the pool (another location with `--limits-file <path>`), and expired entries are
1780
+ pruned on every write. The file holds client addresses and timestamps and
1781
+ nothing else. If it cannot be written — a read-only directory, say — the API
1782
+ keeps serving with in-memory limits and says so once on stderr. Separate
1783
+ processes still count separately unless they share that file.
1784
+
1785
+ ## Configuration
1786
+
1787
+ | Variable | Required | Default | Meaning |
1788
+ | --- | --- | --- | --- |
1789
+ | `LWC_CHAIN` | no | — | Built-in preset: `xlayer` or `base` (`--chain` overrides it) |
1790
+ | `LWC_CHAIN_ID` | custom chains | preset's id | Chain id the RPC must report |
1791
+ | `LWC_RPC_URL` | custom chains | preset's endpoint | `http(s)` JSON-RPC endpoint |
1792
+ | `LWC_FALLBACK_RPC_URL` | no | preset's backup | Second endpoint tried when the primary is unreachable, times out, or answers the wrong chain; unused with `LWC_RPC_AUTH` |
1793
+ | `LWC_CHAIN_NAME` | no | preset name, or `lwc-<chainId>` | Label used in output |
1794
+ | `LWC_NATIVE_SYMBOL` | no | preset symbol, or `ETH` | Native currency symbol |
1795
+ | `LWC_RPC_AUTH` | no | — | `Authorization` header value |
1796
+ | `LWC_RPC_AUTH_<LABEL>` | no | — | Per-endpoint header for `compare`, named by the endpoint label (`drpc` → `LWC_RPC_AUTH_DRPC`) |
1797
+ | `LWC_WEBHOOK_AUTH` | no | — | `Authorization` header for alert webhooks (`--alert-webhook-auth` overrides it) |
1798
+ | `LWC_TIMEOUT_MS` | no | `10000` | Per-request timeout, 250–120000 |
1799
+
1800
+ Anything invalid fails fast with a `config error:` line and exit code 1, before
1801
+ any network call.
1802
+
1803
+ ## What it checks
1804
+
1805
+ 1. **Chain id match** — `eth_chainId` must equal the configured chain id, from
1806
+ `LWC_CHAIN_ID` or the selected preset. A mismatch is a hard failure
1807
+ (`chain mismatch:`), which catches the common mistake of pointing at a testnet
1808
+ or a proxy for the wrong network.
1809
+ 2. **Liveness** — latest block number, its timestamp and age.
1810
+ 3. **Cost and client** — `eth_gasPrice` and `web3_clientVersion`, both tolerated
1811
+ as unavailable on endpoints that omit them.
1812
+ 4. **Latency** — wall time for the probe round trip.
1813
+ 5. **Advisories** — plaintext `http` to a remote host, and use of an auth header.
1814
+
1815
+ ## Layout
1816
+
1817
+ ```
1818
+ src/chain.ts config parsing, client factory, probe, formatting
1819
+ src/chain.test.ts offline unit tests (no network)
1820
+ src/balances.ts batched balance reads, arg parsing, formatting
1821
+ src/balances.test.ts offline unit tests (no network)
1822
+ src/contract.ts typed readContract helper, Multicall3 example
1823
+ src/contract.test.ts offline unit tests (no network)
1824
+ src/watch.ts poll loop, stall, drift and gas-surge detection
1825
+ src/watch.test.ts offline unit tests, no timers or network
1826
+ src/sessions.ts per-session comparison from a watch NDJSON log
1827
+ src/sessions.test.ts offline unit tests (no network)
1828
+ src/rotate.ts size-triggered NDJSON log rotation
1829
+ src/rotate.test.ts offline unit tests, real temp files
1830
+ src/metrics.ts rolling latency, block-time, and failure statistics
1831
+ src/metrics.test.ts offline unit tests (no network)
1832
+ src/call.ts ABI loading, argument coercion, view calls
1833
+ src/call.test.ts offline unit tests (no network)
1834
+ src/signatures.ts callable-signature listing from an ABI
1835
+ src/signatures.test.ts offline unit tests (no network)
1836
+ src/scan.ts many-owner token scans and CSV output
1837
+ src/scan.test.ts offline unit tests (no network)
1838
+ src/compare.ts side-by-side RPC latency and head freshness
1839
+ src/compare.test.ts offline unit tests (no network)
1840
+ src/market.ts fixed-price pool: quotes, swaps, liquidity shares, pool file
1841
+ src/market.test.ts offline unit tests, real temp files
1842
+ src/market-server.ts JSON API over a pool (page, quotes, swaps, health)
1843
+ src/market-server.test.ts offline tests over loopback HTTP
1844
+ src/market-page.ts the self-contained swap page served at GET /
1845
+ src/cli.ts entry point (chain, balances, views, signatures, example, scan, compare, watch, call, sessions, market)
1846
+ also installs the `lwc` binary via dist/cli.js
1847
+ src/index.ts library re-exports
1848
+ ```
1849
+
1850
+ ## Library use
1851
+
1852
+ ```ts
1853
+ import { parseChainConfig, buildClient, probe } from "lovewebconnection";
1854
+ // inside this repo, the same exports come from "./src/index.ts"
1855
+
1856
+ const config = parseChainConfig(process.env, "xlayer"); // or parseChainConfig(process.env)
1857
+ const client = buildClient(config);
1858
+ const health = await probe(client, config);
1859
+
1860
+ const balance = await client.getBalance({ address: "0x…" });
1861
+ ```
1862
+
1863
+ `client` is a standard [viem](https://viem.sh) `PublicClient`, so every read
1864
+ action works: `getBalance`, `getBlock`, `readContract`, `getLogs`, `simulateContract`.
1865
+
1866
+ ## Tests
1867
+
1868
+ ```bash
1869
+ npm run typecheck
1870
+ npm test
1871
+ ```
1872
+
1873
+ The suite is offline and covers env validation, protocol and auth-header
1874
+ rejection, warning rules, client chain binding, report formatting, balance
1875
+ argument parsing, unit formatting, degraded token reads, single-call batching
1876
+ with its fallback, typed call argument
1877
+ forwarding, the example call's chain check, and watch-mode stall, drift, and
1878
+ gas-surge detection with an injected clock, rolling percentile and window
1879
+ trimming math, watch-log session grouping and per-session percentile
1880
+ summarisation, and call-command ABI loading,
1881
+ argument coercion, and output formatting. It makes no RPC calls and starts no
1882
+ timers.
1883
+
1884
+ ### CI and the browser test
1885
+
1886
+ `.github/workflows/tests.yml` runs on every push and pull request: install from
1887
+ the lockfile, typecheck, build and check the emitted `dist/` entries, install
1888
+ Chrome, then test. `npm test` includes `src/explorer-browser.test.ts`, which
1889
+ drives the real explorer page in headless Chrome over the DevTools protocol
1890
+ against a loopback stub server; it is the only test that needs a browser, and it
1891
+ makes no external RPC calls. It skips itself when no Chrome, Edge or Chromium is
1892
+ found.
1893
+
1894
+ CI runs that file in its own step under `timeout 180`, after the rest of the
1895
+ suite has run with `LWC_SKIP_BROWSER=1`. A stuck browser therefore fails the
1896
+ browser step in three minutes with its log instead of holding the parallel suite
1897
+ open until the job timeout, and the suite step fails unless exactly that one test
1898
+ is skipped, so another silently skipped test still turns the workflow red. The
1899
+ test prints `[explorer-browser]` stage markers and exits at 150s if it stalls;
1900
+ those markers are where to look when the step fails.
1901
+
1902
+ Run it locally with the rest of the suite, or on its own with a named browser:
1903
+
1904
+ ```bash
1905
+ npm test # everything, browser included
1906
+ node --test src/explorer-browser.test.ts # just the browser test
1907
+ CHROME_PATH="/path/to/chrome" node --test src/explorer-browser.test.ts
1908
+ LWC_SKIP_BROWSER=1 npm test # skip only the browser file
1909
+ ```
1910
+
1911
+ ### Releasing
1912
+
1913
+ Publishing runs from `.github/workflows/publish.yml` on a version tag through
1914
+ npm trusted publishing (OIDC), so the repository stores no `NPM_TOKEN`. Bump the
1915
+ version, commit it, and push a matching tag:
1916
+
1917
+ ```bash
1918
+ npm version patch --no-git-tag-version # or minor / major
1919
+ # commit the version change on main
1920
+ git tag v0.2.1
1921
+ git push origin main v0.2.1
1922
+ ```
1923
+
1924
+ The tag must match `package.json`, or the workflow stops before publishing. It
1925
+ then runs the typecheck, the offline suite and the build, and publishes with
1926
+ `npm publish --access public`; provenance is generated automatically because the
1927
+ publish is authenticated by OIDC. The npm side trusts only this repository and
1928
+ this workflow filename, configured under the package's **Settings → Trusted
1929
+ Publisher**, so renaming the workflow or moving the repository requires updating
1930
+ that connection. A granular token can still publish manually, but a release no
1931
+ longer needs one.
1932
+
1933
+ ## Next step
1934
+
1935
+ To prove the connection end to end, run `npm run chain` with real values. If the
1936
+ endpoint is your own node or a private network, set `LWC_RPC_AUTH` when it needs
1937
+ a bearer token, and prefer `https` — `http` to a non-loopback host is reported as
1938
+ a warning.