lovewebconnection 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +38 -0
- package/LICENSE +21 -0
- package/README.md +1915 -2
- package/dist/alert-queue.d.ts +72 -0
- package/dist/alert-queue.js +155 -0
- package/dist/alert-store.d.ts +29 -0
- package/dist/alert-store.js +75 -0
- package/dist/balances.d.ts +221 -0
- package/dist/balances.js +1232 -0
- package/dist/cache.d.ts +37 -0
- package/dist/cache.js +128 -0
- package/dist/call.d.ts +46 -0
- package/dist/call.js +377 -0
- package/dist/chain.d.ts +97 -0
- package/dist/chain.js +419 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +1011 -0
- package/dist/compare-history.d.ts +82 -0
- package/dist/compare-history.js +340 -0
- package/dist/compare.d.ts +98 -0
- package/dist/compare.js +492 -0
- package/dist/contract.d.ts +107 -0
- package/dist/contract.js +162 -0
- package/dist/default-abi.d.ts +53 -0
- package/dist/default-abi.js +85 -0
- package/dist/history.d.ts +83 -0
- package/dist/history.js +422 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.js +27 -0
- package/dist/market-page.d.ts +8 -0
- package/dist/market-page.js +554 -0
- package/dist/market-server.d.ts +46 -0
- package/dist/market-server.js +473 -0
- package/dist/market.d.ts +323 -0
- package/dist/market.js +835 -0
- package/dist/metadata-cache.d.ts +72 -0
- package/dist/metadata-cache.js +197 -0
- package/dist/metrics.d.ts +64 -0
- package/dist/metrics.js +138 -0
- package/dist/plot.d.ts +60 -0
- package/dist/plot.js +317 -0
- package/dist/presets-history.d.ts +372 -0
- package/dist/presets-history.js +1243 -0
- package/dist/presets-summary.d.ts +17 -0
- package/dist/presets-summary.js +55 -0
- package/dist/presets.d.ts +59 -0
- package/dist/presets.js +181 -0
- package/dist/prices.d.ts +63 -0
- package/dist/prices.js +154 -0
- package/dist/rotate.d.ts +19 -0
- package/dist/rotate.js +34 -0
- package/dist/scan.d.ts +81 -0
- package/dist/scan.js +501 -0
- package/dist/sessions.d.ts +104 -0
- package/dist/sessions.js +515 -0
- package/dist/signatures.d.ts +13 -0
- package/dist/signatures.js +43 -0
- package/dist/tokenfile.d.ts +50 -0
- package/dist/tokenfile.js +218 -0
- package/dist/views.d.ts +63 -0
- package/dist/views.js +415 -0
- package/dist/watch.d.ts +306 -0
- package/dist/watch.js +1121 -0
- package/dist/webhook.d.ts +17 -0
- package/dist/webhook.js +35 -0
- package/examples/erc20.abi.json +57 -0
- package/examples/xlayer.tokens.json +4 -0
- package/package.json +77 -4
package/README.md
CHANGED
|
@@ -1,3 +1,1916 @@
|
|
|
1
|
-
#
|
|
1
|
+
# lwc — lovewebconnection
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
[](https://github.com/emeraldjewels4-rgb/lovewebconnection-cli/actions/workflows/tests.yml)
|
|
8
|
+
[](https://github.com/emeraldjewels4-rgb/lovewebconnection-cli/actions/workflows/migrations.yml)
|
|
9
|
+
[](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
|
+
## Next step
|
|
1912
|
+
|
|
1913
|
+
To prove the connection end to end, run `npm run chain` with real values. If the
|
|
1914
|
+
endpoint is your own node or a private network, set `LWC_RPC_AUTH` when it needs
|
|
1915
|
+
a bearer token, and prefer `https` — `http` to a non-loopback host is reported as
|
|
1916
|
+
a warning.
|