blockyard 0.0.1 → 0.1.0

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