blockyard 0.0.1 → 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/CHANGELOG.md +679 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +4 -0
  4. package/README.md +172 -4
  5. package/SECURITY.md +38 -0
  6. package/bin/blockyard.js +40 -0
  7. package/config/pool-map.json +2620 -0
  8. package/docs/API.md +1575 -0
  9. package/docs/ARCHITECTURE.md +1307 -0
  10. package/docs/AUTO-UPDATE.md +269 -0
  11. package/docs/CONFIGURATION.md +840 -0
  12. package/docs/DEFECTS.md +813 -0
  13. package/docs/EFFECTS-AGENTS.md +448 -0
  14. package/docs/GETTING-STARTED.md +202 -0
  15. package/docs/INSTALL.md +490 -0
  16. package/docs/MEASUREMENTS.md +1254 -0
  17. package/docs/PRIVATE-LEADERBOARD.md +230 -0
  18. package/docs/RULES.md +681 -0
  19. package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
  20. package/docs/SECURITY-AUDIT.md +258 -0
  21. package/docs/SECURITY.md +195 -0
  22. package/docs/STATE-2026-09-09.md +200 -0
  23. package/docs/TROUBLESHOOTING.md +298 -0
  24. package/docs/USER-GUIDE.md +1022 -0
  25. package/package.json +53 -5
  26. package/public/404.html +9 -0
  27. package/public/css/app.css +1785 -0
  28. package/public/index.html +893 -0
  29. package/public/js/about.js +112 -0
  30. package/public/js/agents.js +964 -0
  31. package/public/js/app.js +1312 -0
  32. package/public/js/arkanoid.js +806 -0
  33. package/public/js/blockanoid.js +347 -0
  34. package/public/js/blockout.js +347 -0
  35. package/public/js/blockpack.js +428 -0
  36. package/public/js/blockscene3d.js +2678 -0
  37. package/public/js/breakout.js +224 -0
  38. package/public/js/charts.js +635 -0
  39. package/public/js/depthchart.js +311 -0
  40. package/public/js/details3d.js +2957 -0
  41. package/public/js/explorer.js +405 -0
  42. package/public/js/feepalette.js +149 -0
  43. package/public/js/fmt.js +162 -0
  44. package/public/js/goggles.js +886 -0
  45. package/public/js/kiosk.js +41 -0
  46. package/public/js/login.js +83 -0
  47. package/public/js/markets.js +357 -0
  48. package/public/js/mining.js +1138 -0
  49. package/public/js/panels.js +966 -0
  50. package/public/js/pricechart.js +188 -0
  51. package/public/js/settings.js +1014 -0
  52. package/public/js/tetris.js +226 -0
  53. package/public/js/tetrust.js +356 -0
  54. package/public/js/tetsound.js +175 -0
  55. package/public/login.html +33 -0
  56. package/scripts/blockfile-measure.js +156 -0
  57. package/scripts/browser-check.mjs +286 -0
  58. package/scripts/check.js +173 -0
  59. package/scripts/decode-check.js +81 -0
  60. package/scripts/doc-counts.js +109 -0
  61. package/scripts/donate-qr.py +20 -0
  62. package/scripts/fake-node.js +534 -0
  63. package/scripts/index-bench.js +216 -0
  64. package/scripts/index-benchmark.js +117 -0
  65. package/scripts/index-build.js +40 -0
  66. package/scripts/live-render-check.mjs +89 -0
  67. package/scripts/manage-users.js +132 -0
  68. package/scripts/motion-check.mjs +138 -0
  69. package/scripts/pool-map.js +157 -0
  70. package/scripts/setup.js +410 -0
  71. package/scripts/shots.mjs +272 -0
  72. package/scripts/smoke.sh +327 -0
  73. package/scripts/ui.js +174 -0
  74. package/server/auth/sessions.js +221 -0
  75. package/server/auth/users.js +243 -0
  76. package/server/chain/blockfile.js +234 -0
  77. package/server/chain/index/build.js +193 -0
  78. package/server/chain/index/heights.js +36 -0
  79. package/server/chain/index/live.js +276 -0
  80. package/server/chain/index/rows.js +145 -0
  81. package/server/chain/index/store.js +154 -0
  82. package/server/chain/index/worker.js +109 -0
  83. package/server/chain/tx.js +310 -0
  84. package/server/collect/gbt.js +229 -0
  85. package/server/collect/logparse.js +765 -0
  86. package/server/collect/logtail.js +189 -0
  87. package/server/collect/markets.js +333 -0
  88. package/server/collect/mining.js +333 -0
  89. package/server/collect/monitor.js +2516 -0
  90. package/server/collect/nextblock.js +275 -0
  91. package/server/collect/sync.js +386 -0
  92. package/server/config.js +620 -0
  93. package/server/http/api.js +1275 -0
  94. package/server/http/explorer.js +418 -0
  95. package/server/http/server.js +412 -0
  96. package/server/http/sse.js +176 -0
  97. package/server/http/static.js +212 -0
  98. package/server/main.js +628 -0
  99. package/server/netinfo.js +253 -0
  100. package/server/rpc/allowlist.js +130 -0
  101. package/server/rpc/client.js +414 -0
  102. package/server/store/audit.js +148 -0
  103. package/server/store/history.js +220 -0
  104. package/server/store/ledger.js +290 -0
  105. package/server/store/ring.js +173 -0
  106. package/server/util/fmt.js +29 -0
  107. package/systemd/blockyard.service +100 -0
@@ -0,0 +1,1307 @@
1
+ # Architecture
2
+
3
+ This guide is for contributors. It explains how BlockYard is put together, where
4
+ state lives, how data moves from the node to the screen, and which rules the code
5
+ depends on. It covers the reasoning as well as the structure, because most of the
6
+ unusual choices here are responses to measured behaviour of the node being
7
+ monitored.
8
+
9
+ Companion documents:
10
+
11
+ - `docs/RULES.md`: the engineering rules, each with the defect that caused it.
12
+ - `docs/MEASUREMENTS.md`: timings and payload sizes measured against real nodes.
13
+ Check it before you change a poll interval, timeout or payload.
14
+ - `docs/DEFECTS.md`: known gaps, including the ones that are deliberately not fixed.
15
+
16
+ ---
17
+
18
+ ## 1. The big picture
19
+
20
+ BlockYard is a single Node.js process (Node 22 or later) with **no dependencies**.
21
+ It sits between one or more Bitcoin Core nodes and any number of
22
+ browsers:
23
+
24
+ - **Upstream**, it reads each node's JSON-RPC interface. If configured, it also
25
+ follows the node's log file (experimental-node builds only; Bitcoin Core's
26
+ `debug.log` is not parsed, and the log source is off by default). Where an
27
+ address index has been built from a node's block files, it reads that too
28
+ (section 2.8).
29
+ - **Downstream**, it serves a static single-page app (vanilla ES modules, all
30
+ drawing done by hand on `<canvas>`), a JSON API, and a Server-Sent Events stream.
31
+
32
+ ```mermaid
33
+ flowchart LR
34
+ subgraph node["Bitcoin node (one per configured node)"]
35
+ RPC["JSON-RPC server<br/>single connection, single thread"]
36
+ LOG["log file<br/>(optional)"]
37
+ end
38
+
39
+ subgraph server["BlockYard server (Node, no deps)"]
40
+ LANE["RPC lane<br/>server/rpc/client.js"]
41
+ MON["NodeMonitor<br/>server/collect/monitor.js"]
42
+ TAIL["LogTail + logparse"]
43
+ HIST["History rings<br/>server/store/history.js"]
44
+ MKT["MarketFeed<br/>server/collect/markets.js<br/>(on demand)"]
45
+ HTTP["HTTP: API, static, SSE hub<br/>server/http/*"]
46
+ end
47
+
48
+ subgraph browser["browser"]
49
+ APP["app.js state + router"]
50
+ PANELS["panels / charts / 3D viewer"]
51
+ end
52
+
53
+ EX["public exchange REST APIs"]
54
+
55
+ MON -->|"batched polls"| LANE --> RPC
56
+ LOG --> TAIL --> MON
57
+ MON --> HIST
58
+ MON -->|"snapshot about 1/s, events"| HTTP
59
+ HIST -->|"series every 20 s"| HTTP
60
+ MKT <-->|"HTTPS, only while a Markets view is open"| EX
61
+ MKT --> HTTP
62
+ HTTP -->|"SSE: snapshot / series / events"| APP
63
+ APP -->|"fetch /api/..."| HTTP
64
+ APP --> PANELS
65
+ ```
66
+
67
+ ### Where state lives
68
+
69
+ | State | Location | Lifetime |
70
+ |---|---|---|
71
+ | Latest node state (chain info, mempool info, peers, fees, tips, ...) | `NodeMonitor.state`, one per node | memory; rebuilt by polling after a restart |
72
+ | Time series (node, mempool, net, fees, peers, blocks, txflow, rpc, self) | `History` rings, shared by every node, each row tagged with its node id | memory, **snapshotted to disk** |
73
+ | Event feed | `History.events`, newest first, capped | memory, stored in the same snapshot |
74
+ | Block stats map, mining attribution rows | `NodeMonitor.state.blocks`, `NodeMonitor.mining` | memory, bounded (`store.blockMapCap`, a 60-height attribution queue) |
75
+ | Market tickers, candles, depth snapshots | `MarketFeed` | memory; about an hour of depth history |
76
+ | Users, sessions | `data/users.json`, `data/sessions.json` | disk (atomic writes; session tokens are stored hashed) |
77
+ | Audit trail | `data/audit.jsonl` plus rotated `audit.N.jsonl` | disk, rotated by size |
78
+ | Display settings (effects, finish, the games' options) | `config/blockyard.json`, via `GET`/`POST /api/settings` | disk; the browser keeps a `localStorage` copy so boards still draw when the server is unreachable |
79
+ | Address index (rows per address and transaction) | the directory named by a node's `addressIndex` (section 2.8) | disk, built once (by the server in the background on its first start, or by `scripts/index-build.js`), then followed block by block |
80
+ | Browser cache | `state.byNode` in `app.js` | the tab's lifetime |
81
+
82
+ ### What is persisted
83
+
84
+ Everything lives under `store.dir`. The default is `data/` in the repo;
85
+ `BLOCKYARD_DATA` overrides it.
86
+
87
+ - **`history.json`** holds every ring plus the event log. `History.save()` writes
88
+ it with tmp, `fsync`, then rename, so a crash leaves either the old file or the
89
+ new one, never half of one. Autosave runs every `store.snapshotEveryMs` (default
90
+ 120 s) while the store has unsaved rows, and once more at shutdown. On load, rows
91
+ older than `store.retentionHours` (default 72) are pruned. Each ring holds at most
92
+ `store.ringCapacity` rows (default 20,000). Downsampling happens when data is
93
+ read (`Ring.series(field, { since, bucketMs, agg })`), so charts receive a few
94
+ hundred points instead of the raw ring.
95
+ - **The ledger** (`server/store/ledger.js`) is a durable store for coinbase
96
+ attribution rows, keyed by height. It has two engines behind one interface:
97
+ `node:sqlite` (WAL, `synchronous=FULL`) when the runtime has it, and an
98
+ append-only `fsync`'d JSONL file otherwise. Aggregation (`aggregate()`) is
99
+ written in JavaScript over the same row shape, so both engines give the same
100
+ answers. The module and its crash-safety behaviour are covered by
101
+ `test/ledger.test.js`. The monitor's live attribution view is currently built
102
+ from its bounded in-memory maps.
103
+ - **Pool labels** are read from `pool-map.json` and `pool-aliases.json` (edited
104
+ by hand). A pool map ships in `config/pool-map.json` (from mempool.space's
105
+ mining-pools data set, MIT, 151 pools); `data/pool-map.json`, written by
106
+ `scripts/pool-map.js`, overrides it, and `BLOCKYARD_POOL_MAP` overrides both.
107
+ The aliases file is optional. A tag no map knows is shown exactly as the miner
108
+ wrote it.
109
+
110
+ ### What is streamed
111
+
112
+ One SSE connection per tab (`GET /api/stream?node=<id>`) carries three event types:
113
+
114
+ | event | cadence | content |
115
+ |---|---|---|
116
+ | `snapshot` | at most one per second per node, coalesced | the read model from `NodeMonitor.snapshot()`: sync, tip, mempool aggregates, peers summary, net, attribution, the latest 40 blocks, log health, `health.quality` |
117
+ | `series` | every 20 s, only while clients are connected | chart series from `NodeMonitor.seriesView()` |
118
+ | `events` | batched as they happen | feed rows (routine log chatter is stored but not pushed) |
119
+
120
+ Large or slowly changing datasets are **not** in the snapshot. Each one has its
121
+ own endpoint and is fetched on its own schedule:
122
+
123
+ - the mempool scatter and cell list: `/api/mempool`
124
+ - the dense next-block view: `/api/mempool/dense`
125
+ - peer rows: `/api/peers`
126
+ - the block template: `/api/nextblock`
127
+ - block history beyond 40: `/api/blocks?limit=`
128
+
129
+ The test for whether a field belongs in the snapshot: does it change every second?
130
+ If it doesn't, it doesn't go in the snapshot (RULES 6).
131
+
132
+ ---
133
+
134
+ ## 2. The server
135
+
136
+ ```
137
+ server/
138
+ main.js boot, wiring, shutdown, logger
139
+ config.js defaults + config/local.json + BLOCKYARD_* env overrides, validation
140
+ netinfo.js bind planning, CIDR parsing and membership
141
+ rpc/client.js the serialized RPC lane and the JSON-RPC client
142
+ rpc/allowlist.js which RPC methods the web UI may call; gated node actions
143
+ collect/monitor.js NodeMonitor: poll tiers, log absorption, read model
144
+ collect/sync.js the sync bar's data contract (pure)
145
+ collect/logtail.js log follower (rotation, truncation, partial lines)
146
+ collect/logparse.js log line parsers (pure; target an EXPERIMENTAL node's grammar, not Core's)
147
+ collect/mining.js coinbase decoding and pool ledger folding (pure)
148
+ collect/gbt.js the block being built, assembled from the mempool (pure)
149
+ collect/nextblock.js template summary and package analysis (pure)
150
+ collect/markets.js exchange feed (tickers, candles, order books, spot price)
151
+ store/ring.js ring buffer, CounterRate, read-time downsampling
152
+ store/history.js named series, per-node views, atomic snapshots
153
+ store/ledger.js durable attribution store (sqlite or JSONL)
154
+ store/audit.js size-rotated audit trail
155
+ auth/users.js scrypt-hashed user store
156
+ auth/sessions.js sessions, CSRF, rate limiting, login guard
157
+ http/server.js request pipeline: gate, auth, CSRF, routing
158
+ http/api.js the route table
159
+ http/sse.js the SSE hub
160
+ http/static.js static files, CSP, nonce + build-id rewriting
161
+ http/explorer.js block / transaction pages over RPC; address pages from the local index
162
+ chain/blockfile.js Core's blk/rev files read directly: XOR key, record framing, undo decoding (pure, read-only)
163
+ chain/tx.js raw transaction / block decoder in Core's verbose field names (pure)
164
+ chain/index/rows.js the 21-byte index row, built lean from block and undo bytes (pure)
165
+ chain/index/build.js the full build: heights, scan on a worker pool, check, sort, manifest
166
+ chain/index/worker.js the build worker: scan one file pair, or sort one bucket
167
+ chain/index/heights.js block hash -> height table in a SharedArrayBuffer, shared by the workers
168
+ chain/index/store.js IndexStore: base segments + layers + live tail, binary-searched lookups
169
+ chain/index/live.js LiveIndex: follows the chain over RPC, logs, rolls back, folds, merges
170
+ ```
171
+
172
+ ### 2.1 Boot (`server/main.js`)
173
+
174
+ `boot({ configFile, log })` builds a single `app` object and returns it. Tests call
175
+ it directly; running the file as a script calls it and prints the banner.
176
+
177
+ 1. `loadConfig()` merges defaults, the config file, and `BLOCKYARD_*` environment
178
+ variables, then validates the result. The config file is `config/local.json`
179
+ unless `BLOCKYARD_CONFIG` names another file or `none`. Invalid or unsafe
180
+ combinations are fatal. One example: enabling node write actions while accounts
181
+ are off, unless that is also explicitly allowed.
182
+ 2. TLS is decided before any listener exists. If a certificate and key are
183
+ configured, every listener is HTTPS and the session cookie becomes `Secure`.
184
+ 3. The history snapshot is loaded and autosave starts.
185
+ 4. Users and sessions are loaded. If accounts are enabled and no user exists, a
186
+ first admin is created and its password is printed once.
187
+ 5. In open mode, a warning is logged that names the bound addresses and what an
188
+ anonymous viewer can read.
189
+ 6. `npm run dev` (`BLOCKYARD_FAKE_NODE=1`) starts an in-process fake node and
190
+ replaces the node list with it, so a dev run never polls a real node.
191
+ 7. One `NodeMonitor` is created per configured node. A node whose datadir is
192
+ missing is skipped with a logged reason rather than kept as a permanently
193
+ offline panel. `wireMonitor()` connects each monitor's events to the SSE hub
194
+ and coalesces pushes to one snapshot per second.
195
+ 8. `MarketFeed` is created if `markets.enabled` is set. It stays idle until the
196
+ Markets API is requested.
197
+ 9. One HTTP(S) server is created per bound address, all sharing the same `app`.
198
+ An address missing at boot is skipped with a warning. Boot is fatal only when
199
+ none of the configured addresses exist.
200
+ 10. Housekeeping timers start: self-telemetry, session sweeps, the 20 s series push.
201
+ 11. For every distinct `addressIndex` directory in the node list, one `LiveIndex`
202
+ follower is started (fed by a node with a local `datadir` where there is one)
203
+ and registered with the explorer; it polls the node's tip every 30 s. A
204
+ follower that cannot open its index logs why, and the address page says the
205
+ same; nothing else waits on it (section 2.8). A directory with no finished
206
+ index (no `manifest.json`) is **built here, in the background**, unless the
207
+ node says `addressIndexBuild: "manual"` or has no `datadir` to read: worker
208
+ threads inside this process, the build's own `RpcClient` on a second lane
209
+ (its `getblockhash` batches once starved behind the monitor's multi-second
210
+ reads), paced by the monitor's lane telemetry, progress as the
211
+ `address-index-building` quality flag, `index` events at start, finish and
212
+ failure, and the follower started on completion (section 2.8).
213
+ `app.shutdown()` stops timers, closes streams, stops monitors, saves history
214
+ and sessions, and closes the listeners. A build in flight is not resumed:
215
+ the next start begins it again.
216
+
217
+ `boot({ log })` and `loadConfig({ ifaces, now })` are **seams**: tests inject a
218
+ logger, a fake interface list, or a fake clock instead of intercepting
219
+ `process.stdout` or the environment (RULES 22, 24).
220
+
221
+ ### 2.2 `NodeMonitor` and the poll tiers
222
+
223
+ `NodeMonitor` (`server/collect/monitor.js`) owns one node. It polls the node's RPC
224
+ on a set of **tiers**, absorbs parsed log events, and turns both into `state`, ring
225
+ rows, feed events and `health.quality` flags. It also produces the read model,
226
+ `snapshot()`.
227
+
228
+ Each tier makes **one batched RPC request**: many methods in one JSON array, sent
229
+ on one connection. Each tier has its own coalescing key and priority in the lane.
230
+ Lower priority numbers run first.
231
+
232
+ | Tier | Default interval | Methods | Lane priority | Feeds |
233
+ |---|---|---|---|---|
234
+ | `fast` | 4 s | `getblockchaininfo`, `getmempoolinfo`, `getconnectioncount`, `getnettotals`, `uptime` | 0 | sync bar, tip, mempool counters, bandwidth rate, new-tip detection, reorg detection |
235
+ | `mid` | 15 s | `getnetworkinfo`, `getmininginfo`, `getchaintips`, `estimatesmartfee` for 1/2/6/24/144 blocks, plus `getpeerinfo` in RPC-only mode | 2 | network info, fees, side tips |
236
+ | `pool` | 20 s | `getrawmempool true` (heavy timeout) | 6 | mempool distribution and cells, the dense next-block set |
237
+ | `slow` | 60 s | `getindexinfo`, `getchaintxstats 120`, and `gettxoutsetinfo muhash` **only for a node whose `getindexinfo` reports a synced `coinstatsindex`** (the first run asks `getindexinfo` alone, so the answer is known before the question is put; without the index that call walks the whole UTXO set — 41 s measured, every minute, on the node's one RPC thread — so the figures are flagged `utxo-unindexed` instead) | 5 | UTXO set, indexes, tx rate |
238
+ | `rare` | 15 min | `getpeerinfo`, `getdeploymentinfo`, `getrpcinfo`, `getaddrmaninfo`, `listbanned` | 7 | peer table, deployments, address book, ban table |
239
+
240
+ Some reads are not on a timer:
241
+
242
+ - **New tip:** `getblockstats` runs for the new heights (at most the newest 24 in
243
+ a burst). Mining attribution is queued for those heights: `getblock <hash> 1`
244
+ plus `getrawtransaction <coinbase> 2`, one block per tick, newest first, and
245
+ never during initial block download.
246
+ - **Block template:** assembled here, from the verbose mempool the pool tier
247
+ already reads — **no RPC call of its own**. Core publishes `depends`, the
248
+ ancestor sizes and fees, and `fees.chunk`/`chunkweight` (its own cluster-mempool
249
+ linearization) in `getrawmempool(true)`, which is everything the selection needs.
250
+ `/api/nextblock` serves it; it is as fresh as the pool tier's last read (20 s)
251
+ and takes ~50-70 ms of *our* CPU. Measured against the node's own
252
+ `getblocktemplate` on the same pool: 0.03% apart on fees (`collect/gbt.js`).
253
+ - **Explorer and console:** requests from the explorer and the read-only RPC
254
+ console go through the same lane (see 2.4).
255
+
256
+ Tier scheduling:
257
+
258
+ - **Staggered start.** The first runs are offset by 0, 0.7, 1.2, 1.6 and 2.6 s,
259
+ so boot does not send five requests at once to a single-threaded server.
260
+ - **Adaptive cadence.** `effectiveTierMs(name)` stretches a tier to the larger of
261
+ 2x the lane's average latency and 1.5x that tier's last run. The result is
262
+ capped, and the cap is never below the configured base (RULES 12). Once the
263
+ node speeds up, cadence returns to the configured value on its own.
264
+ - **Heavy-tier skip.** While average RPC latency is above 4x `rpc.slowLatencyMs`,
265
+ the `pool`, `slow` and `rare` tiers are skipped, except every fifth attempt.
266
+ This keeps the cheap `fast` tier fresh. The skip is announced as a quality flag.
267
+ - **No overlap.** A tier that is still running is not started again.
268
+
269
+ What the UI receives about once a second is `snapshot()`. It is assembled on
270
+ demand from `state`, the rate counters, the log state and `rpc.telemetry()`.
271
+ Beyond the raw figures, it carries the reasoning the UI needs to be honest:
272
+
273
+ - `sync` (from `collect/sync.js`) keeps two figures apart. `pct` is
274
+ blocks/headers, which fills the bar. `verificationProgress` is the node's own
275
+ estimate, drawn as a separate tick. The object also carries rate windows, an ETA
276
+ with its basis, caveats, and `strip`, the pre-assembled facts for the one-line
277
+ header.
278
+ - `hashrateEstEh` is withheld during IBD, with `hashrateNote` giving the reason.
279
+ - `net.downloadMeasured` and `net.uploadMeasured` gate on counters that actually
280
+ move, so a counter stuck at zero is drawn as absent rather than as an idle link.
281
+ - `health.cadence` reports the configured and effective interval of every tier.
282
+ `health.quality` lists the named gaps: what the monitor cannot currently tell you,
283
+ and why.
284
+ - `log.health` reports parser coverage and how long the log has been quiet.
285
+
286
+ ### 2.3 Collectors
287
+
288
+ - **`collect/sync.js`**: the sync state machine (`unknown`, `ibd`, `catching_up`,
289
+ `synced`, `stalled`, `reorg`) and the ETA rules. It refuses to estimate an ETA
290
+ without a measured rate over at least 60 s of samples. `computeSync` takes
291
+ `peerBestHeight` (the monitor passes the highest `synced_headers`, or
292
+ `startingheight`, over `getpeerinfo`): a node is `stalled` only when its peers
293
+ report a tip above the one it holds; a long gap with the peers agreeing is
294
+ `synced` with a caveat; with no peer heights at all it is `stalled` after
295
+ 7200 s, because a 40-minute gap happens about once in fifty on the network. It
296
+ is pure and unit-tested.
297
+ - **`collect/logtail.js`**: follows a file by polling, with `fs.watch` used only
298
+ as a wake-up. On rotation it drains the old inode to EOF before switching to the
299
+ new file. If the file shrinks, it restarts at 0. A partial last line is carried
300
+ over to the next read. At start it backfills the last `log.tailBytes`.
301
+ - **`collect/logparse.js`**: pure parsers for the node's log lines. **These target an
302
+ experimental node implementation's log grammar and do not support Bitcoin Core's
303
+ `debug.log`**: measured 2026-09-13, Core lines return unstructured `raw` events with no
304
+ fields and a fallback timestamp. The log source is off by default and should stay off
305
+ against Core. Labelled lines
306
+ are scanned **field by field**, so a new field costs only that field, which is
307
+ kept verbatim in `extraFields`, rather than the whole line (RULES 16). `SHAPES`
308
+ and `RULE_TO_SHAPE` drive per-shape liveness flags. Coverage is published as
309
+ `log.health.ratio` and held to a threshold against a frozen real sample (RULES 15).
310
+ - **`collect/mining.js`**: coinbase scriptSig push decoding, tag extraction, and
311
+ folding rows into per-pool counters. It never invents pool names.
312
+ - **`collect/gbt.js`**: assembles the block being built from `getrawmempool(true)`,
313
+ in the shape a `getblocktemplate` reply has, so every consumer below reads it
314
+ unchanged. Greedy over the node's own chunk feerate, each transaction taken with
315
+ its unselected ancestors. Pure.
316
+ - **`collect/nextblock.js`**: turns that template into the next-block card: header
317
+ figures, a fixed-bucket feerate histogram, and ancestor packages built from
318
+ `depends`. Pure, and still able to read a real `getblocktemplate` reply.
319
+ - **`collect/markets.js`**: `MarketFeed`, the only outbound connection that is not
320
+ a node. It is covered in detail below.
321
+
322
+ #### The market feed
323
+
324
+ The browser's CSP only allows connections to its own origin
325
+ (`connect-src 'self'`), so exchange data is fetched by the server. The feed reads
326
+ the public, unauthenticated REST endpoints of five exchanges: Coinbase, Kraken,
327
+ Bitstamp, Bitfinex and OKX.
328
+
329
+ - **Starts on demand.** `GET /api/markets` and `/api/markets/depth` call
330
+ `touch()`. The first touch starts three timers:
331
+ - tickers every `markets.tickerMs` (15 s)
332
+ - hourly candles every `markets.candleMs` (5 min, the latest 168)
333
+ - order books every `markets.bookMs` (30 s)
334
+ - **Stops when idle.** Polling parks after `markets.idleAfterMs` (10 min) without
335
+ a request. A monitor nobody is watching makes no exchange traffic.
336
+ - **Depth snapshots.** Each book poll turns every exchange's book into cumulative
337
+ depth on a fixed grid: $50 levels, ±12% around the median mid price.
338
+ `depthOf()` returns `null` past the end of a truncated book instead of
339
+ extending it. About an hour of snapshots is kept, and `depthView(ago)` realigns
340
+ an older snapshot onto the current grid for the "then" line and the change bars.
341
+ - **Spot price.** `spot()` supplies USD figures to the explorer. It uses the
342
+ median of fresh tickers if the feed is running. Otherwise it makes two USD
343
+ ticker reads, cached for a minute. The explorer waits at most 1.5 s for it
344
+ (`withUsd` in `api.js`).
345
+ - **Honest ages.** Every figure keeps its age. A failing exchange is reported
346
+ with its error, never zero-filled. Tickers older than three intervals are marked
347
+ `stale`.
348
+
349
+ `BLOCKYARD_MARKETS=0` (or `markets.enabled: false`) disables all of it.
350
+
351
+ ### 2.4 The RPC lane (`server/rpc/client.js`)
352
+
353
+ **The central constraint:** the node's RPC server services **one connection at a
354
+ time on one thread**. A bare `getblockcount` has been measured at 40 s on a node
355
+ doing initial block download (MEASUREMENTS 1). A dashboard where every tab polls
356
+ on its own would be a denial of service against the node it exists to watch. So
357
+ every RPC request, from every poll tier, every user, the explorer and the console,
358
+ goes through **one `Lane` per node**.
359
+
360
+ ```mermaid
361
+ flowchart TD
362
+ subgraph callers
363
+ F["fast tier, key node:fast, prio 0"]
364
+ M["mid tier, prio 2"]
365
+ X["explorer, prio 3, maxWait 45 s"]
366
+ P["pool tier, prio 6"]
367
+ R["rare tier, prio 7"]
368
+ U["console / actions, unkeyed"]
369
+ end
370
+ callers -->|"submit(job, key, priority, maxWaitMs)"| Q{"breaker open?"}
371
+ Q -->|yes| REJ["reject: breaker"]
372
+ Q -->|no| CO{"same key already pending?"}
373
+ CO -->|yes| SUP["older job rejected as stale<br/>newest wins"]
374
+ CO -->|no| PEND["pending Map"]
375
+ SUP --> PEND
376
+ PEND --> D["_drain: lowest priority number first,<br/>insertion order within a priority"]
377
+ D --> B2{"breaker open now?"}
378
+ B2 -->|yes| REJ
379
+ B2 -->|no| ST{"waited longer than its budget?"}
380
+ ST -->|yes| DROP["drop as stale, do not ask"]
381
+ ST -->|no| SP["wait until spacingMs since last start"]
382
+ SP --> RUN["one HTTP POST, Connection: close<br/>(single call or batch array)"]
383
+ RUN --> DONE["resolve / reject, then drain next"]
384
+ ```
385
+
386
+ Properties:
387
+
388
+ - **One in flight.** `busy` gates `_drain()`. The node could not use more than one
389
+ connection anyway, and a second request would only queue inside the node, where
390
+ the monitor cannot see or cancel it.
391
+ - **Minimum spacing.** `spacingMs = max(rpc.minIntervalMs, 1000 / rpc.maxRatePerSec)`.
392
+ The defaults (250 ms, 4 per second) both come to 250 ms between request starts.
393
+ - **Batching.** `RpcClient.batch(calls)` sends a JSON array, which the node
394
+ answers on one connection. Replies are matched back to calls by id. A method
395
+ that fails inside a batch is reported for that method only; the rest of the
396
+ batch still succeeds.
397
+ - **Coalescing by key.** Poll jobs carry a key such as `<node>:fast`. A new job
398
+ with the same key replaces a pending one, and the old one is rejected with
399
+ `kind: 'stale'`. User-initiated calls carry no key, so each one is answered.
400
+ - **Priority.** Without it, a 60+ second heavy batch once starved the cheap poll
401
+ that feeds the sync bar. With it, `fast` always runs next.
402
+ - **Stale drop.** A job still queued after its budget (`rpc.staleDropMs`, 12 s by
403
+ default; explorer jobs 45 s) is dropped rather than run. The answer would
404
+ describe a moment that has already passed, but would be displayed as current.
405
+ - **Circuit breaker.** After `breakerThreshold` (3) consecutive failures (timeout,
406
+ transport, auth or parse errors, but not RPC-level errors and not stale drops),
407
+ the lane refuses new and queued work for `breakerCooldownMs` (30 s). The breaker
408
+ is checked both at submit and at dequeue. `breakerState()` records which call
409
+ opened it and what it blocked.
410
+ - **Timeouts.** `rpc.timeoutMs` is 90 s and `heavyTimeoutMs` is 300 s, sized from
411
+ measurements rather than guesses (RULES 1). Failures and timeouts are also timed,
412
+ so a node that stalls for 90 s shows up in the latency statistics that adaptive
413
+ cadence reads.
414
+ - **No keep-alive.** Holding a socket open between requests would hold the node's
415
+ only service slot.
416
+ - **Cookie auth.** Credentials are resolved lazily and re-read once on a 401,
417
+ because the node regenerates its cookie every time it starts.
418
+
419
+ ### 2.5 The RPC allowlist (`server/rpc/allowlist.js`)
420
+
421
+ The read-only console (`POST /api/rpc`) may only call methods that
422
+ `classifyMethod()` allows. The decision order:
423
+
424
+ 1. **Explicit deny list**, checked first. It covers wallet key derivation (even
425
+ methods that start with `get`), spends, wallet state, peer control, chain
426
+ mutators, and heavy reads that would monopolise the node (`rescanblockchain`,
427
+ `scantxoutset`, `verifychain`, ...).
428
+ 2. **Deny prefixes** such as `send`, `set`, `import`, `sign` -- and, on a node that
429
+ adds vendor-prefixed commands of its own, their vendor-prefixed forms too.
430
+ 3. **Allow prefixes** that read as reads: `get`, `list`, `estimate`, `decode*`,
431
+ ... A node's vendor-prefixed **read** verbs are admitted by name; the bare vendor
432
+ prefix deliberately is not, so a future vendor-prefixed `setban` cannot slip in.
433
+ 4. **Anything else is denied.** The reply says which file to edit.
434
+
435
+ Node **writes** do not go through this file. They are named `ACTIONS` behind
436
+ `POST /api/action`. An action must be enabled (`BLOCKYARD_ENABLE_ACTIONS=1`) and
437
+ listed in `actions.allow`. The caller must hold the action's role and send a
438
+ typed confirmation equal to the action's name. With accounts off, actions also
439
+ need `BLOCKYARD_ALLOW_WRITES_WITHOUT_AUTH=1`, which is checked again inside the
440
+ route. Every action is audited.
441
+
442
+ ### 2.6 HTTP (`server/http/*`)
443
+
444
+ **Request pipeline** (`server.js`, one server per bound address):
445
+
446
+ ```mermaid
447
+ flowchart LR
448
+ A["request"] --> G{"server.allowCidrs set<br/>and address outside it?"}
449
+ G -->|yes| G403["403"]
450
+ G -->|no| O{"OPTIONS?"}
451
+ O -->|yes| O204["204"]
452
+ O -->|no| S{"GET /api/stream?"}
453
+ S -->|yes| SSE["auth or anonymous, rate check,<br/>404 for an unknown node,<br/>then hand the response to the SSE hub"]
454
+ S -->|no| R{"route matches?"}
455
+ R -->|no| STAT["static file, 404 page or 405"]
456
+ R -->|yes| AU["open mode: anonymous viewer, rate-limited per address<br/>accounts mode: session required, rate-limited per user"]
457
+ AU --> CEIL{"route needs admin<br/>and role is not admin?"}
458
+ CEIL -->|yes| C403["403 (checked in both modes)"]
459
+ CEIL -->|no| BODY["read body, 1 MB cap"] --> CSRF{"session present and route has csrf?<br/>token must come from header or body, never the cookie"}
460
+ CSRF --> H["handler(ctx, app)"] --> J["JSON response + security headers"]
461
+ ```
462
+
463
+ - **`api.js`** is a declarative route table: `{ method, path, auth, csrf, body,
464
+ handler }`. `auth` is `none`, `any` or `admin`. Handlers are thin: most call
465
+ `pickNode(ctx, app)` (from `?node=`, falling back to the primary node) and
466
+ return part of that monitor's read model. The main groups:
467
+ - health, build, session: `/api/health`, `/api/build`, `/api/about`, `/api/me`,
468
+ `/api/login`, `/api/logout`, `/api/logout-all`
469
+ - the read model: `/api/state`, `/api/sync`, `/api/mempool`,
470
+ `/api/mempool/dense`, `/api/peers`, `/api/net`, `/api/mining`,
471
+ `/api/nextblock`, `/api/blocks`, `/api/series`, `/api/events`, `/api/nodes`,
472
+ `/api/telemetry`, `/api/config`
473
+ - drill-down: `/api/block`, `/api/tx`
474
+ - explorer: `/api/x/search`, `/api/x/block`, `/api/x/tx`, `/api/x/address`
475
+ - markets: `/api/markets`, `/api/markets/depth`
476
+ - console and actions: `/api/rpc`, `/api/actions`, `/api/action`
477
+ - configuration written by the app: `/api/config/node/test` and
478
+ `/api/config/node` (the node-connection form; credentials go only to the
479
+ endpoint already configured), `/api/settings` (display settings, stored in
480
+ `config/blockyard.json`). State-changing routes in open mode refuse
481
+ cross-site requests by `Origin` / `Sec-Fetch-Site`.
482
+ - admin: `/api/users*`, `/api/password`, `/api/audit`
483
+ - **`sse.js`** (`StreamHub`): each client has at most one pending snapshot and one
484
+ pending series frame, and the newest replaces the older one. Event rows are
485
+ batched, capped at 800 per client, and trimmed if a client falls behind. Writes
486
+ are flushed on `setImmediate`. A comment ping every 15 s keeps intermediaries from
487
+ closing idle streams and detects dead sockets. A slow client gets fewer updates,
488
+ never an unbounded queue.
489
+ - **`static.js`**: serves files from `public/`.
490
+ - **Containment** is checked on the `realpath`, so both `../` traversal and
491
+ symlinks planted inside `public/` are refused.
492
+ - **HTML is rewritten per response** (`renderHtml`): a fresh script nonce
493
+ replaces `%BLOCKYARD_NONCE%`, the build id replaces `%BLOCKYARD_BUILD%`, and asset URLs get
494
+ `?v=<build>`.
495
+ - **The build id** is computed from the sizes and mtimes of files under
496
+ `public/`. The page compares the build it loaded with `/api/build` and tells
497
+ the user when the tab is out of date.
498
+ - Every response carries the same security headers. HSTS is added only on TLS
499
+ listeners.
500
+ - **`explorer.js`**: block and transaction pages built from this node's own RPC;
501
+ address pages from the local address index.
502
+ - **Batched.** Each page is one or two batched lane requests at priority 3.
503
+ Fetching 25 transactions one call at a time would take several seconds at
504
+ 250 ms spacing.
505
+ - **Cached.** Confirmed transaction summaries are kept in an LRU of 3,000.
506
+ - **Cheap reads only.** `getblock` verbosity 2 is never used (it is megabytes
507
+ per block on this node).
508
+ - **Addresses.** Core has no address index at any setting, so `xAddress` reads
509
+ the index a node names in `addressIndex` (section 2.8): count, balance,
510
+ received and sent, and the transactions newest first with the net change
511
+ each made, 25 a page, and the unspent outputs (each output the index says
512
+ paid the address, checked with `gettxout`, for histories of at most 100
513
+ transactions; longer ones get `utxos: null` and a `utxoNote`). The reply
514
+ carries `index.tip`, `index.behind`, `index.following`, `index.stale` and
515
+ `index.postTip`. Without an index the reply says `indexed: false` with a
516
+ **null** count, never a fabricated zero, and the two insight-style RPCs Core
517
+ refuses are remembered as refused for ten minutes rather than re-sent on
518
+ every view. While the server is building the index, the same shape carries
519
+ `indexBuilding` (phase, done, total, rows, eta, paused) from the status
520
+ object `main.js` registers with `registerIndexBuild`.
521
+ - **Errors are sentences.** Handlers return `{ ok: false, error, hint }` rather
522
+ than throwing.
523
+
524
+ ### 2.7 Auth (`server/auth/*`)
525
+
526
+ - **Open by default.** With `auth.enabled: false` (the default), every request is
527
+ served as a frozen anonymous user with role `viewer`. That ceiling is hardcoded,
528
+ not configurable. Admin routes return 403 in both modes, CSRF is not needed
529
+ because there is no cookie to ride, and rate limits apply per client address.
530
+ - **Accounts.** `BLOCKYARD_AUTH=1` turns on:
531
+ - scrypt password hashes (upgraded to current parameters on login)
532
+ - session tokens that are stored hashed, with idle and absolute TTLs
533
+ - double-submit CSRF (the token in a header or body is compared with the
534
+ server-side session, never with the cookie; RULES 3)
535
+ - a per-username lockout (`LoginGuard`) and a separate per-address limit on
536
+ `/api/login`
537
+ - roles `viewer` < `operator` < `admin`
538
+ - **Auditing.** Logins, RPC calls, denials and actions are appended to the audit
539
+ trail. Credentials are removed from entries before they are written.
540
+
541
+ `node scripts/manage-users.js` administers users from the command line.
542
+
543
+ ### 2.8 The address index (`server/chain/*`)
544
+
545
+ Bitcoin Core cannot answer "which transactions touched this address": the
546
+ insight-style `getaddresstxids` and `getaddressbalance` are refused at every
547
+ setting, and `scantxoutset` reads the whole UTXO set for a balance only
548
+ (measured: 26.5 s for 40 addresses, holding the node's one RPC thread). So
549
+ BlockYard builds the index itself, the way `electrs` does, from the node's own
550
+ files. The numbers are in `docs/MEASUREMENTS.md` §28-30 and the history in
551
+ `docs/DEFECTS.md`.
552
+
553
+ - **Reading the files** (`chain/blockfile.js`). `blocks/blkNNNNN.dat` holds the
554
+ blocks as received; `revNNNNN.dat` holds the undo data written when each block
555
+ was connected, which names every spent output's value and script. Both are
556
+ XOR-obfuscated at rest since Core v28 (`blocks/xor.dat`). Undo records are
557
+ paired with blocks in connection order and checksum-verified. The files are
558
+ opened for reading and nothing else, and the reader copes with the newest file
559
+ still being appended to. `chain/tx.js` decodes raw transactions and blocks into
560
+ Core's verbose field names, checked field-for-field against `getblock <hash> 3`
561
+ (`scripts/decode-check.js`).
562
+ - **The row** (`chain/index/rows.js`): 21 bytes per (script, transaction that
563
+ touched it) -- 8 bytes of `sha256(scriptPubKey)`, a 3-byte height, a 2-byte
564
+ position in the block, and the signed net satoshis the transaction moved for
565
+ that script -- big-endian, so byte order is sort order. A script paid and spent
566
+ in one transaction is one row. Spends come from the undo record, so no UTXO
567
+ replay. The rows are built lean, straight from the bytes, and checked
568
+ row-for-row against rows from the full decoder.
569
+ - **The build** (`chain/index/build.js`, `node scripts/index-build.js --out <dir>`):
570
+ block hashes for every height in batches of `getblockhash` (`heights.js`, a
571
+ `SharedArrayBuffer` table every worker reads); every blk/rev pair scanned on a
572
+ worker pool (`worker.js`), rows partitioned by the key's first byte into 256
573
+ bucket files; a check that every height is indexed exactly once, or the build
574
+ stops rather than publish a hole; each bucket sorted into `seg-XX.rows` plus a
575
+ sparse `seg-XX.idx` (one key per 4,096 rows); and a manifest written last, so an
576
+ index without one is unfinished. Measured on the whole chain: 29 min 45 s on 16
577
+ workers, 5.89 billion rows, 123.7 GB (§30). The build reads ~880 GB and writes
578
+ ~120 GB, so `--out` should be a different device from the block files.
579
+ - **Lookups** (`chain/index/store.js`, `IndexStore`). The sparse keys of the 256
580
+ base segments and of every layer are held in memory; a lookup binary-searches
581
+ them and reads only the row blocks that can hold its key, then merges the base,
582
+ the layers and the live tail in height order. Measured: 0.25 ms median first
583
+ touch, 0.03 ms warm; a 2.3 M-transaction address summed in 83 ms. **No file is
584
+ held open**: a lookup opens the one file it reads and closes it (three
585
+ syscalls on a 0.25 ms lookup). The store used to keep one descriptor per
586
+ segment and layer, 256 and more, which is the whole soft limit on a stock
587
+ macOS (`ulimit -n` 256) before the server has opened a socket.
588
+ - **The build inside the server** (`main.js`, using `buildIndex`, `defaultWorkers`
589
+ and `rpcPacer` from `build.js`). A configured directory with no
590
+ `manifest.json` is built in the background on the server's first start, unless
591
+ `addressIndexBuild: "manual"`. Workers: `addressIndexWorkers` if set, else half
592
+ of `defaultWorkers()` (`cpus − 4`, one per ~2.5 GB, at most 16) capped at 4,
593
+ because the node shares the disk. The build has its own `RpcClient` on a second
594
+ lane — on the monitor's one-in-flight lane its `getblockhash` batches sat
595
+ behind multi-second mempool and block reads and both starved. It is **paced by
596
+ the node's own answers**: `rpcPacer` reads the monitor lane's telemetry before
597
+ each file is handed to a worker, holds (re-checking every 10 s) while the node
598
+ is failing, its breaker is open or its average latency is above
599
+ `rpc.slowLatencyMs` (5 s), and eases to one file per 250 ms above 40% of it.
600
+ Measured on the first Mac install: a build at full speed on the node's disk
601
+ turned its RPC into 18 s answers and 90 s timeouts. Progress is the
602
+ `address-index-building` quality flag (phase, done of total, rows, ETA from
603
+ the phase's own rate, `paused while the node's RPC is slow`), refreshed at most
604
+ every 5 s; `index` events mark start, finish and failure; on finish the follower
605
+ starts and address pages go live with no restart; failure leaves
606
+ `address-index-build-failed` naming the command to run by hand. There is no
607
+ resume: a build stopped with the server starts over next time.
608
+ - **Following the chain** (`chain/index/live.js`, `LiveIndex`). The base is
609
+ immutable and covers the chain to the block it was built at. The follower polls
610
+ the node's tip, rolls the tail back to the fork if a block it holds is no longer
611
+ on the node's chain, and fetches each new block with `getblock <hash> 3` over
612
+ RPC (so it works for a node whose files are elsewhere), turning it into the same
613
+ rows (`verboseBlockRows`, checked against the file builder). Every block and
614
+ every rollback is appended to `<index>/live.log` -- CRC-framed, replayed on
615
+ restart, truncated at the first torn record -- **before** it is served. Blocks
616
+ 100 deep are folded, 144 at a time, into immutable `layers/L<from>-<to>` and the
617
+ log is rewritten without them; past 32 layers they are merged. A reorganisation
618
+ deeper than the tail is not repaired: the index reports itself stale and the
619
+ page says to rebuild.
620
+ - **Configuration.** `addressIndex: "<dir>"` on a node entry, with
621
+ `addressIndexBuild: "manual"` to keep the server from building a missing one
622
+ and `addressIndexWorkers` for the background build's thread count (the
623
+ installer writes the number given; 1 on a spinning disk). One index serves
624
+ every node on the same chain (the store refuses a manifest for another chain);
625
+ the directory must be writable by the service, because the follower writes
626
+ `live.log` and `layers/` inside it. The explorer opens a store once and reopens
627
+ it when a rebuild replaces the manifest.
628
+ - **Scripts.** `scripts/blockfile-measure.js` (what a full read costs, `--verify`
629
+ against the node), `scripts/index-bench.js` (SQLite against sorted flat files,
630
+ §29), `scripts/index-build.js` (the build, by hand), `scripts/index-benchmark.js`
631
+ (lookup latency and `--verify` balances against `scantxoutset`, §30),
632
+ `scripts/setup.js` (the installer: reads the node's `bitcoin.conf`, proves the
633
+ node with `scripts/check.js`, writes the config, builds the index or leaves it
634
+ to the server) and `scripts/check.js` (the same checks against every configured
635
+ node, every RPC call timed — measure a slow node alone before blaming what runs
636
+ beside it).
637
+ - **Tests**: `test/chain-blockfile.test.js`, `test/chain-tx.test.js`,
638
+ `test/chain-index.test.js`, `test/chain-index-live.test.js`.
639
+
640
+ ---
641
+
642
+ ## 3. The browser
643
+
644
+ `public/index.html` is the whole application shell: a header, a nav, and one
645
+ `<section class="page" data-page="...">` per page. All scripts are ES modules
646
+ loaded from the same origin. There is no build step and no framework.
647
+
648
+ | Module | Role |
649
+ |---|---|
650
+ | `app.js` | state, API client, SSE client, hash router, render dispatch, background refresh, watchdogs, the sync strip, the Overview page |
651
+ | `panels.js` | Chain, Mempool, Peers, Network, Events, Node & RPC, Admin pages |
652
+ | `mining.js` | Block flow, the Block space viewer (both modes), the Mining page and next-block panels |
653
+ | `charts.js` | the canvas chart kit: `lineChart`, `histogram`, `scatter`, `meter`, `stackedBars`, `sparkline`, and `paint()` |
654
+ | `explorer.js` | explorer pages (pure renderers, one fetch per route) |
655
+ | `markets.js`, `pricechart.js`, `depthchart.js` | Markets page: flat price chart with axes and crosshair, the same candles on the 3D board, the depth chart |
656
+ | `kiosk.js` | the 3D Markets board and the Block space board side by side, with a full-screen button |
657
+ | `goggles.js` | the 2D treemap maps (squarified) |
658
+ | `blockpack.js`, `feepalette.js`, `blockscene3d.js`, `details3d.js`, `agents.js` | the 3D engine (section 4) |
659
+ | `settings.js` | display settings: `DEFAULTS`, the `PANEL` rows of the settings dialog, `normalise()`, and the option builders (`spaceOptions`, `enabledEffects`, ...) the boards read; stored on the server (`/api/settings`) with a `localStorage` copy |
660
+ | `about.js` | the About page (version, system and node info) |
661
+ | `tetris.js` / `tetrust.js`, `breakout.js` / `blockout.js`, `arkanoid.js` / `blockanoid.js`, `tetsound.js` | the Diversions: pure game rules in the first file of each pair, the tab drawn on the 3D engine in the second, and Tetrust's sound |
662
+ | `fmt.js` | formatters: decimal units (as the node prints them), `–` for anything absent |
663
+ | `login.js` | the login page (a separate file because of the CSP) |
664
+
665
+ ### 3.1 `app.js`
666
+
667
+ **State.** A single exported `state` object. The main fields:
668
+
669
+ - `snap`, `series`, `events`: what is currently displayed.
670
+ - `byNode`: a per-node cache of snapshot and series, so switching back to a node
671
+ is instant.
672
+ - `node`: the node being watched.
673
+ - `page` and `xroute`: the current page and explorer subroute.
674
+ - `viewerMode`: the Block space viewer mode, remembered in `localStorage`.
675
+ - `mempoolDist`, `denseBlock`: the pool data the viewers draw.
676
+
677
+ **Data flow.**
678
+
679
+ ```mermaid
680
+ sequenceDiagram
681
+ participant B as app.js
682
+ participant S as server
683
+ B->>S: GET /api/me, /api/nodes, /api/config, /api/build
684
+ B->>S: GET /api/state?node=... (backgroundRefresh)
685
+ B->>S: GET /api/events?limit=200
686
+ B->>S: EventSource /api/stream?node=...
687
+ loop about once a second
688
+ S-->>B: event: snapshot (state.snap = s; render())
689
+ end
690
+ loop every 20 s
691
+ S-->>B: event: series (merged into the cache, never replaced)
692
+ B->>S: GET /api/state (only while the tab is visible and not paused)
693
+ end
694
+ S-->>B: event: events (feed rows, capped at 1,500)
695
+ Note over B: page-scoped fetches on their own schedules:<br/>/api/mempool (30 s on the viewer pages),<br/>/api/nextblock (20 s on Mining and Block space, 60 s on Overview),<br/>/api/peers (15 s on Peers), /api/markets (while Markets is open)
696
+ ```
697
+
698
+ **Rendering is event-driven.** `render()` runs when a frame arrives, on a page
699
+ change, on resize, and after a page-scoped fetch completes. It switches on
700
+ `state.page` and calls that page's renderer with `(snapshot, state, helpers)`.
701
+ `helpers` is how the page modules get `api`, `toast`, `setText`, `canvas`, the
702
+ chart kit and the fetch helpers without importing `app.js` (which would be a
703
+ circular import).
704
+
705
+ Continuous animation does **not** go through `render()`. Each 3D canvas runs its
706
+ own `requestAnimationFrame` loop, which stops as soon as the picture is still
707
+ (section 4.7).
708
+
709
+ **Router.** The route is the URL hash: `#overview`, `#space`, `#chain`, ...
710
+ `#explorer` has subroutes (`#explorer/block/<height|hash>[/<page>]`,
711
+ `#explorer/tx/<txid>`, `#explorer/address/<addr>[/<page>]`), which `setPage()`
712
+ splits into `state.page = 'explorer'` and `state.xroute`. Every explorer page is
713
+ therefore a shareable link.
714
+
715
+ **Liveness and honesty rules in the client.**
716
+
717
+ - **Data is never nulled.** `state.snap` and `state.series` are never set to
718
+ `null`. Canvases are wiped in exactly one place, `switchNode()`, because the
719
+ pixels on screen belong to a different node.
720
+ - **Charts draw through `paint(canvas, { when, draw, placeholder })`.** A missing
721
+ sample marks a populated chart stale with a pill. It never erases it.
722
+ - **A watchdog runs every 15 s.** If nothing has arrived for 90 s, it tries to
723
+ recover the stream. If the node was removed from the config, it switches to a
724
+ node that exists. Otherwise it says in a banner that the figures are old. The
725
+ stream badge changes to `stale` when the snapshot is old.
726
+ - **Build check.** Every 5 minutes the tab compares the build it loaded with the
727
+ server's current build and says when it is running replaced code.
728
+ - **"Refresh now"** on the pool viewers is enabled only while that viewer's board
729
+ is at rest (`viewerIdle(canvas)`).
730
+
731
+ ### 3.2 Page modules
732
+
733
+ - **`mining.js`**
734
+ - **`blockFlow()`**: the block being built on the left, the chain tip in the
735
+ centre, recent blocks to the right. The arrival animation is keyed on the
736
+ chain's tip height and plays only when the page actually saw the tip change,
737
+ never on first paint. The rail's speed comes from the measured average block
738
+ gap.
739
+ - **`renderBlockSpace()`**: the `#space` page. The pool viewer at panel size,
740
+ plus the next-block and tip panels.
741
+ - **Viewer modes** (`VIEWER_MODES`). Simple (id `1`) shows the richest cells as
742
+ cubes and the tail as equal aggregate pieces on a 44-unit board. Detailed
743
+ (id `2`) shows every transaction in the next block's worth, from
744
+ `/api/mempool/dense`, as low slabs on a 96-unit board (`DENSE_OPTS`:
745
+ `resolution: 96, slab: 1.2, order: 'diagonal', gridStep: 8`). Both modes use
746
+ full transaction ids, so switching modes moves tiles rather than emptying and
747
+ refilling the board, and a click on a tile opens it in the explorer.
748
+ - **One viewer everywhere.** `poolViewer()` is used on Overview, Block space,
749
+ Mempool and Kiosk, so the viewer cannot drift apart between pages.
750
+ - **`explorer.js`**: pure `data -> HTML` renderers, plus `renderExplorer()`, which
751
+ fetches once per route and repaints only when the output would change, so a
752
+ half-typed search survives the once-a-second frames.
753
+ - **`markets.js`**
754
+ - **The two boards.** It lays out candles as `board3d` tiles with a custom
755
+ camera (`CAMERA_3D`, section 4.8). `pricechart.js` draws the flat, readable
756
+ chart with axes and a crosshair.
757
+ - **The depth chart** (`depthchart.js`) draws cumulative bids and asks per
758
+ exchange, the combined line, the combined line N minutes ago, and change bars
759
+ on a symmetric log axis.
760
+ - **`kiosk.js`** reuses `renderMarketsBoard` and `poolViewer` unchanged.
761
+
762
+ ### 3.3 CSP constraints
763
+
764
+ The CSP (`server/http/static.js`) is same-origin only:
765
+
766
+ ```
767
+ default-src 'self'; script-src 'self' 'nonce-<per response>'; style-src 'self';
768
+ img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none';
769
+ base-uri 'self'; form-action 'self'; object-src 'none'
770
+ ```
771
+
772
+ What this means for front-end code:
773
+
774
+ - **No `style="..."` attributes**, in HTML files or in markup injected through
775
+ `innerHTML`. `style-src` has no `'unsafe-inline'` and there is no
776
+ `style-src-attr`. For data-driven sizes, write `data-w`, `data-left` or `data-h`
777
+ and call `applyDataSizes(root)`, which writes the number through the CSSOM
778
+ (`el.style.width = ...`). CSP allows that, and a value that never becomes an
779
+ attribute cannot be an injection point. Static styling belongs in classes in
780
+ `public/css/app.css`. CSS custom properties set with `style.setProperty()` are
781
+ fine.
782
+ - **No inline scripts** unless they carry the per-response nonce. In practice
783
+ every script is an external module.
784
+ - **No CDN, no third-party fetch.** External data (exchange prices) is fetched by
785
+ the server.
786
+ - **Escape all text** that comes from the node, a log line or an exchange with
787
+ `fmt.esc()` before putting it into markup.
788
+
789
+ `test/csp.test.js` and `test/web-contract.test.js` enforce these rules.
790
+
791
+ ---
792
+
793
+ ## 4. The 3D engine
794
+
795
+ The 3D viewer turns a set of transactions, or any caller-supplied tiles, into
796
+ square tiles on a grid. It animates them between layouts without collisions and
797
+ draws them with hand-written canvas polygons. It is split into files by
798
+ responsibility:
799
+
800
+ | File | Responsibility | Canvas? |
801
+ |---|---|---|
802
+ | `blockpack.js` | the square packer (`packBlock`, `BlockLayout`, `packStable`, `sideFor`, `ditheredSide`, `vsizeForSide`), an original implementation | no, pure |
803
+ | `feepalette.js` | the 128 feerate bands (a geometric series from 0.1 to 2,000 sat/vB; sky blue to purple, neighbours stepped in tone) and their colour ramp (`feeColor`, `feeShade`) | no, pure |
804
+ | `blockscene3d.js` | projection, the sphere, transition planning, sampling, scene building (faces, paint order, shadows, idle-effect lighting) | no, pure |
805
+ | `details3d.js` | the renderer: canvas sizing, the fit, ground and grid, axes, stars, the rAF loop, idle-effect scheduling, hover, public entry points | yes |
806
+ | `agents.js` | the `AGENTS` registry: the idle effects that are something moving rather than a pattern (`{ build, frame, draw }` per kind; see `docs/EFFECTS-AGENTS.md`) | draw only |
807
+
808
+ Keeping geometry and choreography pure is what makes the collision-free and
809
+ constant-view invariants testable without a browser.
810
+
811
+ ### 4.1 Pipeline
812
+
813
+ ```mermaid
814
+ flowchart TD
815
+ IN["cells (mempool3d / block3d / render3d)<br/>or laid tiles (board3d)"] --> TX["toTxs: split the aggregate tail into<br/>equal pieces that pack as whole squares"]
816
+ TX --> PK["packBlock: square tiles, first fit, rows upward<br/>(rescaled in 5% steps until the block fits the grid)"]
817
+ PK --> SIG{"layout signature changed?"}
818
+ SIG -->|no| KEEP["leave the running animation alone"]
819
+ SIG -->|"yes, and a transition is in flight"| PARK["park as pending; plan it when this one lands"]
820
+ SIG -->|"yes"| PLAN["planTransition(prev, next)<br/>hold / move / enter / exit, lanes, landings"]
821
+ PLAN --> LOOP["rAF loop"]
822
+ LOOP --> FA["frameAt(plan, t, view)<br/>sampleTween per tile"]
823
+ FA --> BS["buildScene(tiles, view)<br/>faces, paint order, shadows, fxAt lighting"]
824
+ BS --> PF["paintFrame<br/>background, stars, constant transform (obliqueFit),<br/>ground, shadows, glow layer, axes, cubes, price line, cycles, ball"]
825
+ PF --> Q{"settled and no effect running?"}
826
+ Q -->|no| LOOP
827
+ Q -->|yes| IDLE["park the loop; schedule the next idle effect"]
828
+ ```
829
+
830
+ **Packing (`packBlock`, `public/js/blockpack.js`)** is an original square packer:
831
+
832
+ - `vbytesPerUnit` is chosen so a full block needs slightly less than the grid's area. A
833
+ transaction's side is the rounded square root of its area in grid units, with a small
834
+ allowance so the many transactions just over one unit keep their share, at least 1 and
835
+ at most the grid width. `vsizeForSide` is its exact inverse, which the renderer uses to
836
+ cut the aggregate tail into whole squares.
837
+ - **Detailed** uses area-true sides instead (`ditheredSide`, `dither: true`): a transaction
838
+ of u units is drawn at floor(√u) or one more, the larger with the probability that makes
839
+ its expected area exactly u, the coin a hash of its txid so its side never changes between
840
+ refreshes. Nearest-side rounding drew a live block of mostly 140 vB transactions (1.27
841
+ units each, drawn as one) at 82% of its true area, so a full block stopped short of the
842
+ top of the board.
843
+ - Squares are placed first-fit into rows scanning upward, in the order given
844
+ (richest first). Row 0 is the expensive end, and it is drawn at the bottom.
845
+ - Resting squares never overlap. The collision-free proof depends on this.
846
+ - The renderer draws **one block's worth** (`takeOneBlock`), so the grid comes out
847
+ full.
848
+
849
+ `packStable()` (keep survivors in place) exists and is tested, but the renderer
850
+ packs fresh every time, because stable packing left the resting board ragged.
851
+
852
+ **Aggregate tail.** The backend sends small transactions as one aggregate cell.
853
+ The renderer splits it into equal pieces whose sides are whole grid units. The mempool's
854
+ aggregate also carries `strata` (the tail grouped by feerate, richest first), and each piece
855
+ takes the feerate of the stratum it falls in, so the tail shows its own spread of colours.
856
+ Pieces are renamed `aggregate@x,y` by slot, so an unchanged slot keeps its
857
+ identity and does not animate.
858
+
859
+ ### 4.2 `planTransition` — rise, travel, drop
860
+
861
+ Each tile in the next layout is compared with the previous layout by `txid`:
862
+
863
+ - **hold**: same position and size, so it does not move.
864
+ - **move**: same id, different position or size.
865
+ - **enter**: a new id.
866
+ - **exit**: an id that has gone.
867
+
868
+ ```mermaid
869
+ flowchart LR
870
+ subgraph "phase 1: RISE"
871
+ R1["movers lift straight up out of their own slots<br/>(each starts within riseStagger)"]
872
+ R2["exits fly up and off screen,<br/>each with its own start and acceleration"]
873
+ end
874
+ subgraph "phase 2: TRAVEL"
875
+ T1["each mover shifts at its OWN lane altitude,<br/>in two legs (x then y, or y then x)"]
876
+ end
877
+ subgraph "phase 3: DROP"
878
+ D1["movers fall straight into their new slots<br/>under gravity and bounce to rest"]
879
+ D2["arrivals fall in from off screen,<br/>one gravity arc straight into their bounce"]
880
+ end
881
+ R1 --> T1 --> D1
882
+ R2 -.-> T1
883
+ T1 -.-> D2
884
+ ```
885
+
886
+ **Why this cannot collide.** It takes three steps:
887
+
888
+ 1. Resting slots never overlap, so no two tiles meet while rising straight up.
889
+ 2. During travel, two movers whose swept footprints overlap are given **disjoint
890
+ altitude intervals**, and every mover travels above the tallest resting cube
891
+ under its sweep. Tiles in different lanes are separated vertically; tiles in
892
+ the same lane never occupy the same ground.
893
+ 3. New slots never overlap, so no two tiles meet while dropping straight down.
894
+
895
+ The swept footprint includes the corner of the two-leg route, because the route is
896
+ L-shaped, not diagonal.
897
+
898
+ **Lane assignment.** Movers are sorted biggest first, so big cubes get the low
899
+ lanes. Each mover's base altitude starts at
900
+ `max(laneGap, liftMin * (0.55..1.45 by hash))`. It is raised above resting cubes
901
+ under its sweep, then placed first-fit in a gap between overlapping lanes already
902
+ assigned. Exits rise to `exitTo` and arrivals fall from `enterFrom`, both above
903
+ every lane. The off-screen part of a flight exists only in the drawing
904
+ (`entry` → `visualBase` → `offscreenLift`), so the real altitudes the proof depends
905
+ on are unchanged.
906
+
907
+ **Landings** use gravity and mass:
908
+
909
+ - `GRAVITY` is set so a fall of 12 units takes 0.6 s. A fall from `h` takes
910
+ `sqrt(2h/g)`, so higher drops take longer and no two tiles finish together.
911
+ Falls are timed as if from at most 24 units.
912
+ - Each tile's coefficient of restitution comes from its size (`restitutionOf`:
913
+ heavier means a deader bounce) plus a small per-tile hash.
914
+ - `bounceDrop` is a `1 - u^2` fall followed by sine-arc hops, each `e^2` as high
915
+ and `e` as long as the one before, until a hop would be under 1% of the drop.
916
+ - A tile flashes briefly (`lock`) when it lands.
917
+ - The plan's `settleAt` is the last landing plus `lockMs`.
918
+
919
+ **Timing defaults** (`TRANSITION`, all overridable through `options.transition`):
920
+
921
+ | key | default | meaning |
922
+ |---|---|---|
923
+ | `rise` / `travel` / `drop` | 3000 / 11000 / 6000 ms | phase lengths |
924
+ | `riseStagger` | 1300 ms | lift-off spread, kept inside the rise phase |
925
+ | `dropStagger` | 3500 ms | drop start spread (safe: each tile drops into its own slot) |
926
+ | `entryMs` | 1300 ms | how long before its drop an arrival starts falling in from off screen |
927
+ | `lockMs` | 260 ms | the landing flash |
928
+ | `laneGap` | 1.35 | minimum vertical gap unit between lanes |
929
+ | `liftMin` | 24 | base lift height |
930
+ | `heightCap`, `maxGrowth` | 0.22, 0.15 | bound the overhead-camera constant `risePerUnit` |
931
+
932
+ `frameAt(plan, now, view)` samples every tween (`sampleTween`) and passes the live
933
+ tiles to `buildScene`. It returns `{ ops, bounds, settled, tiles }`.
934
+
935
+ ### 4.3 Projection and the camera
936
+
937
+ `project(gx, gy, gz, view)` maps grid coordinates to board-space pixels:
938
+
939
+ - **Grid.** Square and viewed straight on, with no isometric diamond and no
940
+ rotation. `flipY` puts row 0 at the bottom.
941
+ - **Sphere.** With `dome > 0`, the board is a patch of a real sphere: its centre
942
+ is raised by `dome`, its corners lie on the plane, and the same sphere continues
943
+ past the board. `capZ` adds the sphere's height at every point, `surfaceNormal`
944
+ gives the direction tiles fly along, and `domeLight` shades each tile by the
945
+ slope under it.
946
+ - **Oblique camera** (the default). Height is a fixed screen offset:
947
+ `x += z * ox * unit` and `y -= z * oy * unit`. `oblique.dy` shortens the board's
948
+ depth for a lower camera. Every cube shows its top and its west and south faces,
949
+ and nothing changes size with height. Flight altitude is compressed into the room
950
+ available at each tile's position (`flightRoom`, capped by `oblique.flight`), so
951
+ nothing in flight leaves the frame except arrivals and departures, which are
952
+ meant to.
953
+ - **Overhead pinhole.** Setting `oblique: null` selects a pinhole camera about a
954
+ vanishing point. Lifted tiles swell (`liftBoost`), and `settleGrowth` stops
955
+ airborne tiles from growing into each other.
956
+
957
+ ### 4.4 `buildScene` — faces, order, shadows, lighting
958
+
959
+ For each tile, `tileFaces` produces the top quad and the side faces that face the
960
+ camera. A side is emitted when the top edge has moved against the side's outward
961
+ normal; one rule covers both cameras. Tiles large enough get a Tetris-cell finish:
962
+
963
+ - **bevel**: lit on the far and left edges.
964
+ - **face**: the tile's fee colour.
965
+ - **well**: a hollow with walls lit the opposite way and a darker floor.
966
+ - **rim**: a thin light line around the well.
967
+
968
+ Detail thresholds (`facetMinUnits`, `crownMinUnits`) come from the constant board
969
+ transform, so a tile's level of detail cannot change mid-flight.
970
+
971
+ **Paint order.** No depth buffer is used; depth comes from paint order alone.
972
+
973
+ - **`obliqueOrder`** (oblique camera, the default). For each pair of cubes whose
974
+ on-screen hulls actually overlap (by more than half a unit, tested with
975
+ separating axes), it finds the axis that separates them in world space. It tries
976
+ the footprint row first, then the column, then height, and paints the nearer
977
+ cube later. The pairwise facts are sorted topologically. Groups of cubes that
978
+ form a cycle (strongly connected components) are ordered as a group, and cubes
979
+ within a group are ordered by a smooth depth measure, so a cycle cannot be cut
980
+ differently from one frame to the next. Ties fall back to a diagonal order by
981
+ footprint only. The cost is quadratic in overlapping pairs.
982
+ - **`diagonalOrder`** (`order: 'diagonal'`, used by the Detailed mode). Resting tiles paint
983
+ from the far corner inward, then airborne tiles, lowest first. It is `O(n log n)`
984
+ and valid when tiles are low slabs whose footprints never overlap, which is the
985
+ dense board with thousands of tiles.
986
+ - **Overhead pinhole.** Tiles are sorted by the height of their tops.
987
+
988
+ **Shadows.** These are plain `rgba(0,0,0,a)` polygons:
989
+
990
+ - **Flight shadows** (`shadowOps`) fall at the tile's true footprint, spreading
991
+ and paling as it climbs. They fade in over the first 1.5 units of a lift and out
992
+ over the last 1.5 units of a landing, and they leave with a tile that flies off
993
+ screen.
994
+ - **Resting shadows** (`restingShadowOps`) are short, pointing down and right away
995
+ from an upper-left light, and longer for taller cubes.
996
+ - **Cast shadows** from a cube in flight onto the tops of cubes beneath it.
997
+ - **Order.** Under the oblique camera, all shadows paint before any cube, because
998
+ they lie on the floor.
999
+
1000
+ **Idle effects** (`fxAt`). While the board is at rest, one effect plays at a time.
1001
+ They light **resting** tiles only (`z <= 0.02`), returning
1002
+ `{ glow, outline, lift, color }` (plus `hide` and `scale`, which the agents use).
1003
+ There are thirty kinds (`FX_KINDS` in `details3d.js`, one switch each in
1004
+ `settings.js`): twenty-three **fields**, pure functions of a tile's position and
1005
+ the effect's clock, and seven **agents** (`lightcycle`, `ball`, `centipede`,
1006
+ `tractor`, `missile`, `boulderdash`, `stormball`), which have a position and a
1007
+ route and light the cubes they pass through `fx.heads`. The full catalogue,
1008
+ with what was removed, is `docs/EFFECTS-AGENTS.md`. The original eight:
1009
+
1010
+ | effect | what it does |
1011
+ |---|---|
1012
+ | `ripple` | a ring spreading from a point |
1013
+ | `outline` | an energy front tracing cube outlines, with a trail |
1014
+ | `tide` | a wave that lifts cubes as it passes |
1015
+ | `cascade` | a flash from the richest transaction to the cheapest |
1016
+ | `twinkle` | scattered glints |
1017
+ | `scan` | a sweeping line |
1018
+ | `lightcycle` | two trails walking the grid lines edge to edge, riding cube tops (`cyclePath`, `cellTops`, `pathHeights`) |
1019
+ | `ball` | a lightning ball entering from off screen along a grid line (`ballPath`) |
1020
+
1021
+ `light: 'viewer'` switches to lighting from the camera: faces toward the viewer
1022
+ are brightest, edges are drawn light, bevels and all shadows are omitted, and
1023
+ brightness falls off toward the left of the board.
1024
+
1025
+ ### 4.5 `paintFrame` — the draw order
1026
+
1027
+ ```mermaid
1028
+ flowchart TD
1029
+ A["setTransform identity; fill background"] --> B{"opts.space?"}
1030
+ B -->|yes| B1["star field (seeded per canvas size, each star twinkling on its own period)"]
1031
+ B -->|no| C
1032
+ B1 --> C["setTransform(the CONSTANT fit: obliqueFit, or corner-to-corner for the pinhole)"]
1033
+ C --> D["drawGrid, ground part:<br/>space: translucent black board<br/>otherwise: lit plate deck (cached Path2D) over the whole panel, faint phosphor lines"]
1034
+ D --> E["ops with face = shadow"]
1035
+ E --> F["glow layer returned by drawGrid: neon grid inside the board, edge glow,<br/>board edge traced on the sphere, idle-effect floor marks, one-block line<br/>(floorLine: just one neon line along the front edge)"]
1036
+ F --> G["axes: price levels and hour ticks (if opts.axes)"]
1037
+ G --> H["cube faces in paint order: fill, then stroke if edges"]
1038
+ H --> I["price line (axes.line), axis labels"]
1039
+ I --> J["the agent effects (light cycles, lightning ball, ...) over the cubes"]
1040
+ ```
1041
+
1042
+ `obliqueFit(pw, ph, gridW, gridH, opts)` returns `{ k, tx, ty, rect }`. It centres
1043
+ the board with the same margin (`headroom + dome`) on opposite sides. With
1044
+ `oblique.anchor === 'bottom'`, it places the board along the bottom edge, with a
1045
+ strip below for hour labels and room on the right for price labels. `rect` is the
1046
+ panel's extent in grid units. The textured ground covers all of it, and arrivals
1047
+ start entirely outside it.
1048
+
1049
+ ### 4.6 Render options
1050
+
1051
+ Callers use `render3d(canvas, cells, options)` or one of its wrappers:
1052
+
1053
+ - `mempool3d(canvas, dist, options)`: one block's worth of pool cells.
1054
+ - `block3d(canvas, visual, economy, options)`: a block. `blockVbytes` is the
1055
+ weight limit divided by 4.
1056
+ - `board3d(canvas, tiles, options)`: caller-laid tiles.
1057
+
1058
+ Options are merged over `DEFAULTS` in `details3d.js`.
1059
+
1060
+ | Option | Default | Meaning |
1061
+ |---|---|---|
1062
+ | `resolution` | 44 | grid units across (and up) for packed cells |
1063
+ | `blockVbytes` | 1,000,000 | vbytes one full grid represents; sets `vbytesPerUnit` |
1064
+ | `oblique` | `{ ox: 0.13, oy: 0.32, headroom: 10, flight: 120 }` | oblique camera. `ox`/`oy` are the screen offset per unit of height; `dy` (default 1) is the board depth scale for a lower camera; `headroom` is the reserved margin; `flight` is the maximum flight altitude; `anchor: 'bottom'` pins the board to the bottom edge. `null` selects the overhead pinhole. |
1065
+ | `dome` | 5 | sphere rise at the board centre, in grid units (0 is flat) |
1066
+ | `space` | off | star field and a translucent black board instead of the textured deck; the loop keeps running (about 30 fps) for the twinkle while the canvas is visible |
1067
+ | `floorLine` | off | with `space`: no board grid, just one neon line along the front edge |
1068
+ | `light` | `'upper-left'` | where the lamp hangs, for both the dome's slope shading and the side faces: `'overhead'` (straight above: no slope in shade, every side alike), `'upper-left'`, `'upper-right'`, `'front'` — see `LIGHTS` in `blockscene3d.js`. `'viewer'` is the separate camera-lit mode: light edges, no bevel, no shadows. `settings.js` sets this from `space.light`, and Block space ships `'overhead'`. |
1069
+ | `neon` | off | each block a dim solid body in its own colour under lit tubes on every visible edge — a halo, a tube in the block's hue and a thin near-white core. Flattens the tile (no facets, no crown): the tubes are the detail. |
1070
+ | `neonSource`, `neonColour`, `neonBrightness` | `'temperature'`, `'#3d8bff'`, 1 | the tubes take the block's own colour or one chosen hex; brightness (0.2–2) scales both their alpha and their width |
1071
+ | `sheen` | off | a specular highlight on the lit edge of each top face and a dark roll-off on the far one, plus a highlight up the lit side. Works at every level of detail. |
1072
+ | `stars`, `galaxy`, `galaxyAt` | off, off, `'bottom-left'` | the star field, whether it is laid on turning spiral arms, and where the nucleus sits (`'center'` or a corner). `starDensity`, `starBrightness`, `nebulae`, `galaxies`, `dust`, `clusters`, `starColours`, `starGlints` tune it. `stars` defaults to `space` when unset. |
1073
+ | `fxKinds` | all | which idle effects may play, as a list of `FX_KINDS`. An empty list schedules none. `settings.js` builds it from the per-effect switches (`enabledEffects`): the `effects` group for the block board (`SPACE_FX`, every effect but the two drawn on a price line) and the `marketEffects` group for the price board (`MARKET_FX`, the twelve that translate to a candle chart); each group's `noRepeat` (default 12) keeps an effect from playing again until that many others have. |
1074
+ | `still` | off | draw the tiles where they are, with no choreography at all — not even the planner's per-tile stagger. It governs the **tiles**; a board with a sky keeps its loop regardless (see 4.7). |
1075
+ | `maxDpr` | none | cap the device-pixel ratio for this canvas. The star count follows the pixel count, so a panel-sized galaxy at 1x is a quarter of the work of one at 2x. |
1076
+ | `orderMemo` | none | a `Map` the caller keeps per canvas; `obliqueOrder` uses it to hold a tangle's relative order steady between frames (see 4.7). `render3d` supplies its own. |
1077
+ | `slab` | off | cap every tile's height at this value (low slabs for dense boards) |
1078
+ | `order` | (oblique order) | `'diagonal'`: the fast diagonal paint order for non-overlapping slabs |
1079
+ | `gridW`, `gridH` | `resolution` | board size in grid units; used with laid tiles (`board3d`) |
1080
+ | `laid` | none | set by `board3d`: the tiles to draw, bypassing packing |
1081
+ | `axes` | none | `{ y, zTop, z: [{ z, label, color?, strong? }], x: [{ x, label }], line: [{ x, z }] }`: `y` is the row the axes stand on; `z` are price levels drawn across the board; `x` are hour ticks; `line` is a glowing polyline (the close price) |
1082
+ | `transition` | `TRANSITION` | per-caller timing overrides (see 4.2) |
1083
+ | `grid`, `gridStep` | `true`, 4 | draw the ground and grid; plate size in cells |
1084
+ | `edges`, `seamAlpha` | `true`, 0.38 | stroke face outlines; the seam's strength |
1085
+ | `idleFx`, `idleEvery`, `idleFirst` | `true`, `[5000, 9000]`, `[800, 1600]` | idle effects and the delay ranges before the next and the first one |
1086
+ | `facetPx`, `crownPx` | 9, 18 | device-pixel thresholds for tile detail |
1087
+ | `unit`, `zUnit`, `persp`, `vanish`, `edgeMargin` | 6, 6, 0.55, centre, 0.06 | base scale and pinhole-camera parameters |
1088
+ | colours | see `DEFAULTS` | `background`, `floor`, `spaceFloor`, `gridGlow`, `gridColor`, `gridEdgeColor`, `neonCell`, `neonHalo`, `neonGlow`, `neonLine`, `blockLineColor` |
1089
+
1090
+ **Tile fields.** For `board3d`, and in the tiles `packBlock` produces:
1091
+
1092
+ | Field | Meaning |
1093
+ |---|---|
1094
+ | `txid` | stable identity. The same id in the next layout is the same tile and moves rather than leaving and re-arriving. |
1095
+ | `x`, `y` | grid position of the footprint's lower-left cell (row 0 at the bottom) |
1096
+ | `s` | footprint side in grid units |
1097
+ | `tall` | height; defaults to `s` (a cube) |
1098
+ | `floor` | resting altitude; the tile floats this high (a market candle at its price) |
1099
+ | `color` | `#rrggbb`. Every face colour is derived from it, so it must be a six-digit hex string. |
1100
+ | `label` | hover text; if absent, hover shows txid, vsize and feerate |
1101
+ | `rate`, `vsize` | set by the packer; used by the built-in hover text and the `cascade` effect |
1102
+
1103
+ The engine adds these while animating; callers should not set them: `z`, `alpha`,
1104
+ `lock`, `entry`, `landV`, `fallFrom`, `fxz`, `boost`.
1105
+
1106
+ Other exports:
1107
+
1108
+ - `viewerIdle(canvas)`: true when the board is drawn, settled, and has no pending
1109
+ layout.
1110
+ - `triggerIdle(canvas, kind)`: plays an idle effect now; used by tests and demos.
1111
+ - `hitTest(canvas, clientX, clientY)` and `hitOps(ops, x, y)`: the topmost drawn
1112
+ face at a point. Only answers while the board is settled.
1113
+
1114
+ ### 4.7 Invariants
1115
+
1116
+ Each of these has a test that fails if it is violated.
1117
+
1118
+ - **The board transform and the camera are constants.** The fit depends only on
1119
+ the panel size and the fixed grid (`gridW`, `gridH`, `resolution`), never on the
1120
+ packed extent or what is in flight. `risePerUnit` comes from the grid and the
1121
+ height cap, never from the current lane stack. When either varied, the board
1122
+ slid down on refresh, or the first paint drew cube lips several times too tall.
1123
+ To add something to the fit, first confirm it is a property of the board and
1124
+ not of the current transition (RULES 26). Tests: "the view transform is a
1125
+ constant", "the camera is a constant of the board, not of the round", "the grid
1126
+ is centred on the panel ... the view never slides".
1127
+ - **Plain rgba only.** No `ctx.clip()`, no `ctx.globalAlpha`, no
1128
+ `globalCompositeOperation`, no `shadowBlur`. Software rasterisers (headless
1129
+ browsers, VMs, blocklisted GPUs) silently drop fills drawn through a clip and
1130
+ handle alpha and blur inconsistently; this blanked the map once. Transparency
1131
+ goes inside `rgba()`, glows are layered strokes, and depth is paint order.
1132
+ `test/viewer-canvas-rules.test.js` scans the source; `test/details3d.test.js`
1133
+ and `test/never-clip.test.js` record what actually reaches a context.
1134
+ - **No tile is ever see-through.** Arrivals and departures are solid and enter or
1135
+ leave the frame whole. A translucent cube over solid ones reads as a ghost.
1136
+ - **No two tiles intersect** at any sampled moment of a transition. The test
1137
+ samples a whole flight and intersects every pair of boxes.
1138
+ - **Idle effects only at rest.** A transition cancels the current effect. `fxAt`
1139
+ never touches airborne tiles. Effects are scheduled only with a real DOM, never
1140
+ under `prefers-reduced-motion`, and never while the canvas is hidden or moving.
1141
+ There are 30 (`FX_KINDS`), each a pure function of the tile and the effect's
1142
+ clock — board-level choices are hashed from the effect's seed, never from
1143
+ `Math.random`, so an effect replays identically and is asserted rather than
1144
+ watched. An agent never mutates a tile: `hide` and `scale` are applied per
1145
+ frame to a copy, so the board is correct again the moment the effect stops.
1146
+ Every one has a switch in `settings.js`, and a test holds the effect
1147
+ list, the defaults and the panel rows to the same list in the same order.
1148
+ - **The loop parks when — and only when — nothing is moving.** When a frame is
1149
+ settled, nothing is dirty and no effect is running, the rAF loop stops:
1150
+ repainting a still picture only burns power. Boards with a star field keep a
1151
+ throttled loop (about 30 fps) for the twinkle and the galaxy's turn, and stop
1152
+ while the canvas is not displayed. **`still` governs the tiles, never the sky.**
1153
+ It once also returned before `requestAnimationFrame` — and the wake that revives
1154
+ a parked loop was gated behind it too — so Tetrust's sky, which sets `still`
1155
+ because it has no tiles to choreograph, repainted only when its page happened to
1156
+ call `board3d` again: measured at zero repaints in three seconds, and 87 after
1157
+ the fix. Test: `test/still-sky.test.js`.
1158
+ - **A tangle keeps the order it had last frame.** Where drawn outlines overlap,
1159
+ paint order is a constraint graph; contradictory cycles are resolved as groups
1160
+ (strongly connected components). Ordering a group by depth alone threw away the
1161
+ pair decisions inside it, so a cube flying past could pull a settled pair into a
1162
+ tangle — or let it out — and the pair swapped without either of them moving.
1163
+ With `orderMemo` the members of a tangle keep their previous relative order, and
1164
+ only cubes with no previous frame fall in by depth. Replayed over a 634-frame
1165
+ transition of 90 cubes: 19 order flickers to none. Test:
1166
+ `test/order-memo.test.js`.
1167
+ - **A layout does not restart a transition.** An unchanged layout leaves the
1168
+ running plan alone. A changed layout that arrives mid-flight waits as `pending`.
1169
+ The first paint never animates.
1170
+ - **Per-canvas state.** All renderer state lives in a `WeakMap` keyed by canvas.
1171
+ A module-level animation handle once made two viewers on one page cancel each
1172
+ other.
1173
+ - **Reduced motion** draws the same final layout without the flight.
1174
+
1175
+ ### 4.8 Using the engine for other data
1176
+
1177
+ `board3d` is the general entry point. The Markets page is the reference caller:
1178
+
1179
+ - **Tiles.** It lays out one candle per exchange-hour (`txid` = exchange + hour),
1180
+ with volume tiles on the floor in front and bodies and wicks floating at their
1181
+ price (`floor`).
1182
+ - **Axes.** It passes `axes` for price levels, hour labels and the close line.
1183
+ - **Camera.** `CAMERA_3D` overrides the defaults:
1184
+
1185
+ ```js
1186
+ {
1187
+ oblique: { ox: 0.07, oy: 0.95, dy: 0.3, headroom: /* price band */, flight: 10, anchor: 'bottom' },
1188
+ dome: 0, gridStep: 2, space: true, floorLine: true, light: 'viewer',
1189
+ transition: { rise: 700, travel: 2200, drop: 1400, riseStagger: 300, dropStagger: 600, entryMs: 600 },
1190
+ }
1191
+ ```
1192
+
1193
+ A new hour gives every candle a new `x`, so the whole chart slides one slot using
1194
+ the same choreography as the block board, only faster.
1195
+
1196
+ ---
1197
+
1198
+ ## 5. Testing
1199
+
1200
+ `npm test` runs `node --test "test/**/*.test.js"`. The glob is quoted on purpose:
1201
+ passing the bare `test/` directory does not work on some Node 22 releases. Every
1202
+ test is written with `node:test` and `node:assert`, with nothing to install.
1203
+
1204
+ ### Infrastructure
1205
+
1206
+ - **`test/dom-stub.js`** (`installDom()`): a minimal `document`/`window` with a
1207
+ proxy 2D context, a tiny parser for the `data-*` attributes the app itself
1208
+ writes, a CSSOM stub, and a controllable `requestAnimationFrame`. Importing
1209
+ `public/js/app.js` under it reproduces the browser's module-evaluation check.
1210
+ A missing import name parses cleanly but throws during evaluation, and in the
1211
+ browser that blanks the whole page with nothing in the server log. The stub
1212
+ cannot check layout, the CSS cascade, or real `EventSource` behaviour.
1213
+ - **`test/helpers/http.js`** (`withApp({ nodes, config, auth, tlsFiles, log }, fn)`):
1214
+ boots the **real** app in-process against N fake nodes on free loopback ports.
1215
+ Config goes in a temp file, the bootstrap admin password comes back on
1216
+ `app.bootstrap`, and `app.shutdown()` always runs. It **never touches
1217
+ `process.env`**: Node runs a file's top-level tests concurrently, so environment
1218
+ changes race between tests (RULES 24). Assertions about environment variables
1219
+ live in `test/config-env.test.js`, which boots nothing.
1220
+ - **`scripts/fake-node.js`** (`FakeNode`, `startFakeNode`): a stand-in node that
1221
+ serves enough JSON-RPC (including batches) and writes a log in the node's
1222
+ format. It can simulate IBD. It deliberately reproduces the real node's quirks:
1223
+ `getpeerinfo` returns `[]` with non-zero connections, `getnettotals` is zero, and
1224
+ mempool entries have the observed field set. `npm run dev` runs it in-process.
1225
+ - **`scripts/smoke.sh`** (`npm run smoke`): boots the real server on spare ports
1226
+ with a fake node, then checks the HTTP contract with curl: auth, CSRF, the SSE
1227
+ frame, the RPC guard, headers, the nonce, the build id, the login throttle, and
1228
+ open mode. It refuses to run if a port is already taken (otherwise it could test
1229
+ someone else's server), uses a single cleanup trap for exit and signals, and pins
1230
+ `BLOCKYARD_CONFIG=none` and `BLOCKYARD_BIND=127.0.0.1` so it never inherits a
1231
+ deployment's config.
1232
+ - **`scripts/doc-counts.js`** (`npm run counts`, `counts:check`, `counts:fix`):
1233
+ derives the test count quoted in the docs by scanning top-level `test(...)`
1234
+ declarations. `test/doc-counts.test.js` checks the scanner against the count the
1235
+ real `node --test` reporter prints, and fails on nested declarations the scanner
1236
+ would miss. Do not type the count into a document by hand.
1237
+ - **Fixtures**: `test/fixtures/` holds **real** node log lines, taken from actual
1238
+ logs and not typed from memory (RULES 7) -- all of them from the experimental node,
1239
+ which is why Core log support is not claimed anywhere. A frozen 90-minute sample holds parser
1240
+ coverage above its threshold.
1241
+ - **`npm run render:live`** (`scripts/live-render-check.mjs`): runs the page
1242
+ renderers under the DOM stub against a running monitor's real responses.
1243
+
1244
+ ### What the guard tests protect
1245
+
1246
+ | Test | Guards |
1247
+ |---|---|
1248
+ | `privacy.test.js` | no tracked file contains this machine's username, resolvable hostname or interface addresses (derived at run time, never written down); no overlay-network addresses; RFC 1918 and routable addresses only where explicitly reviewed; the systemd unit uses a placeholder account. Use `192.0.2.x`, `198.51.100.x` or `203.0.113.x` in examples. |
1249
+ | `csp.test.js` | no `'unsafe-inline'` and no `style-src-attr`; one nonce per response, in `script-src` only; HSTS only over TLS; no `style=` in shipped HTML or injected markup; data-driven sizes go through the CSSOM |
1250
+ | `viewer-canvas-rules.test.js`, `never-clip.test.js`, `details3d.test.js` | no `clip`, `globalAlpha`, composite modes or `shadowBlur`, checked both in the source and in what reaches the context |
1251
+ | `never-blank.test.js` | charts never erase data they already show; stale data gets a pill; only `resetCanvas` in `switchNode` clears a canvas |
1252
+ | `web-contract.test.js`, `app-boot.test.js`, `browser-render.test.js` | every element id the JS looks up exists; every nav page has a section and a renderer; `app.js` evaluates; every page renders full, mid-IBD and all-null snapshots without throwing, and says something when it has nothing |
1253
+ | `blockscene3d.test.js`, `details3d.test.js`, `viewer-modes.test.js` | no intersections during a transition, constant transform and camera, paint-order stability, shadows, landings, idle behaviour, loop parking, hover only at rest, both viewer modes |
1254
+ | `rpc-lane.test.js` | priority, coalescing, stale drop, breaker semantics, the unkeyed-job recursion bug, cadence stretching and its floor |
1255
+ | `open-access.test.js`, `http-app.test.js`, `tls.test.js`, `cidr.test.js`, `audit-and-kdf.test.js` | the viewer ceiling, admin 403 in open mode, refused writes, sessions and CSRF, TLS, CIDR gate failure direction, audit rotation, scrypt |
1256
+ | `logparse.test.js`, `bench-log.test.js`, `shape-liveness.test.js`, `log-core-unsupported.test.js` | parsers against real experimental-node lines, coverage thresholds, per-shape liveness flags, and the standing proof that Core's format is NOT parsed |
1257
+ | `chain-blockfile.test.js`, `chain-tx.test.js`, `chain-index.test.js`, `chain-index-live.test.js` | block/undo file framing and XOR, the decoder against Core's verbose output (`test/fixtures/chain-tx.json`), lean rows against full-decoder rows, the store's lookups, and the follower's log replay, rollback and folding |
1258
+ | `agents.test.js`, `effects.test.js` | every registered agent builds, frames, draws, publishes heads, replays from a seed, leaves tiles untouched and keeps working on a flat board; every effect lights something, every kind is reachable and none takes more than twice an even share |
1259
+
1260
+ A green `npm test` is necessary but not sufficient (RULES 5). Before calling a
1261
+ change done, run `npm run dev` and look at the page, or run `npm run smoke`.
1262
+
1263
+ ---
1264
+
1265
+ ## 6. Conventions for contributors
1266
+
1267
+ - **Zero dependencies.** Use Node builtins, `node:test`, hand-written canvas, and
1268
+ no CDN. `package.json` has empty `dependencies` and `devDependencies` and should
1269
+ stay that way. If a dependency looks necessary, build less UI instead (RULES 2).
1270
+ - **Read-only by default.** Every new RPC call goes through the lane and must be
1271
+ classified as a read by the allowlist. Node writes exist only as named, audited,
1272
+ opt-in actions. Do not add a per-user or concurrent poll, or anything that
1273
+ bypasses `server/rpc/client.js`. Prefer adding a method to an existing tier's
1274
+ batch over adding a tier. Anything expensive for the node should be fetched on
1275
+ demand and shared between viewers.
1276
+ - **Honest data.**
1277
+ - Absent is shown as absent. A figure the node did not provide is `null` in the
1278
+ payload and `–` on screen, with a reason in `health.quality` or next to the
1279
+ figure. Never substitute a zero, and check whether a number is physically
1280
+ plausible, not just whether it is zero (RULES 8).
1281
+ - Two similar figures stay two figures; do not merge or `max()` them (RULES 9).
1282
+ - Stale must look stale: keep old data on screen, and mark it with its age.
1283
+ - Log-derived figures report their own coverage (RULES 15).
1284
+ - **Measure before tuning.** Intervals, timeouts, caps and payload budgets come
1285
+ from measurements recorded in `docs/MEASUREMENTS.md` with a way to reproduce
1286
+ them. A comment that justifies a number states the measurement, not an intuition.
1287
+ - **Keep payloads in their place.** Nothing that changes more slowly than once a
1288
+ second goes in the SSE snapshot (RULES 6). Chart series use the series event or
1289
+ `/api/series`; bulky datasets get their own endpoint and schedule.
1290
+ - **Keep pure code pure.** Parsers, the sync model, mining decoders, next-block
1291
+ analysis, packing, projection and choreography have no I/O and no canvas, so
1292
+ they can be tested headless. Put I/O and drawing at the edges.
1293
+ - **Tests observe through seams**: `boot({ log })`,
1294
+ `loadConfig({ ifaces, now })`, injected `fetchImpl` and `now` in `MarketFeed`.
1295
+ They never replace process globals and never mutate `process.env` in a file that
1296
+ boots the app.
1297
+ - **Respect the CSP**: no inline styles or scripts; use `data-*` plus CSSOM, or
1298
+ classes. Escape every external string.
1299
+ - **The 3D rules**: no clip, alpha, composite or blur; per-canvas state; anything
1300
+ the view transform depends on must be a constant of the board; idle effects only
1301
+ at rest; the loop parks when still.
1302
+ - **Privacy**: the repository is public. Use generic paths (`<datadir>`,
1303
+ `/path/to/node`) and documentation IP ranges in docs, fixtures and examples.
1304
+ `test/privacy.test.js` enforces this.
1305
+ - **Documentation**: a new rule goes in `docs/RULES.md` together with the defect
1306
+ that caused it. Known gaps go in `docs/DEFECTS.md`, including why each is still
1307
+ open. Test counts are generated (`npm run counts:fix`), never typed.