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.
- package/CHANGELOG.md +929 -0
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +191 -4
- package/SECURITY.md +38 -0
- package/bin/blockyard.js +41 -0
- package/config/pool-map.json +2620 -0
- package/docs/API.md +1577 -0
- package/docs/ARCHITECTURE.md +1394 -0
- package/docs/AUTO-UPDATE.md +269 -0
- package/docs/CONFIGURATION.md +847 -0
- package/docs/DEFECTS.md +813 -0
- package/docs/EFFECTS-AGENTS.md +448 -0
- package/docs/GETTING-STARTED.md +205 -0
- package/docs/INSTALL.md +547 -0
- package/docs/MEASUREMENTS.md +1401 -0
- package/docs/RULES.md +681 -0
- package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
- package/docs/SECURITY-AUDIT.md +258 -0
- package/docs/SECURITY.md +212 -0
- package/docs/TROUBLESHOOTING.md +332 -0
- package/docs/USER-GUIDE.md +1262 -0
- package/package.json +53 -5
- package/public/404.html +9 -0
- package/public/css/app.css +2009 -0
- package/public/donate-qr.png +0 -0
- package/public/index.html +1085 -0
- package/public/js/about.js +112 -0
- package/public/js/agents.js +1141 -0
- package/public/js/app.js +1386 -0
- package/public/js/arkanoid.js +806 -0
- package/public/js/blockanoid.js +347 -0
- package/public/js/blockout.js +347 -0
- package/public/js/blockpack.js +428 -0
- package/public/js/blockscene3d.js +2830 -0
- package/public/js/breakout.js +224 -0
- package/public/js/charts.js +635 -0
- package/public/js/depthchart.js +315 -0
- package/public/js/details3d.js +4342 -0
- package/public/js/doom.js +31 -0
- package/public/js/dosaudio.js +48 -0
- package/public/js/dosgame.js +389 -0
- package/public/js/dosio.js +186 -0
- package/public/js/dospc.js +1353 -0
- package/public/js/dosworker.js +196 -0
- package/public/js/explorer.js +405 -0
- package/public/js/feepalette.js +149 -0
- package/public/js/fmt.js +162 -0
- package/public/js/goggles.js +886 -0
- package/public/js/kiosk.js +41 -0
- package/public/js/login.js +88 -0
- package/public/js/markets.js +395 -0
- package/public/js/mining.js +1416 -0
- package/public/js/panels.js +970 -0
- package/public/js/pricechart.js +189 -0
- package/public/js/quake.js +20 -0
- package/public/js/settings.js +1096 -0
- package/public/js/soundcard.js +459 -0
- package/public/js/tetris.js +226 -0
- package/public/js/tetrust.js +356 -0
- package/public/js/tetsound.js +175 -0
- package/public/js/theme.js +235 -0
- package/public/js/wolf3d.js +22 -0
- package/public/js/x86.js +1978 -0
- package/public/login.html +33 -0
- package/scripts/blockfile-measure.js +156 -0
- package/scripts/browser-check.mjs +286 -0
- package/scripts/check.js +173 -0
- package/scripts/decode-check.js +81 -0
- package/scripts/doc-counts.js +109 -0
- package/scripts/donate-qr.py +23 -0
- package/scripts/dos-bench.js +56 -0
- package/scripts/fake-node.js +534 -0
- package/scripts/index-bench.js +216 -0
- package/scripts/index-benchmark.js +117 -0
- package/scripts/index-build.js +40 -0
- package/scripts/live-render-check.mjs +89 -0
- package/scripts/manage-users.js +132 -0
- package/scripts/motion-check.mjs +138 -0
- package/scripts/pool-map.js +157 -0
- package/scripts/setup.js +432 -0
- package/scripts/shots.mjs +278 -0
- package/scripts/smoke.sh +327 -0
- package/scripts/tls.js +31 -0
- package/scripts/ui.js +174 -0
- package/server/auth/sessions.js +221 -0
- package/server/auth/users.js +243 -0
- package/server/chain/blockfile.js +234 -0
- package/server/chain/index/build.js +210 -0
- package/server/chain/index/heights.js +36 -0
- package/server/chain/index/live.js +276 -0
- package/server/chain/index/rows.js +145 -0
- package/server/chain/index/store.js +154 -0
- package/server/chain/index/worker.js +109 -0
- package/server/chain/tx.js +310 -0
- package/server/collect/gbt.js +229 -0
- package/server/collect/logparse.js +765 -0
- package/server/collect/logtail.js +189 -0
- package/server/collect/markets.js +333 -0
- package/server/collect/mining.js +333 -0
- package/server/collect/monitor.js +2545 -0
- package/server/collect/network.js +295 -0
- package/server/collect/nextblock.js +275 -0
- package/server/collect/sync.js +386 -0
- package/server/config.js +644 -0
- package/server/http/api.js +1319 -0
- package/server/http/explorer.js +418 -0
- package/server/http/games.js +77 -0
- package/server/http/server.js +420 -0
- package/server/http/sse.js +176 -0
- package/server/http/static.js +212 -0
- package/server/main.js +673 -0
- package/server/netinfo.js +253 -0
- package/server/rpc/allowlist.js +130 -0
- package/server/rpc/client.js +414 -0
- package/server/store/audit.js +148 -0
- package/server/store/history.js +220 -0
- package/server/store/ledger.js +290 -0
- package/server/store/ring.js +173 -0
- package/server/tls/selfsigned.js +160 -0
- package/server/util/fmt.js +29 -0
- package/systemd/blockyard.service +102 -0
|
@@ -0,0 +1,1401 @@
|
|
|
1
|
+
# Measurements of the node, from this box
|
|
2
|
+
|
|
3
|
+
Every number here was taken with `curl` against a running node on 2026-09-08.
|
|
4
|
+
Reproduce with the commands given. **If you are about to add a poll, a panel or a
|
|
5
|
+
timeout, check here first** — each of these changed a design decision, and
|
|
6
|
+
re-deriving them costs real time.
|
|
7
|
+
|
|
8
|
+
Two nodes were live during this work:
|
|
9
|
+
|
|
10
|
+
| name | RPC | datadir | chain | log |
|
|
11
|
+
|---|---|---|---|---|
|
|
12
|
+
| production | `127.0.0.1:8331` | `<datadir>` | main | `logs/main/bitcoin.main.log` (systemd stdout, logrotated) |
|
|
13
|
+
| bench | `127.0.0.1:8461` | `/mnt/2tbssd/bench/data` | main, **doing IBD** | `console.log` in the run dir, **not** `data/main/debug.log` (see §11) |
|
|
14
|
+
|
|
15
|
+
Auth is cookie: `<datadir>/<chain>/.cookie`, regenerated every start, deleted on
|
|
16
|
+
stop. Resolve fresh, re-resolve on 401; a cached cookie is a restart away from
|
|
17
|
+
being wrong.
|
|
18
|
+
|
|
19
|
+
## 1. The RPC server is single-connection, single-thread
|
|
20
|
+
|
|
21
|
+
Confirmed by the node's own `docs/RPC_LIVE_NODE.md` (slice 11), which also
|
|
22
|
+
explains why its `waitforblock` refuses to wait indefinitely and why
|
|
23
|
+
`rescanblockchain` blocks every other RPC while it runs.
|
|
24
|
+
|
|
25
|
+
Latency of `getblockcount` on the **bench node during IBD**: **40.4 s**.
|
|
26
|
+
Same method on the **synced production node** under benchmark load: **7.0 s**.
|
|
27
|
+
Same method on the synced node when idle: **~0 s** (batched with two others: 3 ms).
|
|
28
|
+
|
|
29
|
+
Consequences already implemented:
|
|
30
|
+
|
|
31
|
+
- `rpc.timeoutMs` is 90 s, not 20 s. A 20 s ceiling declares a busy-but-healthy
|
|
32
|
+
node unreachable.
|
|
33
|
+
- Poll tiers **stretch** to at least 2× observed RPC latency
|
|
34
|
+
(`monitor.effectiveTierMs`), capped, and recover automatically when the node
|
|
35
|
+
speeds up.
|
|
36
|
+
- Poll jobs **coalesce by key** and are **dropped rather than queued** if they
|
|
37
|
+
cannot be answered inside `rpc.staleDropMs` (12 s). A queued poll answers a
|
|
38
|
+
question about a moment that has already passed.
|
|
39
|
+
|
|
40
|
+
## 2. Batching works, and it is the cheapest thing available
|
|
41
|
+
|
|
42
|
+
A top-level JSON array is answered on **one** connection (`docs/RPC_LIVE_NODE.md`
|
|
43
|
+
slice 11: "Batches never throw HTTP errors"). Verified:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
A=$(cat <datadir>/main/.cookie)
|
|
47
|
+
curl -s --user "$A" http://127.0.0.1:8331/ -d '[
|
|
48
|
+
{"jsonrpc":"1.0","id":"a","method":"getdifficulty","params":[]},
|
|
49
|
+
{"jsonrpc":"1.0","id":"b","method":"getconnectioncount","params":[]},
|
|
50
|
+
{"jsonrpc":"1.0","id":"c","method":"uptime","params":[]}]'
|
|
51
|
+
# -> [{...127450789715843.1},{"result":13},{"result":51321}] in 3 ms
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Ten methods per tier therefore costs ~1 connection instead of 10. Every tier in
|
|
55
|
+
`collect/monitor.js` is one batch.
|
|
56
|
+
|
|
57
|
+
## 3. `getpeerinfo` returns `[]` while `getconnectioncount` says 13
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
getconnectioncount -> 13
|
|
61
|
+
getnetworkinfo -> connections: 13, connections_in: 0, connections_out: 13
|
|
62
|
+
getpeerinfo -> []
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The node's `docs/RPC_LIVE_NODE.md` slice 2 describes a shared peer table that
|
|
66
|
+
should fill this, and `rpc_fill_peer_slot(...)` is called in `main.c` — but the
|
|
67
|
+
deployed build (`deploy-20260907a`) publishes nothing. **This is a
|
|
68
|
+
node-side gap, not a monitor bug.** The monitor therefore:
|
|
69
|
+
|
|
70
|
+
- renders peer **identity and activity from the log** (relay legs, block serving,
|
|
71
|
+
connects/drops),
|
|
72
|
+
- shows `getpeerinfo` verbatim in its own panel so the gap is visible,
|
|
73
|
+
- raises the `peerinfo-empty` quality flag naming the counts.
|
|
74
|
+
|
|
75
|
+
Do not "fix" this by hiding the empty table or by inventing peers.
|
|
76
|
+
|
|
77
|
+
## 4. `getnettotals` reports zero bytes
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
getnettotals -> {"totalbytesrecv":0,"totalbytessent":0,
|
|
81
|
+
"uploadtarget":{"target":0,"serve_historical_blocks":true,...}}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The byte counters are per-peer `TCP_INFO` values held by the forked download
|
|
85
|
+
worker (slice 6). Result: **upload rate is genuinely unavailable.** The Network
|
|
86
|
+
page says so in words rather than charting a zero.
|
|
87
|
+
|
|
88
|
+
Downlink and disk-write throughput **are** available, from the log:
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
[dlc] -- network recv this tick: 6.6KB (674.1B/s) | total recv: 1.8MB ||
|
|
92
|
+
disk write this tick: 0.0B (0.0B/s) | total written: 1.7MB --
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Units are **decimal**: 4096 bytes prints as `4.0KB`. `parseSize` decodes the same
|
|
96
|
+
way. A 1024-based decoder here would make every bandwidth figure wrong by 2.4%.
|
|
97
|
+
|
|
98
|
+
## 5. `getrawmempool verbose` shape and size
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
4,743 txs -> 660,151 bytes in 21 ms
|
|
102
|
+
entry keys: vsize, weight, time, fees{base}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Not present, therefore **not shown**: `depends`, `ancestors`, `descendants`,
|
|
106
|
+
`ancestorcount`, `modifiedfees`, `fees.prioritiserved`, `currentlimit`,
|
|
107
|
+
`replaceable`, `withdrawreason`, `replaced-by`. The Mempool page lists these
|
|
108
|
+
under "fields this node does not report" instead of rendering empty boxes.
|
|
109
|
+
|
|
110
|
+
Peak pool observed in the log: **17,288 tx** (~2.4 MB verbose) — hence the 60 s
|
|
111
|
+
tier, not the 15 s one.
|
|
112
|
+
|
|
113
|
+
## 6. `getblock verbosity 2` is unusable for block analytics
|
|
114
|
+
|
|
115
|
+
Returns the full hex of every transaction: **11,254,837 bytes for one block,
|
|
116
|
+
83 ms**, and — the real problem — **no `fee` or `deltafee` per transaction**,
|
|
117
|
+
which Core includes. So per-block fee data cannot come from `getblock`.
|
|
118
|
+
|
|
119
|
+
`getblockstats` is the right source and is complete and fast:
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
getblockstats 965993 [totalfee,txs,...] -> 4 ms
|
|
123
|
+
{totalfee:1109919, txs:6041, total_size:1601834, total_weight:3991625,
|
|
124
|
+
avgfee:183, medianfee:46, maxfee:84983, subsidy:312500000,
|
|
125
|
+
feerate_percentiles:[0,0,0,1,2], ins:7338, outs:13167, swtxs:5975,
|
|
126
|
+
utxo_increase:5829, ...}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
All block charts use it. 12 heights per batch.
|
|
130
|
+
|
|
131
|
+
## 7. Fast reads that are safe at any reasonable cadence
|
|
132
|
+
|
|
133
|
+
```
|
|
134
|
+
getdifficulty 3 ms getmempoolinfo fast getchaintips 3 ms
|
|
135
|
+
estimatesmartfee 4 ms getmininginfo fast getnettotals fast
|
|
136
|
+
gettxoutsetinfo 6 ms (muhash + txouts; NOT the 60-83 s reload the docs
|
|
137
|
+
mention — that is `gettxout`'s old read-only view)
|
|
138
|
+
getindexinfo 524 ms getchaintxstats fast uptime fast
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`getindexinfo` at ~0.5 s is the only slow one in the slow tier.
|
|
142
|
+
|
|
143
|
+
## 8. Real IBD numbers (bench node, 2026-09-08 ~01:06–01:09Z)
|
|
144
|
+
|
|
145
|
+
This is the dataset the sync bar is designed and tested against:
|
|
146
|
+
|
|
147
|
+
```
|
|
148
|
+
time rpc_s blocks headers height% vprogress ibd
|
|
149
|
+
2026-09-08T01:06:08Z 1 647688 965953 67.05% 0.670517 True
|
|
150
|
+
2026-09-08T01:06:34Z 1 648283 965953 67.11% 0.671133 True +595
|
|
151
|
+
2026-09-08T01:06:59Z 0 648723 965953 67.16% 0.671589 True +440
|
|
152
|
+
2026-09-08T01:07:24Z 0 649123 965953 67.20% 0.672003 True +400
|
|
153
|
+
2026-09-08T01:07:49Z 0 649531 965953 67.24% 0.672425 True +408
|
|
154
|
+
2026-09-08T01:08:14Z 0 649906 965953 67.28% 0.672814 True +375
|
|
155
|
+
2026-09-08T01:08:39Z 0 650105 965953 67.30% 0.673020 True +199
|
|
156
|
+
2026-09-08T01:09:04Z 0 650263 965953 67.32% 0.673183 True +158
|
|
157
|
+
2026-09-08T01:09:29Z 0 650266 965953 67.32% 0.673186 True +3
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Three findings, each of which is now encoded:
|
|
161
|
+
|
|
162
|
+
1. **`verificationprogress` ≈ `blocks/headers` during IBD** (67.05% vs 0.670517).
|
|
163
|
+
They are still kept as two separate figures — but the divergence caveat stays
|
|
164
|
+
quiet when they agree, tested in `test/sync.test.js`.
|
|
165
|
+
2. **The rate decelerates hard**: 595 → 3 blocks per 25 s sample as validation
|
|
166
|
+
catches the download. One window's ETA would be a number about to be wrong, so
|
|
167
|
+
the sync model computes a 2-minute rate *and* a 10-minute rate, uses the recent
|
|
168
|
+
one, and reports `rateTrend: decelerating` with a caveat saying the ETA will
|
|
169
|
+
get **longer**.
|
|
170
|
+
3. Headers were **constant at 965,953** while blocks advanced, i.e. the
|
|
171
|
+
headers-first shape means `blocks/headers` is the correct 0→100% measure.
|
|
172
|
+
|
|
173
|
+
## 9. Log formats
|
|
174
|
+
|
|
175
|
+
`test/fixtures/log-samples.txt` holds frozen real lines; `test/logparse.test.js`
|
|
176
|
+
asserts against them. Formats that differ from what you might assume:
|
|
177
|
+
|
|
178
|
+
- `-- peers banned this run: 95 of 123 --` — *not* `peers banned:`.
|
|
179
|
+
- Tags include **underscores** (`[tx_accept]`) and **indices** (`[mux:11]`,
|
|
180
|
+
`[dl:7]`). A `[a-z0-9]+` tag regex silently loses `tx_accept`.
|
|
181
|
+
- `[block] stored height=… hash=0000000000000000.. bytes=… tx=… (via IP:port)` —
|
|
182
|
+
the hash is truncated with `..`, and `via` is the only place that names which
|
|
183
|
+
peer served a block.
|
|
184
|
+
- Timestamps are **local time with no offset**: `2026-09-07 09:24:39.281`.
|
|
185
|
+
Build with explicit `new Date(y,m-1,d,...)`; `Date.parse` of a non-ISO string is
|
|
186
|
+
implementation-defined.
|
|
187
|
+
- `uptime=00:14:15:21` is `DD:HH:MM:SS` = 51,321 s — which matched the node's own
|
|
188
|
+
`uptime` RPC exactly, confirming the format.
|
|
189
|
+
- **A build can rewrite these lines.** The bench build of 2026-09-08 03:02 changed
|
|
190
|
+
`[dlc]` so comprehensively that the rules written against the production build
|
|
191
|
+
matched **30 of 1,702 lines** in its log, and **1 of its 1,006** `[dlc]` lines.
|
|
192
|
+
Same facts, new grammar — see §11. `test/bench-log.test.js` now keeps a
|
|
193
|
+
representative frozen sample and fails on the ratio, not only on a regex.
|
|
194
|
+
|
|
195
|
+
## 11. The second build: bench node 2026-09-08 03:02, and what its log says
|
|
196
|
+
|
|
197
|
+
The bench node was rebuilt during this work (`v0.0.1 built Sep 8 2026 03:02`, run
|
|
198
|
+
03:02–04:31Z archived as `console.run17c.log`). It disagrees with every
|
|
199
|
+
measurement above in three specific ways, each re-run by hand:
|
|
200
|
+
|
|
201
|
+
**`getpeerinfo` publishes rows now — and they still are not the traffic.**
|
|
202
|
+
|
|
203
|
+
```
|
|
204
|
+
getconnectioncount -> 5
|
|
205
|
+
getpeerinfo -> 5 rows, each carrying bytessent / bytesrecv
|
|
206
|
+
sum(bytessent) -> 1153 sum(bytesrecv) -> 3232
|
|
207
|
+
getnettotals -> {totalbytesrecv: 3232, totalbytessent: 1153}
|
|
208
|
+
stalest lastrecv -> 4443 s ago
|
|
209
|
+
log for the same run -> [dlc] -- recv 11.2MB/s ... elapsed 1:11:11
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
11.2 MB/s for 71 minutes is ~47 GB. The peer table reported **3,232 bytes**, and
|
|
213
|
+
those bytes **sum exactly to `getnettotals`**, on peers last heard from 74 minutes
|
|
214
|
+
ago. So the newest build publishes a peer table for the control channel only; the
|
|
215
|
+
block bytes still live in the forked download worker and still appear nowhere in
|
|
216
|
+
RPC. Production (`deploy-20260907a`) was re-checked in the same minute and still
|
|
217
|
+
answers `[]` with `getconnectioncount: 13`.
|
|
218
|
+
|
|
219
|
+
**Per-peer throughput exists only in the log**, in a line the production build
|
|
220
|
+
does not print at all:
|
|
221
|
+
|
|
222
|
+
```
|
|
223
|
+
[dlc] w1 84.215.4.221:8333 chunks=54 blocks=2160 (+10 blk/s, 941.2KB/s)
|
|
224
|
+
[dlc] w2 164.90.253.129:8333 chunks=0 blocks=0 (+0 blk/s, 0.0B/s) [early-kill, last 0.0B/s, peer BANNED]
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
256 such lines in the 90-minute run, naming a peer, a rate and a block count.
|
|
228
|
+
That is the whole per-peer bandwidth panel, and no RPC call returns any of it.
|
|
229
|
+
|
|
230
|
+
**The `[dlc]` tick changed grammar**, so `bandwidth` now arrives from two shapes:
|
|
231
|
+
|
|
232
|
+
```
|
|
233
|
+
prod [dlc] -- network recv this tick: 4.0KB (405.0B/s) | total recv: 2.3MB ||
|
|
234
|
+
disk write this tick: 0.0B (0.0B/s) | total written: 1.9MB --
|
|
235
|
+
bench [dlc] -- recv 11.2MB/s (avg 10.5MB/s) | write 11.2MB/s (avg 10.7MB/s) |
|
|
236
|
+
floor 32.0 KB/s (median 353.4) | banned 6/121 | events 0 rot 0 ... --
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
The bench form drops the running totals (so the totals panels show `–`, not 0)
|
|
240
|
+
and prints `(median 353.4)` **with no unit**. Both readings are defensible — KB/s
|
|
241
|
+
would make it a peer speed, B/s would make it 1000× smaller — so the value is
|
|
242
|
+
kept as the printed text in `poolMedianText` and `poolMedian` stays `null`.
|
|
243
|
+
|
|
244
|
+
**Three progress figures, not one.** The bench build prints its own sync
|
|
245
|
+
arithmetic twice, and they disagree by design:
|
|
246
|
+
|
|
247
|
+
| source | line | measured |
|
|
248
|
+
|---|---|---|
|
|
249
|
+
| download worker | `[dlc] == elapsed 1:28:22 \| eta 00:08:38:19 \| overall: 397852/966011 stored (41.19%) \| applied=396275 lag=0 ==` | node's own eta **8h38m** |
|
|
250
|
+
| applying thread | `[utxo_live] catchup progress: height=396273/966010 (41.0%) 23.8 blk/s ... eta 00:06:38:48` | node's own eta **6h38m**, 42.3 ms/blk |
|
|
251
|
+
| monitor | blocks/headers + measured rate over ≥60 s windows | separate, see §8 |
|
|
252
|
+
|
|
253
|
+
All three are stored and labelled separately. Rules 4 and 9 apply: the node's ETA
|
|
254
|
+
is a fourth opinion, not a correction to ours.
|
|
255
|
+
|
|
256
|
+
**Clock widths differ by line**: `elapsed 1:22:01` is `H:MM:SS` while
|
|
257
|
+
`eta 00:07:54:48` is `DD:HH:MM:SS`. Decoding the first as four fields would read
|
|
258
|
+
1 h 22 m as 1 day 22 min. `parseClock()` counts fields instead of assuming.
|
|
259
|
+
|
|
260
|
+
**Where the file is.** This node writes its log to the run directory's
|
|
261
|
+
`console.log`. `<datadir>/main/debug.log` exists but is **144 bytes — three `node
|
|
262
|
+
start` lines — and had not moved since boot**, which is what the monitor had
|
|
263
|
+
open (fd 22) for two hours while every log-derived panel of this node sat still.
|
|
264
|
+
|
|
265
|
+
**And it is buffered.** At 04:50 the replacement run's `console.log` sat frozen at
|
|
266
|
+
**2,719 bytes for 18 minutes while the node's chain advanced ~15,000 blocks**
|
|
267
|
+
(397k → 412,606). stdout is block-buffered when it is a file, so lines arrive in
|
|
268
|
+
bursts: delayed here, never lost. A "log is stale" warning that did not check the
|
|
269
|
+
chain height would have accused the config of being wrong while being right about
|
|
270
|
+
nothing.
|
|
271
|
+
|
|
272
|
+
## 12. Quiet gaps in a healthy node's log (why a stale gate cannot be short)
|
|
273
|
+
|
|
274
|
+
Across 1,840 lines of the synced production node's log, the longest gap between
|
|
275
|
+
consecutive lines was **1,182 s (~20 min)**. A 5-minute "the log stopped moving"
|
|
276
|
+
gate would flag a healthy idle node every few minutes, which trains people to
|
|
277
|
+
ignore it. The default `log.staleMs` is 30 min, per-node `logStaleMs` tightens it
|
|
278
|
+
where the node is chatty, and the flag reports the chain delta observed during the
|
|
279
|
+
silence rather than asserting a cause.
|
|
280
|
+
|
|
281
|
+
## 13. Parser coverage, measured on both builds
|
|
282
|
+
|
|
283
|
+
Share of tagged log lines that a parser claimed (a `raw` event is a miss):
|
|
284
|
+
|
|
285
|
+
| corpus | before this change | after |
|
|
286
|
+
|---|---|---|
|
|
287
|
+
| bench run `console.run17c.log` (1,997 tagged lines) | 1.5% (30 lines; `[dlc]` 1/1006) | **94.8%** |
|
|
288
|
+
| production `bitcoin.main.log` (1,931 tagged lines) | 82.4% | **90.5%** |
|
|
289
|
+
|
|
290
|
+
The remaining misses are one-shot `[config]`, `[boot]`, `[wallet]`, `[tor]` and
|
|
291
|
+
`[serve]` lines that carry no figure the monitor wants. Coverage is now asserted
|
|
292
|
+
in `test/bench-log.test.js` against a frozen representative sample, at ≥0.85, and
|
|
293
|
+
surfaced live as `log.health.ratio` on the Node panel.
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
## 14. Methods the node documents as refusing
|
|
297
|
+
|
|
298
|
+
`getmempoolcluster`, `loadtxoutset`, `getblockfilter`/`scanblocks`/
|
|
299
|
+
`getdescriptoractivity` (outside the undo window), `getblockfrompeer`,
|
|
300
|
+
`preciousblock`, `pruneblockchain`, `submitheader`, `exportasmap`,
|
|
301
|
+
`enumeratesigners`, `getopenrpcinfo`. A refusal from these is expected behaviour,
|
|
302
|
+
and `NODE_REFUSES` in `server/rpc/allowlist.js` says so in the reply rather than
|
|
303
|
+
letting it look like a monitor fault.
|
|
304
|
+
|
|
305
|
+
## 15. Third build in four hours: the RPC grew a spine, and the log grew a field
|
|
306
|
+
|
|
307
|
+
Every RPC claim above is scoped to a build, because in a single day the same host
|
|
308
|
+
gave three different answers to the same three calls. This is the record.
|
|
309
|
+
|
|
310
|
+
| build / time | `getconnectioncount` | `getpeerinfo` | `getnettotals` recv |
|
|
311
|
+
|---|---|---|---|
|
|
312
|
+
| deployed to production, 04:16 and again 05:20 | 13 → 17 | **`[]`** | **0** |
|
|
313
|
+
| bench, built 03:02, sampled 04:16 | 5 | 5 rows, bytes summing to 3,232 | 3,232 |
|
|
314
|
+
| bench, rebuilt ~05:47, sampled 05:51 | 5 | **21 rows**, largest 201,608,074, a download-worker marker field present | 2,116,236,872 |
|
|
315
|
+
|
|
316
|
+
`getpeerinfo`'s 21 rows sum to 1,487,577,978 = **70.29%** of what `getnettotals`
|
|
317
|
+
reports received. The missing third is traffic from peers no longer in the table,
|
|
318
|
+
so the rows are a subset, not a breakdown — surfaced as `peerinfo-partial`.
|
|
319
|
+
|
|
320
|
+
**Down rate is now derivable from RPC on the newest build.** Sampled twice, 90 s
|
|
321
|
+
apart, on the bench node mid-IBD:
|
|
322
|
+
|
|
323
|
+
```
|
|
324
|
+
getnettotals delta over 90s : 11.56 MB/s blocks 407.43/s
|
|
325
|
+
node's own log, same window : recv 11.2MB/s (avg 9.0 → 10.1MB/s across the window)
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
**3% apart.** §4's conclusion ("bandwidth comes from the log") is now build-scoped,
|
|
329
|
+
not absolute, and §11's "3 KB against 47 GB" is equally real for the build it
|
|
330
|
+
measured. Both were measured today; neither is a universal.
|
|
331
|
+
|
|
332
|
+
**Up rate is still not there, and the arithmetic proves it.** Same process
|
|
333
|
+
lifetime: **12,896,531,244 bytes in against 1,129 bytes out**, with 21 peers
|
|
334
|
+
connected (later 14.0 GB against ~1 KB with 5). A node that pulled 12.9 GB of
|
|
335
|
+
blocks necessarily sent `getdata` for them; an 11,000,000:1 ratio is a statement
|
|
336
|
+
about the counter, not the traffic. So `outBps` is withheld and the reason is
|
|
337
|
+
published (`upload-unmeasurable`). A rate computed as `Δ(1129)/Δt` would have
|
|
338
|
+
rendered as a calm `0 B/s` upload on a node doing 11 MB/s in the other direction.
|
|
339
|
+
|
|
340
|
+
**RPC cannot tell you which regime you are in.** Both the build that reports 12.9 GB
|
|
341
|
+
and the build that reports 0 answer the same vendor `subversion` string and the same
|
|
342
|
+
`version: 1`. The only build attestation on this box is the build's own log banner
|
|
343
|
+
(`… LOG START … built Sep 8 2026 03:02 …`). So "trust the RPC instead of the log" is
|
|
344
|
+
not a safe global stance: the log is how you know whether
|
|
345
|
+
the RPC's zeros mean zero or mean *unpublished*.
|
|
346
|
+
|
|
347
|
+
**Update 2026-09-11 (§27):** true of every build up to this date, and no longer of
|
|
348
|
+
`deploy-20260910ag`. Its `getnetworkinfo` carries the build's own commit attestation
|
|
349
|
+
field, "860b8fdd", and a dirty flag reading false. Older builds omit the field, so this
|
|
350
|
+
paragraph still describes them.
|
|
351
|
+
|
|
352
|
+
**And the log moved again, an hour after its parser was written.** At 05:42:27 the
|
|
353
|
+
tick line gained a field:
|
|
354
|
+
|
|
355
|
+
```
|
|
356
|
+
[dlc] -- recv 81.2MB/s (avg 108.7MB/s) | write 62.8MB/s (avg 80.4MB/s) | floor 32.0 KB/s (median 5.1) | banned 8/114 | staged 1 --
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
26 of 26 tick lines in that run matched nothing, because the rule anchored on the
|
|
360
|
+
end of the line. The global `log-unparsed` gate (<5% of lines) never fired — the
|
|
361
|
+
run overall still parsed ~24% — so a shape can die completely while the ratio looks
|
|
362
|
+
survivable. Coverage per rule, not just coverage overall, is the actual lesson; see
|
|
363
|
+
rule 15 and DEFECTS.
|
|
364
|
+
|
|
365
|
+
## 16. Cheap RPC reads worth having (measured on production, 2026-09-08)
|
|
366
|
+
|
|
367
|
+
| method | ms | bytes | note |
|
|
368
|
+
|---|---|---|---|
|
|
369
|
+
| `getaddrmaninfo` | 11 | 300 | peer book: 52,877 tried (ipv4 36,482 / ipv6 9,046 / onion 6,304 / i2p 1,045) |
|
|
370
|
+
| `getchainstates` | 2 | 336 | headers + per-state blocks/difficulty/`validated` |
|
|
371
|
+
| `listbanned` | 3 | 36 | **`[]` while the same node's log said `banned 8/114`** — worker bans are not in the stored ban table |
|
|
372
|
+
| `uptime` | 2 | 39 | seconds |
|
|
373
|
+
| `getmemoryinfo` | 2 | — | refused: `getmemoryinfo "stats" reports Bitcoin Core's SECURE ALLOCATOR…` |
|
|
374
|
+
| `getlogging` | 2 | — | does not exist; the method is `logging`, which is denied as a mutator |
|
|
375
|
+
|
|
376
|
+
There is **no `getlogevents`** and no build-specific stats method (171 methods,
|
|
377
|
+
identical lists on both builds), so the log buffer is not reachable over RPC:
|
|
378
|
+
`[dlc]`'s write rate, `[tx_accept]`'s reject breakdown, `[check]`'s archive holes
|
|
379
|
+
and `[utxo_live]`'s validation stalls have no RPC equivalent at any cadence.
|
|
380
|
+
|
|
381
|
+
**Update 2026-09-11 (§27):** `deploy-20260910ag` serves **165** methods and has gained a
|
|
382
|
+
build-specific one, a vendor download-info method with no Core counterpart. It covers
|
|
383
|
+
the download's state, not the log's reject, write-rate or hole figures, and there is
|
|
384
|
+
still no `getlogevents`. The 171-name
|
|
385
|
+
list above was never saved, so the six dropped methods cannot be named. The 165-name list
|
|
386
|
+
is in `docs/rpc-methods-2026-09-11.txt`.
|
|
387
|
+
|
|
388
|
+
## 17. Fourth build, and the parsers that died while nobody was looking
|
|
389
|
+
|
|
390
|
+
At 06:21:35 the production node logged `[serve] shutting down (signal 15):
|
|
391
|
+
tip=965914 outbound_legs=0` and came back as `deploy-20260908a`. Two
|
|
392
|
+
things were true the moment it returned:
|
|
393
|
+
|
|
394
|
+
**1. The `[dlc] ==` progress line had changed parenthetical, and the rigid rule
|
|
395
|
+
matched 185 of 185 → 0 of 185:**
|
|
396
|
+
|
|
397
|
+
```
|
|
398
|
+
old: … | in flight 279 of window 4096 through 391761 (oldest gap 0s at 388878, 99.93% landed) | applied=388876 lag=1 ==
|
|
399
|
+
new: … | in flight 0 of window 4096 through 322920 (no gap, 100.00% landed) | applied=322920 lag=0 ==
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
`eta` also prints `--:--:--:--` when the node has no estimate — decoded as `null`,
|
|
403
|
+
never as zero.
|
|
404
|
+
|
|
405
|
+
**2. Production was suddenly talking to something RPC cannot see.** 646 lines in
|
|
406
|
+
half an hour:
|
|
407
|
+
|
|
408
|
+
```
|
|
409
|
+
[serve] inbound 127.0.0.1:34208 accepted -> child pid 3425602 (2/245 inbound)
|
|
410
|
+
[serve] inbound 127.0.0.1:34208 v2 handshake failed -- dropping
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
323 inbound connections from **this host**, accepted and dropped before BIP324
|
|
414
|
+
completed. `getpeerinfo` cannot show a peer that never negotiated, and
|
|
415
|
+
`getconnectioncount` never counted them. The monitor aggregates them
|
|
416
|
+
(`inbound-handshake-failing`, with the per-host breakdown) instead of putting 323
|
|
417
|
+
identical rows in the feed — the finding is the rate and the source, not the
|
|
418
|
+
individual socket.
|
|
419
|
+
|
|
420
|
+
**Coverage, live, before and after field-scanning these two lines:**
|
|
421
|
+
|
|
422
|
+
| corpus | rigid rules | field-scanned |
|
|
423
|
+
|---|---|---|
|
|
424
|
+
| bench `console.log` (947 tagged lines) | 68.2% | **90.3%** |
|
|
425
|
+
| production `bitcoin.main.log` (3,689 tagged lines) | 73.6% | **89.7%** |
|
|
426
|
+
|
|
427
|
+
The remaining misses are one-shot `[config]`, `[boot]`, `[wallet]`, `[tor]`,
|
|
428
|
+
`[serve]` boot chatter and `[dl]` internal-progression lines.
|
|
429
|
+
|
|
430
|
+
Four grammars changed in one day on two builds, and each time the *global* parse
|
|
431
|
+
ratio stayed high enough to look survivable — 24%, 68%, 74%. That is the argument
|
|
432
|
+
for scanning labelled lines field by field, which is now how the tick and progress
|
|
433
|
+
lines are parsed: a new field costs that field (`extraFields`, values kept verbatim:
|
|
434
|
+
`staged 38 commit 6288`) instead of costing the line.
|
|
435
|
+
|
|
436
|
+
## 18. Production deploy-20260908a: the RPC gap closed, and a port trap reloaded
|
|
437
|
+
|
|
438
|
+
Fixed at 07:52 after an outage the monitor reported correctly the whole time
|
|
439
|
+
(`main online false`, with the reason, no invented numbers).
|
|
440
|
+
|
|
441
|
+
**What actually broke was two lines in the node's `bitcoin.conf`.** The 06:21 deploy
|
|
442
|
+
rewrote that file to four lines, dropping `rpcport=8331` and `port=8332`, so the
|
|
443
|
+
daemon fell back to Core's defaults and both halves of the default layout collided
|
|
444
|
+
with something else on this box:
|
|
445
|
+
|
|
446
|
+
1. P2P wanted **8333**, which the benchmark's Core oracle (`bitcoind`, pid 106094)
|
|
447
|
+
holds on `127.0.0.1`–`16`. Boot died with `[boot] lsock failed: Address already
|
|
448
|
+
in use` at 06:32:02, having loaded the archive fine (`tip=966028 (0.00s)`).
|
|
449
|
+
2. The 06:35 workaround bound P2P to `192.0.2.10`, which fixed the collision but
|
|
450
|
+
left **RPC on 8332** — while the monitor, the systemd unit and this node's own
|
|
451
|
+
`config/bitcoin.conf` all say RPC is **8331**. Result: RPC answering on a port
|
|
452
|
+
nobody was looking at, and a monitor reporting `ECONNREFUSED` truthfully.
|
|
453
|
+
|
|
454
|
+
The node's own conf header records this exact failure from 2026-08-26 ("rpcport MUST
|
|
455
|
+
differ from port= … the embedded RPC server loses that bind and comes up RPC-less") —
|
|
456
|
+
a third repeat. Restoring the two lines fixed both symptoms; the node came up with
|
|
457
|
+
`[rpc] JSON-RPC server on 127.0.0.1:8331` at 07:53:24 and the monitor's breaker
|
|
458
|
+
closed within a minute. Backup kept as `bitcoin.conf.pre-rpcport-restore-20260908-0752`.
|
|
459
|
+
|
|
460
|
+
**Two timings worth knowing before blaming the monitor:**
|
|
461
|
+
|
|
462
|
+
- **RPC binds ~100 s after systemd reports `running`.** Boot 07:51:41 → RPC 07:53:24.
|
|
463
|
+
A probe at 40 s sees `ECONNREFUSED` and no cookie file; neither means failure.
|
|
464
|
+
- **`[utxo_live] init` blocks chain RPCs for ~40 s** (07:53:24 → 07:54:06 measured).
|
|
465
|
+
The monitor's slow tier landed inside that window and reported
|
|
466
|
+
`rpc timeout after 90000ms` — while `uptime`, `getconnectioncount`,
|
|
467
|
+
`getblockcount` and `getnettotals` each answered in **0.15 ms** moments later.
|
|
468
|
+
Cheap methods answering while chain-backed ones hang is the signature; the lane's
|
|
469
|
+
timeout and breaker are the right response, not a bug.
|
|
470
|
+
|
|
471
|
+
**And the gap that started this whole investigation is now closed on production.**
|
|
472
|
+
Same host, new build, 2 minutes after boot:
|
|
473
|
+
|
|
474
|
+
| | deploy-20260907a (04:16, 05:20) | **deploy-20260908a** (07:56) |
|
|
475
|
+
|---|---|---|
|
|
476
|
+
| `getconnectioncount` | 13–17 | 6 |
|
|
477
|
+
| `getpeerinfo` | **`[]`** | **4 rows** |
|
|
478
|
+
| sum of `bytesrecv` | 0 | **1,036,340 — exactly `getnettotals` recv** |
|
|
479
|
+
| `getnettotals` sent | 0 | **310,937** |
|
|
480
|
+
| download-worker marker field in rows | — | absent (the bench build has it) |
|
|
481
|
+
|
|
482
|
+
So on the build production runs *now*, RPC-only mode can back the bandwidth and peer
|
|
483
|
+
panels. Two caveats the data still shows: rows (4) were fewer than
|
|
484
|
+
`getconnectioncount` (6), and `subversion` is still the same vendor string on
|
|
485
|
+
every build including the two that reported 0 bytes — so the log banner remains the
|
|
486
|
+
only way to know which regime you are in, which is why `BLOCKYARD_LOG_SOURCE=0` states
|
|
487
|
+
its losses instead of assuming they are gone.
|
|
488
|
+
|
|
489
|
+
**Update 2026-09-11 (§27):** on `deploy-20260910ag` both caveats moved. Rows equal
|
|
490
|
+
connections (9 of 9, 09:22Z), and `getnetworkinfo` attests the build
|
|
491
|
+
(commit attestation "860b8fdd"). Rows now sum to 99.99% of `getnettotals` because that
|
|
492
|
+
total no longer counts closed peers, not because it covers them.
|
|
493
|
+
|
|
494
|
+
## 19. Cadences, measured — because a stale-warning needs a number, not a feeling
|
|
495
|
+
|
|
496
|
+
Four grammars changed today and the corpus parse ratio flagged none of them (rule 16).
|
|
497
|
+
The fix is a per-shape liveness flag, which needs one thing I did not have: how often
|
|
498
|
+
each line *should* arrive. Measured on both live nodes (production, 355–640
|
|
499
|
+
occurrences per shape):
|
|
500
|
+
|
|
501
|
+
| shape | median | p95 | max | watched? | gate |
|
|
502
|
+
|---|---|---|---|---|---|
|
|
503
|
+
| heartbeat | 65 s | 152 s | 1657 s | yes | 20 min |
|
|
504
|
+
| relay legs | 64 s | 120 s | 1842 s | yes | 16 min |
|
|
505
|
+
| orphans | 65 s | 152 s | 1657 s | yes | 20 min |
|
|
506
|
+
| accepts and rejects | 33 s | 86 s | 1652 s | yes | 12 min |
|
|
507
|
+
| address gossip | 22 s | 87 s | 902 s | yes | 12 min |
|
|
508
|
+
| bandwidth tick | 10 s | 17 s | 17 s | IBD only | 10 min |
|
|
509
|
+
| download progress | 10 s | 17 s | 17 s | IBD only | 10 min |
|
|
510
|
+
| **`[dl] updating utxo`** | **460 s** | **1868 s** | **3669 s** | **no** | — |
|
|
511
|
+
| **`[dl] header mirror`** | **502 s** | **2054 s** | **3669 s** | **no** | — |
|
|
512
|
+
|
|
513
|
+
Gate = p95 × 8, clamped to [10, 30] min. The last two rows are the ones that make the
|
|
514
|
+
flag trustworthy rather than noise: an irregular line watched on a tight gate is
|
|
515
|
+
wrong often enough to be ignored, and an ignored warning is worse than none. The
|
|
516
|
+
maxima of ~1,650 s are the restart windows, where *no* lines arrive — the flag
|
|
517
|
+
requires other lines to be arriving, so a down node does not also produce a format
|
|
518
|
+
warning.
|
|
519
|
+
|
|
520
|
+
Shapes also stay unarmed until their rule matches once, which is how build
|
|
521
|
+
differences are handled with no build table: the bench build never emits `heartbeat`,
|
|
522
|
+
production emits a bandwidth tick twice in hours because it is synced.
|
|
523
|
+
|
|
524
|
+
**Event time, not wall clock**, deliberately: the bench node block-buffers its
|
|
525
|
+
stdout (§11), so a tail delivers minutes of history in one burst. Measuring silence
|
|
526
|
+
against arrival time would cry wolf at every flush; measured against the log's own
|
|
527
|
+
timestamps it is ~0 s across a burst, which is the truth.
|
|
528
|
+
|
|
529
|
+
### Four unparsed shapes found while building this, and one lesson about ratios
|
|
530
|
+
|
|
531
|
+
Production was at 73.4% coverage; after parsing what was actually missing it is at
|
|
532
|
+
**82.8%** (bench 85.7%). The unparsed shapes were:
|
|
533
|
+
|
|
534
|
+
```
|
|
535
|
+
418 [txrelay] addrv2 gossip: +3 address(es) to the book
|
|
536
|
+
130 [dl] outbound top-up: 4 dial(s) failed, first 93.201.114.79:8333: connect: Operation now in progress
|
|
537
|
+
43 [dl] updating utxo: applied 1 block(s), now at height 966054, live=165330654 (0.21s)
|
|
538
|
+
41 [dl] header mirror +1 from the archive (now 966055, archive tip 966054)
|
|
539
|
+
52 [dial] 31.21.210.206:8333: dialing in the background (ipv4)
|
|
540
|
+
2 [dial] no global IPv6 route on this host: ipv6 peers are unreachable (cjdns unaffected)
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
The ratio had *fallen* 16 points for a completely harmless reason — gossip chatter
|
|
544
|
+
grew — after it had stayed *healthy* while three rules died. A corpus number is
|
|
545
|
+
useless in both directions; it is reported now because it is cheap, and nothing
|
|
546
|
+
depends on it.
|
|
547
|
+
|
|
548
|
+
Two of those are figures the monitor did not have: `[dl] updating utxo` is
|
|
549
|
+
**validation throughput** (`applied 1 block in 0.21 s` → 4.76 blk/s), kept as a third
|
|
550
|
+
rate beside the download rate and the catch-up rate, never averaged with either
|
|
551
|
+
(rule 9); and `[dial] no global IPv6 route` explains an ipv6 peer count of zero
|
|
552
|
+
better than the count does — flagged as `ipv6-unreachable`, "a host capability, not a
|
|
553
|
+
node fault".
|
|
554
|
+
|
|
555
|
+
`updating utxo` and `header mirror` are parsed but deliberately *not* watched, per the
|
|
556
|
+
table above — so the bursty-rate lesson is encoded where the data that produced it
|
|
557
|
+
lives, not in a comment nobody re-reads.
|
|
558
|
+
|
|
559
|
+
## 20. One ring per series had been averaging two daemons since the second node was added
|
|
560
|
+
|
|
561
|
+
Found while trying to answer a different question ("did the RPC breaker open while the
|
|
562
|
+
node was healthy?") — which turned out to be **unanswerable from the history**, because
|
|
563
|
+
no ring row said which node it came from. The measured mix, two live nodes, 2026-09-08:
|
|
564
|
+
|
|
565
|
+
| ring | rows | from production | from bench |
|
|
566
|
+
|---|---|---|---|
|
|
567
|
+
| `peers` | 5,005 | 2,308 (`connections >= 15`) | 1,816 (`connections <= 6`) |
|
|
568
|
+
| all 9 rings | ~45,000 | no marker | no marker |
|
|
569
|
+
|
|
570
|
+
So every chart on the Nodes/Network/Peers pages was drawing a bucketed aggregate of
|
|
571
|
+
two different daemons, and `last()`/`tail()` were returning whichever node wrote most
|
|
572
|
+
recently. With `agg: 'last'` per bucket, a bucket holding a production point and a
|
|
573
|
+
bench point rendered as *the node the chart is labelled with* — which is not a
|
|
574
|
+
misreading by the viewer, it is a wrong value from the store.
|
|
575
|
+
|
|
576
|
+
Now: writes are stamped (`node: <id>`) and reads are filtered through
|
|
577
|
+
`History.forNode(id)`. One ring per series is kept deliberately — capacity and
|
|
578
|
+
retention are configured per series, and per-node rings would multiply memory by node
|
|
579
|
+
count and orphan a removed node's history. The cost is that two nodes halve each
|
|
580
|
+
other's reach back in time, which is asserted in a test so it stays a decision rather
|
|
581
|
+
than becoming a surprise.
|
|
582
|
+
|
|
583
|
+
**The consequence of fixing it, stated rather than hidden:** every row written before
|
|
584
|
+
this change is unattributed — 14,177 `node`, 18,022 `peers`, 12,619 `net`, 16,384
|
|
585
|
+
`rpc` — and a node-filtered read **excludes** them. Attributing them would need a
|
|
586
|
+
guess about which daemon produced them, and a guessed owner is the same error one
|
|
587
|
+
degree further out. So per-node charts refill from the restart: minutes for the 1 h
|
|
588
|
+
ranges, and up to a day and a week for the long ones. `history.summary()` now reports
|
|
589
|
+
`unattributed` and `nodes` per ring so that gap is visible from the API instead of
|
|
590
|
+
being discovered as "where did my history go".
|
|
591
|
+
|
|
592
|
+
## 21. What monitoring the benchmark cost, and why it stopped
|
|
593
|
+
|
|
594
|
+
The benchmark node (`bench`) was removed from the default configuration on
|
|
595
|
+
2026-09-08. Not because its data was wrong — because on this box the act of watching
|
|
596
|
+
it was load, and it could not be read reliably enough to be worth that load.
|
|
597
|
+
|
|
598
|
+
**Cost to the node we actually care about.** The node's RPC services one connection on
|
|
599
|
+
one thread. Every poll of the bench node is a slot the benchmark cannot use. This repo
|
|
600
|
+
already knew that: `main.js` deliberately strips the real bench node out of
|
|
601
|
+
`npm run dev` and `scripts/smoke.sh`, with a comment saying a benchmark disturbed by a
|
|
602
|
+
test suite is a nasty class of interference. Running the bench node as a permanently
|
|
603
|
+
polling-monitored node was the same mistake with better branding.
|
|
604
|
+
|
|
605
|
+
**Reliability, measured over roughly one hour of it being watched:**
|
|
606
|
+
|
|
607
|
+
| measurement | bench | main, same monitor, same code |
|
|
608
|
+
|---|---|---|
|
|
609
|
+
| RPC average latency | ~18–32 s | ≤ 22 ms |
|
|
610
|
+
| RPC timeouts | 90 s, on fast *and* slow tiers | 0 |
|
|
611
|
+
| failed tier runs | 25 | 0 |
|
|
612
|
+
| breaker trips | several (flapping `online`) | 0 |
|
|
613
|
+
| restarts during the window | 9+ (its harness cycles it) | the deploys |
|
|
614
|
+
| log continuity | truncated at every restart; block-buffered, minutes frozen then a burst | continuous, logrotated |
|
|
615
|
+
|
|
616
|
+
A node that is being benchmarked by someone else on the same machine is not a
|
|
617
|
+
telemetry source with a little jitter; it is a different operating environment, and the
|
|
618
|
+
monitor kept reporting it as if it were the first kind.
|
|
619
|
+
|
|
620
|
+
**And it had already corrupted the production view once.** Before series rows carried
|
|
621
|
+
a node id, the `peers` ring held 2,308 production rows interleaved with 1,816 bench
|
|
622
|
+
rows and drew them as one line (§20). The rings are per-node now, but the incident is
|
|
623
|
+
the reason this file treats "which node produced this row" as a first-class fact
|
|
624
|
+
rather than a refinement.
|
|
625
|
+
|
|
626
|
+
**What was lost, honestly:** live visibility of a second IBD in progress — the thing
|
|
627
|
+
that motivated §8's rate/ETA work, and the reason the sync bar's refusal to guess an
|
|
628
|
+
ETA was ever tested against real numbers. That knowledge stays in this file and in
|
|
629
|
+
`test/`'s frozen bench fixtures, which is where it does its job. If a future session
|
|
630
|
+
needs live bench eyes again, `config.js` carries the exact `config/local.json` block to
|
|
631
|
+
bring it back, along with the two traps that bit us: the real log is `console.log` in
|
|
632
|
+
the run directory (not `<datadir>/main/debug.log`, a 144-byte stub), and it is
|
|
633
|
+
block-buffered, so silence means "flushed late" as often as it means "stopped".
|
|
634
|
+
|
|
635
|
+
## 22. Per-response page rewriting, the block map, and the cost of a login attempt (2026-09-09)
|
|
636
|
+
|
|
637
|
+
Numbers behind the changes made on 2026-09-09, measured on this box rather than
|
|
638
|
+
estimated. The method for each is in parentheses, because three of them are the kind
|
|
639
|
+
of claim that silently becomes false when a file grows.
|
|
640
|
+
|
|
641
|
+
### HTML rewriting per response is free
|
|
642
|
+
|
|
643
|
+
The build stamp and the CSP nonce both require rewriting every HTML response. Cost:
|
|
644
|
+
|
|
645
|
+
| measurement | value | how |
|
|
646
|
+
|---|---|---|
|
|
647
|
+
| `index.html` on disk | 21,341 B | `Buffer.byteLength` |
|
|
648
|
+
| after rewriting (nonce + 3 versioned asset URLs) | 21,391 B | same |
|
|
649
|
+
| `renderHtml()` per request | **0.009 ms** | 3,000 iterations after 200 warm-up |
|
|
650
|
+
| `securityHeaders()` per request | 0.0019 ms | same |
|
|
651
|
+
| combined throughput ceiling | ~95,000 rewrites/s | 1000 / (sum) |
|
|
652
|
+
|
|
653
|
+
So the per-response rewrite is not a performance decision, and the `?v=` stamp does
|
|
654
|
+
not need a build step or a manifest. It also means HTML can no longer answer `304`:
|
|
655
|
+
the body is not the file on disk any more, and a cached body would carry a nonce the
|
|
656
|
+
new CSP refuses. Assets keep their ETag/`304` path (asserted in
|
|
657
|
+
`test/http-app.test.js`), which is where the bytes actually are.
|
|
658
|
+
|
|
659
|
+
### The in-memory block map
|
|
660
|
+
|
|
661
|
+
`store.blockMapCap` became configurable, so the default needed a cost:
|
|
662
|
+
|
|
663
|
+
| measurement | value |
|
|
664
|
+
|---|---|
|
|
665
|
+
| 12,000 block rows | **3.6 MB heap** (~310 B/row) |
|
|
666
|
+
| 3,000 rows (the old hard-coded cap) | ~0.9 MB, i.e. the old cap was saving ~2.7 MB |
|
|
667
|
+
|
|
668
|
+
Measured by filling a `Map` with rows shaped exactly as `fetchBlockStats()` builds
|
|
669
|
+
them and differencing `process.memoryUsage().heapUsed` (`test/monitor-shapes.test.js`,
|
|
670
|
+
which prints the figure and asserts an upper bound so the comment cannot rot). The old
|
|
671
|
+
3,000-row cut bought 2.7 MB and threw away blocks that the 72 h retention would have
|
|
672
|
+
kept — and `gapSec` for the oldest survivor then described a block whose predecessor
|
|
673
|
+
had been dropped, silently.
|
|
674
|
+
|
|
675
|
+
### One login attempt
|
|
676
|
+
|
|
677
|
+
The login throttle exists because an attempt is not free:
|
|
678
|
+
|
|
679
|
+
| measurement | value |
|
|
680
|
+
|---|---|
|
|
681
|
+
| `scrypt(N=16384, r=8, p=1, keylen=32)` | **20 ms**, 16.0 MB (`128·r·N`) |
|
|
682
|
+
| the comment in `server/auth/users.js` before this | claimed "~50 ms" — corrected to the measured 20 ms |
|
|
683
|
+
| throttle bucket | capacity 10, refill 0.5/s, per address |
|
|
684
|
+
| 22–24 attempts in one burst | first refusal at attempt ~6–10, ≥10 of the burst refused (`test/http-app.test.js`, `scripts/smoke.sh` asserts ≥8 of 22) |
|
|
685
|
+
|
|
686
|
+
`LoginGuard` locks a *username* after 8 failures in 5 min; the per-user token bucket
|
|
687
|
+
does not apply pre-auth. So before this change a distributed grind — many addresses, a
|
|
688
|
+
few attempts each — sat under every threshold in the code while each attempt cost
|
|
689
|
+
20 ms of the request thread and 16 MB of its memory.
|
|
690
|
+
|
|
691
|
+
### Audit log budget
|
|
692
|
+
|
|
693
|
+
`audit.jsonl` now rotates by size: `store.auditMaxBytes` 8 MiB, `store.auditKeep` 5.
|
|
694
|
+
Worst-case footprint on disk is therefore **48 MiB** (current + 5), against "unbounded"
|
|
695
|
+
before. The reason for a hard bound at all is that this disk also holds the history
|
|
696
|
+
snapshots and the node's datadir: an unbounded audit log's failure mode is not "no
|
|
697
|
+
audit", it is "no node".
|
|
698
|
+
|
|
699
|
+
### Things that were *not* measured, and therefore were not done
|
|
700
|
+
|
|
701
|
+
- **No verbosity-2 block fetch, anywhere.** The drill-down asks `getblock` verbosity 1
|
|
702
|
+
plus `getblockstats`. §6 measured 11 MB of hex per block for verbosity 2 on this
|
|
703
|
+
node, and the fake node now *refuses* verbosity 2 so that a test can fail if the
|
|
704
|
+
monitor ever asks (`test/drilldown.test.js`).
|
|
705
|
+
- **No per-tier circuit breakers.** Still one breaker per node. What was added is the
|
|
706
|
+
telemetry to answer which method opened it, how long it will block, and which calls
|
|
707
|
+
were in the lane when it opened. The granularity question stays unimplemented
|
|
708
|
+
because the contention it would relieve has never been measured (DEFECTS).
|
|
709
|
+
- **No fee in the transaction view.** Computing it means fetching every input's
|
|
710
|
+
prevout — N more turns on a one-threaded RPC server — and §6 records that this node
|
|
711
|
+
omits `fee`/`deltafee` from its verbose reply anyway. The field is named in
|
|
712
|
+
`notReported` instead (rule 8).
|
|
713
|
+
|
|
714
|
+
## 23. The node started publishing getnettotals without restarting (2026-09-09)
|
|
715
|
+
|
|
716
|
+
Switched this monitor to RPC-only (`BLOCKYARD_LOG_SOURCE=0`) at 17:31Z to settle what the
|
|
717
|
+
mode actually costs on the running build. Two claims died in the next few minutes, one of
|
|
718
|
+
them mine.
|
|
719
|
+
|
|
720
|
+
### getnettotals went from 0/0 to real, mid-uptime
|
|
721
|
+
|
|
722
|
+
```
|
|
723
|
+
09:36Z getnettotals totalbytesrecv 0 totalbytessent 0 (also 0 on 09-08, repeatedly)
|
|
724
|
+
17:36Z getnettotals totalbytesrecv 23,955,131 totalbytessent 164,323
|
|
725
|
+
17:36Z+25s 23,955,307 170,419 (delta 176 B / 6,096 B)
|
|
726
|
+
17:44Z getconnectioncount 17 | getpeerinfo -> [] (still)
|
|
727
|
+
```
|
|
728
|
+
|
|
729
|
+
No restart happened in between — see the process tree below. So "does this node publish
|
|
730
|
+
byte counters?" is not only a question about **which build** (the 03:02 bench build
|
|
731
|
+
answered 11.56 MB/s against the 11.2 MB/s in its own log while the production build
|
|
732
|
+
answered 0, both reporting the same vendor subversion string); it is a question
|
|
733
|
+
about **when**, answerable only by asking again. Anything in this repo that caches the
|
|
734
|
+
answer — a flag string, a provenance row, a panel that stays empty out of habit — is a
|
|
735
|
+
staleness bug with documentation attached. Peer identity did **not** change: `getpeerinfo`
|
|
736
|
+
still answers `[]` with 17 connections, so the peer panel remains log-only on this build
|
|
737
|
+
even though bandwidth no longer is.
|
|
738
|
+
|
|
739
|
+
Practical consequence for the mode itself: RPC-only on *this* build keeps bandwidth and
|
|
740
|
+
loses per-peer bytes, per-peer identity, relay legs, the accept/reject breakdown, disk
|
|
741
|
+
writes, the worker ban count, the node's own ETA, archive holes and `sync_failing`. That
|
|
742
|
+
list is what `log-source-disabled` and `/api/net`'s `unavailable` must enumerate, and both
|
|
743
|
+
now do.
|
|
744
|
+
|
|
745
|
+
### The 16:00:49 process is a grandchild, not a restart
|
|
746
|
+
|
|
747
|
+
I reported `ps -o lstart` at 16:00:49 as evidence of a daemon restart. Wrong, and the
|
|
748
|
+
ppid chain is the correction:
|
|
749
|
+
|
|
750
|
+
```
|
|
751
|
+
2034867 ppid 1 started 08:50:56 the daemon (serve .../data)
|
|
752
|
+
2057444 ppid 2034867 started 08:52:26 forked download worker
|
|
753
|
+
241094 ppid 2057444 started 16:00:49 coinstats fold worker
|
|
754
|
+
```
|
|
755
|
+
|
|
756
|
+
A fork of a daemon inherits its command line, so `pgrep`/`ps` filtering on the command
|
|
757
|
+
line cannot tell a daemon from its children — only `ppid` can. This matters for
|
|
758
|
+
attribution: the entire post-reindex window is **one build in one process**, which is
|
|
759
|
+
exactly what makes the 0/0 → 23.9 MB observation above a statement about a process
|
|
760
|
+
rather than a comparison of builds. Any lifetime or teardown measurement that assumed a
|
|
761
|
+
restart at 16:00:49 was slicing one run in half.
|
|
762
|
+
|
|
763
|
+
### What stopped being a fabricated zero
|
|
764
|
+
|
|
765
|
+
`inBps` was computed from `getnettotals` deltas and shipped whatever came out, including
|
|
766
|
+
0 while the counter was identically 0 — a chart reading "idle node" drawn from a counter
|
|
767
|
+
that had never counted anything. The gate now mirrors the one upload already had:
|
|
768
|
+
`downloadMeasured` requires a nonzero, moving counter (or a log rate, when the tail is
|
|
769
|
+
on), and the panel shows `–` otherwise. Measured both ways: totals 0 → `inBps` null;
|
|
770
|
+
totals advancing (23,955,131 → 23,955,307) → `inBps` 7.24 B/s. The same code had been
|
|
771
|
+
printing `node log [dlc] tick lines` as `/api/net`'s provenance while the tail was closed
|
|
772
|
+
by configuration; provenance is per mode now, because a source the process refused to
|
|
773
|
+
open is not a source.
|
|
774
|
+
|
|
775
|
+
## 24. Block size was empty because we asked for a field that does not exist (2026-09-09)
|
|
776
|
+
|
|
777
|
+
Reported as "Block size never updates". It never updated because it never had a value:
|
|
778
|
+
`BLOCKSTATS_FIELDS` asked `getblockstats` for `size`, `weight` and `strippedsize`. Those
|
|
779
|
+
are `getblock` fields. `getblockstats` has never provided them, and it answers a request
|
|
780
|
+
for a statistic it lacks by **omitting it** — no error, nothing in the response, no flag.
|
|
781
|
+
Every other figure on the row (fees, txs, percentiles, utxo increase) filled in normally,
|
|
782
|
+
so the panel looked healthy apart from one permanently empty chart.
|
|
783
|
+
|
|
784
|
+
Same height, two requests, through the monitor's own read-only console (2026-09-09):
|
|
785
|
+
|
|
786
|
+
```
|
|
787
|
+
getblockstats 966253 ["size","weight",...] -> 31 keys, none of them size/weight/strippedsize
|
|
788
|
+
getblockstats 966253 [...,"total_size","total_weight","mediantxsize","swtotal_size","swtxs"]
|
|
789
|
+
-> total_size 1,579,815 total_weight 3,991,545
|
|
790
|
+
mediantxsize 221 swtotal_size 1,403,359 swtxs 4,103
|
|
791
|
+
getblockstats 966252 -> total_size 1,628,281 total_weight 3,991,846
|
|
792
|
+
24h of the size series before the fix -> 0 non-null samples in 400 buckets
|
|
793
|
+
```
|
|
794
|
+
|
|
795
|
+
`total_weight` at 3,991,545 against the 4,000,000 cap is the sanity check that the field
|
|
796
|
+
is the right one.
|
|
797
|
+
|
|
798
|
+
Three consequences kept, not just the mapping:
|
|
799
|
+
|
|
800
|
+
- **The number is labelled with its basis, in the payload.** `total_size` is the sum of
|
|
801
|
+
transaction sizes; the serialized block also carries the 80-byte header and the
|
|
802
|
+
txid-count varint. The row ships `sizeBasis` and the Chain page prints it under the
|
|
803
|
+
chart, because "size" is a name that invites a reader to assume identity.
|
|
804
|
+
- **Absence names itself.** A reply without `total_size` sets `sizeMissing`; the slot
|
|
805
|
+
says so instead of rendering an empty chart that reads as a broken panel.
|
|
806
|
+
- **The fields this node does publish were being thrown away.** `mediantxsize`,
|
|
807
|
+
`avgtxsize`, `swtotal_size` and `swtxs` were never in the request, so they were never
|
|
808
|
+
in the reply either. They are stored and ringed now.
|
|
809
|
+
|
|
810
|
+
The lesson is about the shape of the bug rather than its size: an RPC allowlist and a
|
|
811
|
+
field list are both claims about a node, and a claim answered by silence looks exactly
|
|
812
|
+
like a claim that is fine. A test now pins the list — `'size'`, `'weight'` and
|
|
813
|
+
`'strippedsize'` may not come back — because the failure mode is silence, not error.
|
|
814
|
+
Measured live after the fix: heights 966253–966255 carry 1,579,815 / 1,501,896 /
|
|
815
|
+
1,505,279 bytes.
|
|
816
|
+
|
|
817
|
+
## 25. Who mined it: coinbase attribution, and the map that names it (2026-09-09)
|
|
818
|
+
|
|
819
|
+
Cost of the two reads per block, measured through the monitor's own read-only console:
|
|
820
|
+
`getblock <hash> 1` = 259,891 bytes in 8 ms (exact `size`/`weight`/`strippedsize` plus the
|
|
821
|
+
txid list; `tx[0]` is the coinbase), `getrawtransaction <coinbase> 2` = 2,915 bytes in
|
|
822
|
+
63 ms. Two calls per block, one block per poll tick, priority 3, nothing at all while the
|
|
823
|
+
node is in initial download. The 11 MB verbosity-2 path (MEASUREMENTS 6) is never used.
|
|
824
|
+
|
|
825
|
+
Three decoding traps, each found on a real block the same afternoon:
|
|
826
|
+
|
|
827
|
+
| block | what the coinbase did | what was wrong with the obvious parser |
|
|
828
|
+
|---|---|---|
|
|
829
|
+
| 966257 | `/ViaBTC/Mined by ecgbtc/` in its own push | none — the clean case, BIP34 `71be0e` = 966257 little-endian |
|
|
830
|
+
| 966253 | `Mined by AntPool971` with the extra nonce **inside the same frame** (`...971\x15\x00"\x00\xe16{m`) | "is the frame text?" is the wrong question; the answer is the **printable prefix**, so the tag is `Mined by AntPool971` and not `null` |
|
|
831
|
+
| 966258 | `/Foundry USA Pool #dropgold/` inside a 50-byte scriptSig that declares a **47-byte push it has no room for** | the strict push walk stops with no tag, though the name is plain ASCII in the block. Fallback: scan for printable runs. The row says `tagSource: 'scan'` so the weaker method is visible in the data |
|
|
832
|
+
|
|
833
|
+
OP_PUSHDATA1/2/3 (0x4c/0x4d/0x4e) are handled: tags longer than 75 bytes use them, and
|
|
834
|
+
stopping at them silently files the block as an unknown pool. The witness commitment
|
|
835
|
+
(`fabe6d6d…`) is recovered from the unparsed tail — and when a scriptSig genuinely has no
|
|
836
|
+
commitment, as Foundry's does here, the answer is `null` rather than something recovered.
|
|
837
|
+
The test asserts both.
|
|
838
|
+
|
|
839
|
+
### Labels from mempool.space, with provenance
|
|
840
|
+
|
|
841
|
+
`node scripts/pool-map.js` writes `data/pool-map.json` from
|
|
842
|
+
`raw.githubusercontent.com/mempool/mining-pools/master/pools-v2.json` — 35,733 bytes,
|
|
843
|
+
171 pools listed, **151 with usable tags, 200 matchers**, sha256 `0491a15f88db…`. Matching
|
|
844
|
+
is a case-insensitive **literal substring** test on the whole scriptSig as text, longest
|
|
845
|
+
tag first, tags under three normalised characters dropped so a bare "pool" cannot claim a
|
|
846
|
+
block. No regexes, no similarity, no guessing.
|
|
847
|
+
|
|
848
|
+
What that produced against the live node, 23 blocks (966237–966259):
|
|
849
|
+
|
|
850
|
+
```
|
|
851
|
+
antpool 6 blk 26.1% raw tags seen: 'Mined by AntPool971', 'Mined by AntPool '
|
|
852
|
+
foundry usa 6 blk 26.1% raw tags seen: '/Foundry USA Pool #dropgold/', …#dropgold/O'
|
|
853
|
+
viabtc 3 blk 13.0% raw tags seen: '/ViaBTC/Mined by akamali2/', …
|
|
854
|
+
spiderpool 3 blk 13.0% raw tags seen: 'jSpiderPool/213/', 'jSpiderPool/561/'
|
|
855
|
+
f2pool 3 blk 13.0% raw tags seen: '/F2Pool/e', '/F2Pool/d'
|
|
856
|
+
ocean 1 blk 4.3% raw tag: '< OCEAN.XYZ >'
|
|
857
|
+
mara pool 1 blk 4.3% raw tag: 'j| MARA Made in USA '
|
|
858
|
+
```
|
|
859
|
+
|
|
860
|
+
Every grouped row keeps the distinct raw tags folded into it, and every labelled row
|
|
861
|
+
keeps the tag that matched (`matchedTag`) plus how the tag was read (`tagSource`). The
|
|
862
|
+
map's provenance travels with the payload: `labelSource = { source, sha256, fetchedAt,
|
|
863
|
+
attribution }`, so "who says this is AntPool?" is answerable from the screen. Unmatched
|
|
864
|
+
blocks stay `unknown:<fingerprint>` with their raw text shown. Refresh is manual by
|
|
865
|
+
design: the app must not need the network at runtime, and a fetch nobody asked for should
|
|
866
|
+
not be able to change which organisation the dashboard blames for a block.
|
|
867
|
+
|
|
868
|
+
Fetch trivia, because it cost a debugging turn: node's `fetch` tried every A and AAAA
|
|
869
|
+
address, all IPv6 attempts were `ENETUNREACH` (this box has no IPv6 route — the node's own
|
|
870
|
+
log says so), and the call died inside a 20 s budget while an IPv4 socket to the same host
|
|
871
|
+
connected in **367 ms** and `curl` returned 200 in 300 ms. The tool pins `family: 4` and
|
|
872
|
+
falls back to `curl` rather than depending on Happy Eyeballs behaving.
|
|
873
|
+
|
|
874
|
+
## 26. The block being built, and the ancestor graph the Mining page was wrong about (2026-09-09)
|
|
875
|
+
|
|
876
|
+
The Mining page used to state that Goggles-style cluster analysis was impossible on this
|
|
877
|
+
node, because `getrawmempool verbose` returns `vsize` and `fees.base` with no `depends` and
|
|
878
|
+
no `ancestorcount`, and there is no `zmqpubsequence`. That observation was measured; the
|
|
879
|
+
conclusion drawn from it was not. **The node does publish the ancestor graph -- from
|
|
880
|
+
`getblocktemplate`, where every selected transaction carries `depends`.**
|
|
881
|
+
|
|
882
|
+
Measured on height 966265, same day:
|
|
883
|
+
|
|
884
|
+
```
|
|
885
|
+
getblocktemplate(rules:[segwit]) 1,790,010 bytes, 1.286 s first call, 1.482 s second
|
|
886
|
+
selected transactions 1,496
|
|
887
|
+
transaction entries { data, depends, fee, hash, sigops, txid, weight }
|
|
888
|
+
weight selected 1,630,838 WU of 4,000,000 = 40.8%
|
|
889
|
+
fees selected 790,578 sat (0.00791 BTC); coinbasevalue 313,322,914 sat
|
|
890
|
+
per-tx feerate sat/vB min 0.10 median 1.60 p90 4.00 max 74.30
|
|
891
|
+
ancestor packages 1,475 total, 19 multi-transaction, 40 txs (2.7%) inside one
|
|
892
|
+
example package 3 tx: rates 38.1 / 7.6 / 0.5 sat/vB -> package feerate 10.93
|
|
893
|
+
```
|
|
894
|
+
|
|
895
|
+
That last line is the whole point of the Goggles view: the 0.5 sat/vB transaction is not
|
|
896
|
+
going to be mined for its own sake, and no per-transaction feerate says so. Folded with its
|
|
897
|
+
child, the package pays 10.93. `depends` entries are **indices into the same
|
|
898
|
+
transactions array** (measured: `'depends': [6]`), not txids -- reading them as txids
|
|
899
|
+
finds zero packages in a template that has 19, which is how a false limitation ends up
|
|
900
|
+
written into documentation and repeated by everyone who reads it.
|
|
901
|
+
|
|
902
|
+
`getmininginfo` still reports `pooledtx: 0` on this build while `getrawmempool` returns
|
|
903
|
+
thousands of entries, so the "transactions waiting to be mined" figure is taken from
|
|
904
|
+
`getrawmempool`/`getmempoolinfo`, never from `pooledtx` -- another counter that reads zero
|
|
905
|
+
and means "not published".
|
|
906
|
+
|
|
907
|
+
Cost, and the decision it forced: one `getblocktemplate` costs this node **1.3-1.5 s of its
|
|
908
|
+
single RPC thread**, on a server that serves one connection at a time. A background poll
|
|
909
|
+
would spend the node's minute on a page most viewers do not have open, so the Mining page
|
|
910
|
+
asks for the template only while it is visible, at most every 20 s, and the server shares
|
|
911
|
+
one in-flight call between concurrent viewers and serves the cached answer for 15 s after.
|
|
912
|
+
The `data` field -- the full hex of every selected transaction, and the entire reason the
|
|
913
|
+
reply is 1.79 MB -- is dropped before the summary is built and never reaches a snapshot
|
|
914
|
+
frame.
|
|
915
|
+
|
|
916
|
+
**Superseded 2026-09-13 (§26b): Bitcoin Core publishes the graph in the mempool, so the call
|
|
917
|
+
is no longer made.** The paragraph above is kept because it was measured and it was true of
|
|
918
|
+
the node it was measured on. Against Bitcoin Core on an Umbrel, `getrawmempool(true)` returns
|
|
919
|
+
per entry:
|
|
920
|
+
|
|
921
|
+
```
|
|
922
|
+
depends[] present -- 24,796 of 29,742 entries were in a package
|
|
923
|
+
ancestorcount present, with ancestorsize and fees.ancestor
|
|
924
|
+
descendantcount present, with descendantsize and fees.descendant
|
|
925
|
+
chunkweight present, with fees.chunk <- Core's own cluster-mempool linearization
|
|
926
|
+
```
|
|
927
|
+
|
|
928
|
+
So the dependency graph does not have to be bought with a 1.79 MB template call: it is in the
|
|
929
|
+
reply the monitor already reads every 20 s for the mempool view. `server/collect/gbt.js`
|
|
930
|
+
assembles the block from it -- greedy over `fees.chunk`/`chunkweight`, each transaction taken
|
|
931
|
+
with its unselected ancestors -- and returns it in the shape a `getblocktemplate` reply has,
|
|
932
|
+
so `summarizeTemplate`, `templateCells`, `packagesFromTemplate` and `blockEconomy` read it
|
|
933
|
+
unchanged.
|
|
934
|
+
|
|
935
|
+
Measured the same day, a back-to-back template and mempool pair at height 966821 (so the two
|
|
936
|
+
describe the same pool):
|
|
937
|
+
|
|
938
|
+
```
|
|
939
|
+
transactions weight fees assembly
|
|
940
|
+
ours 6,546 3,995,859 643,076 sat 52 ms
|
|
941
|
+
the node's template 6,535 3,991,951 642,860 sat ~500 ms of the NODE's thread
|
|
942
|
+
difference +11 +3,908 +216 sat (0.03%)
|
|
943
|
+
set difference 178 in theirs not ours, 189 in ours not theirs -- all at the
|
|
944
|
+
0.30 sat/vB margin, where ties are arbitrary
|
|
945
|
+
```
|
|
946
|
+
|
|
947
|
+
It is a reconstruction, not the node's answer: sigop limits and policy the mempool does not
|
|
948
|
+
publish are not modelled, so it can differ at the margin. What it costs the operator's node is
|
|
949
|
+
nothing.
|
|
950
|
+
|
|
951
|
+
One number in the paragraph above is also stale on a warm node: on 2026-09-13 the same
|
|
952
|
+
`getblocktemplate` answered in **0.5 s**, not 1.3-1.5 s, after the `dbcache`/`rpcthreads`
|
|
953
|
+
tuning in INSTALL.md. The 4.0-4.5 s figure in §TROUBLESHOOTING was an untuned Umbrel.
|
|
954
|
+
|
|
955
|
+
## 27. The node's RPC surface, re-read (2026-09-11)
|
|
956
|
+
|
|
957
|
+
29 calls against production (`127.0.0.1:8331`, cookie auth), strictly one at a time,
|
|
958
|
+
~1.5 s apart, 2026-09-11 **09:18:42–09:23:42Z**. Each was a `curl` whose `time_total` is
|
|
959
|
+
the latency column below. The running monitor kept polling throughout (see *Contention*).
|
|
960
|
+
The raw responses stayed in the session scratch and are **not** committed, because
|
|
961
|
+
`getpeerinfo` rows carry real peer addresses. The method list is committed as
|
|
962
|
+
`docs/rpc-methods-2026-09-11.txt`, the baseline §16 never kept.
|
|
963
|
+
|
|
964
|
+
### Which build answered, and RPC can now say so
|
|
965
|
+
|
|
966
|
+
```
|
|
967
|
+
09:20:05Z getnetworkinfo build commit "860b8fdd" build dirty false
|
|
968
|
+
subversion <the build's own vendor string> version 1 connections 9 (in 0 / out 9)
|
|
969
|
+
09:20:32Z uptime 10823 -> RPC up since 06:20:09Z
|
|
970
|
+
09:23:06Z getblockchaininfo blocks 966,485 == headers 966,485, initialblockdownload false, vp 1
|
|
971
|
+
```
|
|
972
|
+
|
|
973
|
+
Cross-checked off the wire. The daemon pid (started 06:18:24Z per `ps lstart`, box in
|
|
974
|
+
UTC) has `/proc/<pid>/exe` → the deployed build `deploy-20260910ag`, and its log banner
|
|
975
|
+
reads `LOG START: 2026-09-11 06:18:25 UTC … v0.0.1 built Sep 10 2026 20:04:29`. The
|
|
976
|
+
build's `live` symlink points at the same file, but its mtime was 09:24 (touched
|
|
977
|
+
*during* the survey), so it was not taken as evidence. `uptime` started 105 s after the
|
|
978
|
+
process did, the same shape as §18's ~100 s bind delay.
|
|
979
|
+
|
|
980
|
+
**This supersedes a claim three sections made.** §15 ("RPC cannot tell you which regime you
|
|
981
|
+
are in"), §18 ("the log banner remains the only way") and §23 (subversion identical across
|
|
982
|
+
builds) were true of every build before this one. On `deploy-20260910ag`, RPC attests the
|
|
983
|
+
commit and whether the tree was dirty. Two limits:
|
|
984
|
+
|
|
985
|
+
- a build that predates the field omits it, so absence means "a pre-attestation build",
|
|
986
|
+
not "commit unknown";
|
|
987
|
+
- a commit says which code, not what it publishes *now*. §23 measured a counter going
|
|
988
|
+
from 0 to real inside one process, so the commit keys a cache of capability answers
|
|
989
|
+
but does not replace re-asking.
|
|
990
|
+
|
|
991
|
+
### `help`: 165 methods, a flat list, and no usage text
|
|
992
|
+
|
|
993
|
+
| call | time | bytes | result |
|
|
994
|
+
|---|---|---|---|
|
|
995
|
+
| `help` | 1.503 s (contended, below) | 3,028 | `== Methods served by this node (165) ==`, one alphabetical list, **no category headings**; the header says it "is generated from the dispatch tables, so it is exactly what will be answered" |
|
|
996
|
+
| `help <m>`, 11 methods, 09:21:05–09:21:24Z | 0.17–0.25 ms (one outlier: 4.42 s) | 283–298 | the same template for every method: "This node serves `<m>`, but does not carry Bitcoin Core's per-method usage text. Consult Core's own `help <m>` …" |
|
|
997
|
+
|
|
998
|
+
§16 counted **171** on the 09-08 builds and did not save the list, so the six methods
|
|
999
|
+
missing now **cannot be named**. That is why the list is committed this time. The next
|
|
1000
|
+
count should be a diff of that file.
|
|
1001
|
+
|
|
1002
|
+
The per-method text is also **cut at 255 characters**. 9 of the 11 sampled replies end
|
|
1003
|
+
mid-word (`…recorded in docs/RP`, `…docs/RPC_L`). Only the two shortest names
|
|
1004
|
+
(`addhdkey` 240 chars, `getorphantxs` 252) arrive whole. Either way `help` says nothing
|
|
1005
|
+
about arguments or side effects. **Every read-only judgement in the table below came from
|
|
1006
|
+
the node's source** (`asm/rpc_node.c`, `asm/rpc_chain.c`), not from `help`.
|
|
1007
|
+
|
|
1008
|
+
### New and non-standard methods
|
|
1009
|
+
|
|
1010
|
+
| method | read-only? (from source) | params | latency | shape |
|
|
1011
|
+
|---|---|---|---|---|
|
|
1012
|
+
| the build's own download-info method | yes: reads the shared-memory `node_status_t` the download publishes; no Core counterpart | none | **0.175 ms** (09:22:08Z) | measured idle: `{"active":false,"bytes_total":0}`, 71 B. **The download-time shape is read from source and was NOT measured on the wire** (production was synced): `workers, pool_idle_pct, pool, banned, free_peers, window, first_hole, claim, applied, end_height, staged, stall_timeout_s, stall_evictions, median_bps, bytes_total`, plus `peers[]` of `{worker, addr, subver, services, startingheight, conntime, bytes_recv, bps_recv, idle_pct, inflight_lo, inflight_hi}` |
|
|
1013
|
+
| `getorphantxs` | yes: a compact snapshot the worker shares | verbosity `0` or `1`; `2` (hex) is refused `-8` by source | v0 **0.253 ms**, 375 B; v1 **0.200 ms**, 622 B | v0: 5 txids. v1: `{txid, bytes, parents, age_ms}`; 5 orphans, `bytes` 3,071 ×4 and 195, `parents` 1 each, `age_ms` 97,934–217,047. Core fields absent by source: `wtxid`, `vsize`, `weight`, `expiration`, `from`. `parents` (inputs still missing) is this node's own |
|
|
1014
|
+
| `getprivatebroadcastinfo` | yes (its sibling `abortprivatebroadcast` is a control op, not invoked) | none | 0.187 ms | HTTP 404, `-32601` "Private broadcast is not enabled. Ensure you're running Bitcoin Core with -privatebroadcast=1": the option is off on production |
|
|
1015
|
+
| `getaddressbalance` / `getaddresstxids` | read, from the address-index journal; Core has no such methods | address, array, or `{"addresses":[…]}` | **not invoked** | by source: answers only with `addrindex=1`; `getaddresstxids` returns up to 100,000 txids. A monitor has no address to ask about |
|
|
1016
|
+
| `getdescriptoractivity` | read but **heavy**: walks blocks directly; `DENY_EXACT` in `server/rpc/allowlist.js` | blockhashes, scanobjects | **not invoked** | — |
|
|
1017
|
+
| `getmempoolcluster` | refuses by source: "this node's mempool has no cluster linearization…" | — | not invoked (help only); already in §14 | — |
|
|
1018
|
+
|
|
1019
|
+
`simulaterawtransaction`, `addhdkey` and `exportwatchonlywallet` were only
|
|
1020
|
+
`help`-sampled. Nothing is recorded about them beyond the boilerplate.
|
|
1021
|
+
|
|
1022
|
+
### Gap statuses on this build
|
|
1023
|
+
|
|
1024
|
+
| gap | before | deploy-20260910ag, 2026-09-11 |
|
|
1025
|
+
|---|---|---|
|
|
1026
|
+
| per-peer **bytes** | `[]` on deploy-20260907a (§3) and on the 2026-09-09 process (§23); 4 rows for 6 connections on deploy-20260908a (§18) | **closed.** 09:22:05Z `getpeerinfo` 9 rows; `getconnectioncount` 9 at 09:22:14Z. Row sums **109,027,561** recv / **34,804,574** sent against `getnettotals` **109,036,122** / **34,807,044** at 09:22:07Z = **99.992%** / 99.993%, 1.5 s apart. 09:23:36Z: 8 rows, 90,137,131 vs 90,139,443 (99.997%) |
|
|
1027
|
+
| per-peer **relay counts** | absent (DEFECTS) | still absent. Row keys: `id, addr, services, servicesnames, relaytxes, lastsend, lastrecv, bytessent, bytesrecv, conntime, timeoffset, version, subver, inbound, permissions, startingheight, synced_headers, synced_blocks, network`. `relaytxes` is a boolean (true on all 9), not a count |
|
|
1028
|
+
| `getnettotals` as a lifetime counter | assumed | **no: it falls when a peer leaves** (next subsection) |
|
|
1029
|
+
| ancestor fields in `getrawmempool true` | absent (§5) | still absent: 09:23:08Z, **19,014** entries, 2,641,932 B, **0.144 s**, keys `vsize, weight, time, fees{base}` only |
|
|
1030
|
+
| … in `getmempoolentry` | not recorded | **present**, 0.69 ms, 522 B: `vsize, weight, time, height, descendantcount, descendantsize, ancestorcount, ancestorsize, wtxid, fees{base, modified, ancestor, descendant}, depends, spentby, unbroadcast` (sampled tx: `ancestorcount` 18). One call per txid, so a full sweep is ≥19,014 × 0.69 ms ≈ 13 s of the single RPC thread: a drill-down, not a poll. §26's `getblocktemplate` was the cheap ancestor graph on that node; **on Core the graph is in `getrawmempool(true)` itself and costs nothing extra — see §26b** |
|
|
1031
|
+
| ZMQ | `zmqpubsequence` refused (DEFECTS) | `getzmqnotifications` → `[]`, 0.180 ms. No mempool sequence feed. **2026-09-13, Bitcoin Core/Umbrel: all five publishers present** — `pubsequence` tcp://0.0.0.0:28335, `pubrawblock` 28332, `pubrawtx` 28333, `pubhashblock` 28334, `pubhashtx` 28336, hwm 1000 each. Not reachable from this host, though: all five refuse while 8332 is open, because bitcoind binds them inside its container and Umbrel publishes only the RPC port |
|
|
1032
|
+
| log over RPC | no `getlogevents` (§16) | still none: the only log-named method in the 165 is `logging`, which `allowlist.js` denies |
|
|
1033
|
+
|
|
1034
|
+
### `getnettotals` is a sum over live peers, not a lifetime counter
|
|
1035
|
+
|
|
1036
|
+
| time (Z) | call | recv | sent |
|
|
1037
|
+
|---|---|---|---|
|
|
1038
|
+
| 09:22:05.667 | `getpeerinfo`, 9 rows, summed | 109,027,561 | 34,804,574 |
|
|
1039
|
+
| 09:22:07.189 | `getnettotals` | **109,036,122** | 34,807,044 |
|
|
1040
|
+
| 09:22:08.709 | the download-info method | `bytes_total` 0 | — |
|
|
1041
|
+
| 09:23:03.804 | `getnettotals` | **89,979,098** | 30,012,780 |
|
|
1042
|
+
| 09:23:36.938 | `getpeerinfo`, **8** rows, summed | 90,137,131 | 30,063,796 |
|
|
1043
|
+
| 09:23:38.456 (served 09:23:41.372) | `getnettotals` | 90,139,443 | 30,066,175 |
|
|
1044
|
+
|
|
1045
|
+
The counter fell **19,057,024** bytes received (4,794,264 sent) in 56.6 s. The row missing
|
|
1046
|
+
at 09:23:36 (`id` 15) held **19,303,469** / 4,868,658 at 09:22:05. The difference,
|
|
1047
|
+
246,445 / 74,394, is what the surviving peers moved in between. The download term was 0
|
|
1048
|
+
(the download-info method reported inactive), so the drop is exactly one departed peer.
|
|
1049
|
+
|
|
1050
|
+
The node's source says so in so many words. `cmd_getnettotals` sums the `used` slots of
|
|
1051
|
+
the live peer table plus `dl_bytes_total`, and comments: *"Core counts bytes for the
|
|
1052
|
+
process lifetime including closed peers; we sum the LIVE peer table plus everything the
|
|
1053
|
+
download received this run."* So it is a documented divergence from Core, not a bug in
|
|
1054
|
+
the node. Two consequences:
|
|
1055
|
+
|
|
1056
|
+
- **The 99.99% above is not coverage.** Rows and total agree because both forget closed
|
|
1057
|
+
peers. §15's `peerinfo-partial` (70.29% on the bench build) passes on this build for
|
|
1058
|
+
that reason, not because nothing is lost.
|
|
1059
|
+
- **A delta across a disconnect is negative**: −336,613 B/s over 09:22:07→09:23:03. The
|
|
1060
|
+
monitor's `CounterRate` reads any decrease as a node restart and publishes `0`. That
|
|
1061
|
+
is an open defect, recorded in DEFECTS under Security / correctness. The next clean
|
|
1062
|
+
interval, 09:23:03→09:23:41, read 4,268 B/s received.
|
|
1063
|
+
|
|
1064
|
+
### Contention: three sub-millisecond calls took seconds
|
|
1065
|
+
|
|
1066
|
+
| call | time | same method elsewhere in the run |
|
|
1067
|
+
|---|---|---|
|
|
1068
|
+
| `help` (09:18:42Z) | **1.503 s** | — (first call of the run) |
|
|
1069
|
+
| `help getprivatebroadcastinfo` (09:21:09Z) | **4.421 s** | the other 10 `help <m>`: 0.167–0.251 ms (two at 5 and 11 ms, one at 87 ms) |
|
|
1070
|
+
| `getnettotals` (09:23:38Z) | **2.914 s** | 0.174 and 0.259 ms |
|
|
1071
|
+
|
|
1072
|
+
The third call carries its own evidence. Its `timemillis` is **09:23:41.372Z**, 2.916 s
|
|
1073
|
+
after curl started, and the reply arrived at 09:23:41.370Z by curl's clock. The node built
|
|
1074
|
+
the answer at the end of the 2.9 s, not the start, so the time was spent **waiting to be
|
|
1075
|
+
served**, not computing. The likely cause is queueing behind the running monitor's own
|
|
1076
|
+
tiers on the single RPC thread (§1): the fast tier batches five methods every 4 s, and
|
|
1077
|
+
§26's `getblocktemplate` holds the thread 1.3–1.5 s (no longer called — §26b). **Unconfirmed**: the monitor's lane
|
|
1078
|
+
telemetry for those seconds was not read. The general point stands. A latency measured
|
|
1079
|
+
from this box while the monitor runs is an upper bound, and a sub-ms method's outlier is
|
|
1080
|
+
somebody else's call.
|
|
1081
|
+
|
|
1082
|
+
### Not wired, as of this survey
|
|
1083
|
+
|
|
1084
|
+
`grep` over `server/`, 2026-09-11: nothing calls the download-info method or
|
|
1085
|
+
`getorphantxs`, and nothing reads the build's commit/dirty attestation fields. The only
|
|
1086
|
+
`server/` mention is the allowlist comment that admits the download-info method to the
|
|
1087
|
+
read-only console via its vendor method-name prefix. What each could supply is listed in
|
|
1088
|
+
DEFECTS (Functional gaps) as an
|
|
1089
|
+
opportunity, not a feature.
|
|
1090
|
+
|
|
1091
|
+
## 28. What an address index over the chain costs, read from the node's own files (2026-09-14)
|
|
1092
|
+
|
|
1093
|
+
`scripts/blockfile-measure.js --sample 16 --verify` on the local Core datadir: 16 of 5,756
|
|
1094
|
+
`blk`/`rev` file pairs, evenly spaced from file 0 to 5754, each decoded end to end with
|
|
1095
|
+
`server/chain/tx.js` and `server/chain/blockfile.js`, one block per file checked against
|
|
1096
|
+
`getblock <hash> 3` (every prevout: value, script, height, coinbase flag) — **0 mismatches**
|
|
1097
|
+
in 91,813 spent coins. Totals are interpolated between the samples, because a file's
|
|
1098
|
+
contents change enormously over the chain's history (file 0 holds 119,960 blocks; file 5754
|
|
1099
|
+
holds 84).
|
|
1100
|
+
|
|
1101
|
+
**Calibrated against the node, not only extrapolated.** Interpolated transactions:
|
|
1102
|
+
1,448,817,382; `getchaintxstats` at height 966,921: **1,438,794,396** — 0.7% high (the blk
|
|
1103
|
+
files also carry stale blocks). The unspent-output cross-check is looser and says so:
|
|
1104
|
+
funding rows minus spending rows is 121.3 M against `gettxoutsetinfo` txouts **165.2 M**.
|
|
1105
|
+
That is a small difference of two ~3.5 B figures, each within a couple of percent, so treat
|
|
1106
|
+
row totals as ±2% and the difference between them as not measured by this method.
|
|
1107
|
+
|
|
1108
|
+
| | Whole chain (interpolated) |
|
|
1109
|
+
|---|---|
|
|
1110
|
+
| block files / undo files | 768.3 GB / 108.8 GB |
|
|
1111
|
+
| transactions | 1.449 B |
|
|
1112
|
+
| outputs / funding rows (spendable) | 3.823 B / 3.627 B |
|
|
1113
|
+
| inputs = spending rows | 3.506 B |
|
|
1114
|
+
|
|
1115
|
+
Single-core time, by stage:
|
|
1116
|
+
|
|
1117
|
+
| stage | hours | note |
|
|
1118
|
+
|---|---|---|
|
|
1119
|
+
| read | 0.62 | **warm cache for some files** (file 0 and 5754 read in ~40 ms); cold device read is the 0.7 h of the earlier spike (DEFECTS) |
|
|
1120
|
+
| XOR | 0.23 | |
|
|
1121
|
+
| decode blocks, addresses included | 5.87 | the dominant cost |
|
|
1122
|
+
| pair blocks with undo | 0.34 | after the fix below |
|
|
1123
|
+
| decode undo, addresses included | 3.06 | early files cost most: uncompressed-key P2PK coins are rebuilt with BigInt modular arithmetic |
|
|
1124
|
+
| **CPU total** | **9.5** | per core; files are independent, and this box has 32 cores |
|
|
1125
|
+
|
|
1126
|
+
Raw index rows, before any storage engine's overhead:
|
|
1127
|
+
**85.6 GB** history only (12 B per row: an 8-byte script-hash or outpoint prefix and a 4-byte
|
|
1128
|
+
height) and **142.7 GB** with an 8-byte amount on every row, so a balance needs no node call.
|
|
1129
|
+
Free space on `/storage` at the time: 653 GB.
|
|
1130
|
+
|
|
1131
|
+
**The pairing bug this run found.** Pairing each block with its undo record by trying every
|
|
1132
|
+
candidate of the same transaction count is quadratic where blocks are tiny: file 0 ran 35
|
|
1133
|
+
minutes without finishing, and a second attempt (one hash per distinct record) still took
|
|
1134
|
+
230 s. Core appends undo records in connection order, so `pairBlocksWithUndo` puts each
|
|
1135
|
+
file's blocks in chain order by their previous-block links and walks the records in step,
|
|
1136
|
+
checksum-verified: file 0 pairs in 5.9 s, a recent file in ~15 ms, and every block but
|
|
1137
|
+
genesis (which has no undo) pairs.
|
|
1138
|
+
|
|
1139
|
+
## 29. Storing the address index: SQLite against sorted flat files (2026-09-14)
|
|
1140
|
+
|
|
1141
|
+
`node --no-warnings scripts/index-bench.js --files 384,2685,5370` builds real index rows from three
|
|
1142
|
+
file pairs (2015, 2021 and 2026 — 436 blocks, 695,639 transactions) and stores them two ways.
|
|
1143
|
+
|
|
1144
|
+
**The row.** One per (address script, transaction that touched it): an output paying the script, or
|
|
1145
|
+
an input spending an output that paid it, the spent script taken from the undo file. Key: the first
|
|
1146
|
+
8 bytes of sha256(script), block height, position in the block. Value: the net satoshis that
|
|
1147
|
+
transaction moved for the script. The transaction itself stays in the node (`txindex`). A script
|
|
1148
|
+
paid and spent in the same transaction is one row, which removes **18.3%** of raw rows here
|
|
1149
|
+
(21.6% on the 2026 file alone): 3,827,812 raw rows became **3,128,638**, 4.50 per transaction.
|
|
1150
|
+
|
|
1151
|
+
| | SQLite (`node:sqlite`, WITHOUT ROWID) | sorted flat file |
|
|
1152
|
+
|---|---|---|
|
|
1153
|
+
| bytes per row | 26.5 as inserted; 24.1 after VACUUM; 27.4 bulk-loaded in key order | **21.0** with amounts; **13.0** history only (+ a sparse index of one key per 4,096 rows) |
|
|
1154
|
+
| build rate | 418 k rows/s in arrival order; **1.40 M rows/s** in key order | sort **5.53 M rows/s**, write **2.99 M rows/s** |
|
|
1155
|
+
| lookup (warm, 20,000 real scripts) | **0.0027 ms** | 0.057 ms (one 86 KB read per lookup) |
|
|
1156
|
+
|
|
1157
|
+
**What these numbers do not show.** The whole sample fits in memory, so every figure is the
|
|
1158
|
+
in-memory regime. At full size the arrival-order SQLite rate will not hold: a B-tree fed random
|
|
1159
|
+
keys past RAM pays a disk seek per insert. That collapse was not measured and nothing here should
|
|
1160
|
+
be read as its rate. Key-order loading avoids it, but requires sorting every row first -- the same
|
|
1161
|
+
sort the flat file needs.
|
|
1162
|
+
|
|
1163
|
+
**Extrapolated to the chain** (MEASUREMENTS 28: 7.13 B raw rows, less the 18.3% merged here ≈
|
|
1164
|
+
**5.8 B rows**; the merge share varies by era, so ±10%):
|
|
1165
|
+
|
|
1166
|
+
| design | size |
|
|
1167
|
+
|---|---|
|
|
1168
|
+
| flat, history only (13 B) | ≈ 76 GB |
|
|
1169
|
+
| flat, with amounts (21 B) | ≈ 122 GB |
|
|
1170
|
+
| SQLite, with amounts (24–27 B) | ≈ 140–160 GB, before any second index for reorg deletes by height |
|
|
1171
|
+
|
|
1172
|
+
A key-order SQLite load of 5.8 B rows at 1.40 M rows/s is ≈ 69 minutes on one thread; the flat
|
|
1173
|
+
file's sort is ≈ 18 minutes of single-thread CPU if bucketed by hash prefix so each bucket sorts in
|
|
1174
|
+
memory. Both sit on top of the 9.5 single-core hours of decoding (§28), which splits across cores
|
|
1175
|
+
because files are independent.
|
|
1176
|
+
|
|
1177
|
+
## 30. The address index, built and compared (2026-09-14)
|
|
1178
|
+
|
|
1179
|
+
`node scripts/index-build.js --out ~/blockyard-index --workers 16` read every blk/rev pair from the
|
|
1180
|
+
local node's `/storage` NVMe and wrote to a separate NVMe (`/`). `node scripts/index-benchmark.js`
|
|
1181
|
+
then measured it against the node.
|
|
1182
|
+
|
|
1183
|
+
**Build.** 29 min 45 s wall, 16 workers (1,633% CPU; 7.8 CPU-hours; peak RSS 30 GB):
|
|
1184
|
+
|
|
1185
|
+
| phase | time |
|
|
1186
|
+
|---|---|
|
|
1187
|
+
| block hashes 0..966,930 (`getblockhash`, batches of 5,000) | 49 s |
|
|
1188
|
+
| scan 5,756 file pairs (lean rows, not the full decoder) | 25 min 45 s |
|
|
1189
|
+
| sort 256 buckets | 3 min 10 s |
|
|
1190
|
+
|
|
1191
|
+
5,890,519,289 rows, **123.7 GB**, every height 0..966,930 present exactly once, 2 stale blocks
|
|
1192
|
+
skipped, 0 missing undo records, 0 duplicate rows. §29 projected ≈5.8 B rows and ≈122 GB: both
|
|
1193
|
+
within 1.5%. The first attempt failed on its last file, the one the node is still writing: Core
|
|
1194
|
+
preallocates it as raw zeros, which read as the XOR key once de-obfuscated (`records` now takes the key).
|
|
1195
|
+
|
|
1196
|
+
**Correct.** 40 of 40 addresses (the heavy ones below except the genesis address, and a sample from
|
|
1197
|
+
across the chain) have an index balance equal, to the satoshi, to `scantxoutset` at the same height.
|
|
1198
|
+
Before that, every (script, transaction) pair of four whole blocks from 2009 to the tip was found
|
|
1199
|
+
at its height and position with its net amount (21,351 pairs, a 5-file test build). The genesis
|
|
1200
|
+
address is excluded from the balance check on purpose: its first 50 BTC is the genesis coinbase,
|
|
1201
|
+
which Core never added to the UTXO set; the index counts it as received, as explorers do.
|
|
1202
|
+
|
|
1203
|
+
**Lookups** (344 distinct addresses sampled from 24 blocks spread over the chain; median history 14
|
|
1204
|
+
transactions, largest 3,195,827):
|
|
1205
|
+
|
|
1206
|
+
| | p50 | p90 | p99 | max |
|
|
1207
|
+
|---|---|---|---|---|
|
|
1208
|
+
| first lookup | 0.248 ms | 1.108 ms | 28.3 ms | 119 ms |
|
|
1209
|
+
| repeated (warm) | 0.028 ms | — | 25.0 ms | — |
|
|
1210
|
+
|
|
1211
|
+
Heavy addresses, whole history summed for the balance: 2,326,967 transactions in 82.9 ms; 65,786 in
|
|
1212
|
+
2.5 ms; 5,583 in 0.5 ms. Opening the index (the sparse keys into memory) takes 84 ms.
|
|
1213
|
+
|
|
1214
|
+
**Against the alternatives.**
|
|
1215
|
+
|
|
1216
|
+
| method | what it answers | cost |
|
|
1217
|
+
|---|---|---|
|
|
1218
|
+
| this index | full history, balance, per-transaction amounts | 0.25 ms p50 lookup; 124 GB; 30 min build on 16 cores |
|
|
1219
|
+
| `scantxoutset` (Core, no index) | current balance / UTXOs only, no history | **26.5 s** for one scan of 40 addresses, holding the node's RPC thread |
|
|
1220
|
+
| `getaddresstxids` (insight-style) | — | refused by Core at every setting (DEFECTS) |
|
|
1221
|
+
| SQLite, same rows (§29, in memory only) | same | 24–27 B/row ≈ 140–160 GB; 1.4 M rows/s sorted load |
|
|
1222
|
+
| romanz/electrs (published) | history, no amounts | ≈2 h on 6 cores; 56 GB |
|
|
1223
|
+
| mempool/electrs, what mempool.space runs (published) | history, amounts, full tx store | "a few hours"; 1.3 TB |
|
|
1224
|
+
|
|
1225
|
+
The published figures are the projects' own READMEs, on other hardware; they are context, not a
|
|
1226
|
+
race run on this box.
|
|
1227
|
+
|
|
1228
|
+
## 31. The first fresh install, and a sync false alarm (2026-09-14)
|
|
1229
|
+
|
|
1230
|
+
Figures from the first install on a machine that is not this one -- a Mac, Core 29.1, block files on
|
|
1231
|
+
a platter array -- as recorded in the day's commits; not re-run here, since the machine is the
|
|
1232
|
+
operator's. The descriptor figures were taken on this box.
|
|
1233
|
+
|
|
1234
|
+
**A node without `coinstatsindex`, sent `gettxoutsetinfo` every minute** (`6bf0ea5`): RPC answers of
|
|
1235
|
+
18 s on the monitor's lane, 90 s timeouts, the verbose mempool read dropped, the block-space board
|
|
1236
|
+
empty; `getindexinfo` reported no coinstats index and the UTXO set held 165 M outputs. The same node
|
|
1237
|
+
measured alone by `npm run check` (`ab2c5dc`): `getblockchaininfo` 10 ms, `getblock <tip> 3` 921 ms,
|
|
1238
|
+
`getrawmempool true` 1.0 s. The index build running beside it was throttled four ways first
|
|
1239
|
+
(`4a2bb49`, `89c738e`, `02b35e5`, `26a9433`) and was not the cause; the pacer's first threshold of
|
|
1240
|
+
1 s then ran a healthy build at about a sixth of its speed (`0b87d2e`), so it holds at
|
|
1241
|
+
`rpc.slowLatencyMs` (5 s) and eases above 40% of that now (RULES 27).
|
|
1242
|
+
|
|
1243
|
+
**Descriptors** (`a05f1c6`): the index store held a descriptor per segment and layer (256 and more)
|
|
1244
|
+
and the build all 256 bucket files, on a platform whose soft limit is 256. After opening per read, on
|
|
1245
|
+
the full index on this box: 0.02 ms median warm lookup, 0.28 ms p90, 21 descriptors held by the
|
|
1246
|
+
process; the build keeps at most 64 buckets open.
|
|
1247
|
+
|
|
1248
|
+
**The installer at 80 columns** (`ce54ae8`): the whole run piped at 80 columns, widest line 79.
|
|
1249
|
+
|
|
1250
|
+
**Sync** (`72908f0`): two independent nodes at the same height, no block for 42 minutes, both
|
|
1251
|
+
showing STALLED. Block intervals are close to exponential with a 10-minute mean, so a gap of 40
|
|
1252
|
+
minutes or more has probability e^-4, about 1.8% -- once in fifty blocks, a few times a day. Stalled
|
|
1253
|
+
now means a connected peer reports a higher tip (`getpeerinfo` `synced_headers`); peers agreeing on
|
|
1254
|
+
the tip is a long gap and synced; no peer height at all waits two hours.
|
|
1255
|
+
|
|
1256
|
+
## 32. The DOOM Diversion's emulated PC (2026-09-15)
|
|
1257
|
+
|
|
1258
|
+
Not the node: the i386 and the PC in `public/js/x86.js` and `dospc.js` running the shareware
|
|
1259
|
+
`DOOM.EXE` v1.9 (the Diversion). Taken on this box -- AMD Ryzen 9 9950X3D, Node v22.23.2, Chromium
|
|
1260
|
+
152 (snap, headless). Reproduce the headless figures with `node scripts/dos-bench.js doom`.
|
|
1261
|
+
|
|
1262
|
+
**Speed of the interpreter**, 400 M instructions of DOOM's title and demos on a clock of 30 M
|
|
1263
|
+
instructions to the virtual second:
|
|
1264
|
+
|
|
1265
|
+
| build | instructions a second |
|
|
1266
|
+
|---|---|
|
|
1267
|
+
| first cut: unsigned values (`>>> 0`), flag operands in closure variables, a try/catch per instruction | 66-68 M |
|
|
1268
|
+
| every value an int32, lazy flags in an `Int32Array`, one try/catch around the loop, 32-bit fast paths | 95 M |
|
|
1269
|
+
| the same with the Sound Blaster and OPL3 attached, synthesising at 44.1 kHz per call of `tick` | 73 M |
|
|
1270
|
+
| the OPL's per-register work hoisted out of the sample loop | 82 M |
|
|
1271
|
+
| `tick` working in batches of at least 128 frames (it had been called every 2,000 instructions: a sample or two each) | **92 M** |
|
|
1272
|
+
|
|
1273
|
+
The try/catch alone was 14% of the profile, and the collector 1.5% from doubles boxed in closure
|
|
1274
|
+
variables; the rewrite was checked instruction by instruction against the first cut for 40 M
|
|
1275
|
+
instructions (identical except the start-up's environment read, which the first cut got wrong).
|
|
1276
|
+
|
|
1277
|
+
**In the browser**, the machine in a worker, sound on: **94-110 M instructions a second, 34-37
|
|
1278
|
+
frames a second** -- DOOM's own cap is 35. The game needs about a million instructions a frame of
|
|
1279
|
+
real work; the rest of each tic it spends in its own busy wait for the timer. The page's CSP forbids
|
|
1280
|
+
eval, so a JIT was never an option; a slower machine than this one has roughly a factor of two in
|
|
1281
|
+
hand before DOOM drops below 35.
|
|
1282
|
+
|
|
1283
|
+
**Start-up**: graphics mode after 8.0 M instructions without a sound card, 17.0 M with one (the
|
|
1284
|
+
DMX driver probes the DSP and the OPL); 20 pages flipped by 31 M.
|
|
1285
|
+
|
|
1286
|
+
**Correctness of the CPU, measured against the host's own**: a differential fuzzer ran random
|
|
1287
|
+
instructions natively (a C harness in 64-bit mode, 32-bit operands, register forms) and in `x86.js`,
|
|
1288
|
+
comparing all registers and every flag the instruction defines -- ALU rows, immediates, shifts and
|
|
1289
|
+
rotates with counts past the width, MUL/IMUL/DIV/IDIV including divide faults, BT/BTS/BTR/BTC,
|
|
1290
|
+
BSF/BSR, SHLD/SHRD, MOVZX/MOVSX, SETcc, BSWAP, XADD, CMPXCHG, SAHF/LAHF. Two seeds, 78,565
|
|
1291
|
+
instructions, 2,338 divide faults agreed, **zero mismatches**. The harness needs a C compiler, so it
|
|
1292
|
+
is not in `npm test`; `test/x86.test.js` keeps a case from each class it covered.
|
|
1293
|
+
|
|
1294
|
+
**The music is in tune**: over 10 s of the title music, 178 notes keyed on, 96 of them within
|
|
1295
|
+
2.5 cents of equal temperament; the rest spread to +-50 cents, which is DMX's pitch bends.
|
|
1296
|
+
|
|
1297
|
+
## 33. Quake on the same PC (2026-09-15)
|
|
1298
|
+
|
|
1299
|
+
`QUAKE.EXE` v1.06 (DJGPP, go32 stub) on the emulated PC, this box, Node v22.23.2. Reproduce with
|
|
1300
|
+
`node scripts/dos-bench.js quake`.
|
|
1301
|
+
|
|
1302
|
+
**Speed**: 77 M instructions a second headless with the Sound Blaster attached, 72-79 in a Chromium
|
|
1303
|
+
worker -- lower than DOOM's 90-105 because Quake's code is FPU-heavy (the x87 was 15% of the profile)
|
|
1304
|
+
and every memory operand adds a segment base (DJGPP's DS is at its memory block; `ea` was 13%).
|
|
1305
|
+
Moving the FPU stack to a Float64Array with typed-array operand conversion and one base addition
|
|
1306
|
+
when DS and SS share it took 74 to 76: the interpreter's dispatch is the rest.
|
|
1307
|
+
|
|
1308
|
+
**Frame rate**: `+timedemo demo1` on a clock of 74 M instructions to the virtual second -- the
|
|
1309
|
+
emulator's own speed -- reported **969 frames in 33.5 seconds, 28.9 fps**, about 2.6 M instructions a
|
|
1310
|
+
frame. In the browser, a new game on the start map drew 26 frames a second. Period hardware for
|
|
1311
|
+
comparison: a Pentium 90 ran the same demo at 320x200 at roughly that rate.
|
|
1312
|
+
|
|
1313
|
+
**Start-up**: graphics mode after 244 M instructions on a 30 M clock (Quake pages its 27 MB heap in
|
|
1314
|
+
and times its hardware), twenty screens drawn by 285 M; 2.8 s of wall time headless.
|
|
1315
|
+
|
|
1316
|
+
## 34. The decoded-instruction cache, and Quake's view size (2026-09-15)
|
|
1317
|
+
|
|
1318
|
+
This box, Node v22.23.2, `node scripts/dos-bench.js doom 400` / `quake 1500` (M instructions a
|
|
1319
|
+
second, headless, Sound Blaster attached):
|
|
1320
|
+
|
|
1321
|
+
| build | DOOM | Quake |
|
|
1322
|
+
|---|---|---|
|
|
1323
|
+
| before (the interpreter, §32-33) | 90 | 77 |
|
|
1324
|
+
| a closure per decoded instruction | 68 | 69 |
|
|
1325
|
+
| int32 decodings in a typed array per page, one switch; a write drops the whole page | 83 | 90 |
|
|
1326
|
+
| ...a write clears only the instructions it overlaps (DOOM patches its span drawer each call) | 100 | 90 |
|
|
1327
|
+
| ...the FPU decoded too, and the loop keeps the current page between instructions | 107 | 104 |
|
|
1328
|
+
| ...no page check after a cached handler | **111** | **108** |
|
|
1329
|
+
|
|
1330
|
+
The page-drop row is the one to remember: 217,660 whole-page drops in 300 M instructions of DOOM,
|
|
1331
|
+
every one from `mov [eax],ebx` into the constants of its own span routine at 0x12bdaf.
|
|
1332
|
+
|
|
1333
|
+
**Quake's view size**, `+viewsize N +timedemo demo1` (969 frames), on a clock set to the emulator's
|
|
1334
|
+
speed:
|
|
1335
|
+
|
|
1336
|
+
| viewsize | before the cache (76 M clock) | after (105 M clock) |
|
|
1337
|
+
|---|---|---|
|
|
1338
|
+
| 100 | 29.7 fps | 40.9 fps |
|
|
1339
|
+
| 80 (the new default) | 32.5 | 44.7 |
|
|
1340
|
+
| 60 | 40.5 | -- |
|
|
1341
|
+
|
|
1342
|
+
In a Chromium worker: Quake 99-106 MIPS and 40-42 frames a second in a new game at `viewsize 80`
|
|
1343
|
+
(26 before), DOOM 133 MIPS at its 35 fps cap.
|
|
1344
|
+
|
|
1345
|
+
**Checked, not assumed**: both games lock-stepped against the uncached interpreter (DOOM 400 M, Quake
|
|
1346
|
+
1.5 G instructions; registers and flags compared every 10,000; memory identical at the end), and the
|
|
1347
|
+
native fuzzer re-run through `run(1)` over 118k instructions with no mismatch.
|
|
1348
|
+
|
|
1349
|
+
## 35. A second pass on the CPU: what paid and what did not (2026-09-15)
|
|
1350
|
+
|
|
1351
|
+
Asked for all five of: split the ALU routine, specialise the hot x87 forms, cheaper dispatch, dead
|
|
1352
|
+
flags, and a block copy of the frame into video memory. **Method, after the first readings misled:**
|
|
1353
|
+
each build against a copy of the previous one (`oracle4`), pinned to one core with `taskset`, best of
|
|
1354
|
+
three; unpinned runs on this shared box moved +-5% between identical runs. Quake throughput as M
|
|
1355
|
+
instructions a second over its timedemo, and as **frames a second of wall time** over 600 timedemo
|
|
1356
|
+
frames -- the second catches work a MIPS figure cannot, such as a `rep movsd` that is one instruction
|
|
1357
|
+
however many bytes it moves. DOOM on `-timedemo demo1` only: its normal MIPS depends on how much time
|
|
1358
|
+
lands in its cheap wait loop, which a change to the machine's slice size alone moved by 5%.
|
|
1359
|
+
|
|
1360
|
+
| build | Quake MIPS | Quake fps (wall) | DOOM timedemo MIPS |
|
|
1361
|
+
|---|---|---|---|
|
|
1362
|
+
| before (the cache, §34) | 101 | 37.7 | 103 |
|
|
1363
|
+
| ALU split per operation + hot x87 forms decoded to their own handlers | 133 | -- | -- |
|
|
1364
|
+
| + fused cmp/test+Jcc dispatch and "no flags" forms by flag liveness | 131 | -- | 110 |
|
|
1365
|
+
| the same without the look-ahead (fusion and no-flags off) | 133 | -- | 114.5 |
|
|
1366
|
+
| + the address formed inline in the loop instead of a call per handler | 141.5 | -- | 112 |
|
|
1367
|
+
| + aligned reads/writes inline in the hottest moves | 141 | 52.5 | 115 |
|
|
1368
|
+
| the same without the VGA block copy | 141 | 51 (noise) | -- |
|
|
1369
|
+
|
|
1370
|
+
**Kept**: the ALU split, the x87 handlers, the inline address and moves -- Quake's frames a second of
|
|
1371
|
+
wall time 37.7 -> 52.5 (+40%). **Removed**: the fused branches and the no-flags forms (nothing gained;
|
|
1372
|
+
the look-ahead re-ran on every re-decode of DOOM's self-patching drawer), and the block copy (within
|
|
1373
|
+
noise: the frame copy is 64,000 bytes against 140 M instructions a second).
|
|
1374
|
+
|
|
1375
|
+
The reason inlining mattered: `run()` is one function with 170-odd cases, V8's cumulative inlining
|
|
1376
|
+
budget runs out long before the helpers it calls, and every uninlined `eaOf` was a real call.
|
|
1377
|
+
|
|
1378
|
+
**In a Chromium worker** (new game, `viewsize 80`): Quake 115-118 MIPS and 39-49 frames a second on
|
|
1379
|
+
screen, once the worker looked for a finished frame every 50,000 instructions instead of once per
|
|
1380
|
+
10 ms slice (two frame copies inside one slice had been showing as one). DOOM 139 MIPS.
|
|
1381
|
+
|
|
1382
|
+
Checked: DOOM 400 M and Quake 1.5 G instructions lock-stepped identical to `oracle4` (memory equal),
|
|
1383
|
+
and the native fuzzer through `run(1)`, 78k instructions, no mismatch.
|
|
1384
|
+
|
|
1385
|
+
## 36. Wolfenstein 3D in real mode (2026-09-15)
|
|
1386
|
+
|
|
1387
|
+
`WOLF3D.EXE` v1.4 (LZEXE-packed, Borland C, real mode) on the emulated PC, this box, Node v22.23.2,
|
|
1388
|
+
pinned to one core. It unpacks itself and reaches the sign-on screen in 1.0 s of wall time (60 M
|
|
1389
|
+
instructions on a 20 M clock).
|
|
1390
|
+
|
|
1391
|
+
**Speed**: in a game, turning on the first map, **65 M instructions a second** headless (twice the same
|
|
1392
|
+
reading) and 68-69 in a Chromium worker. Real-mode code runs through the uncached `step()`, so this is
|
|
1393
|
+
the plain interpreter's speed; the game needs about **285 k instructions a frame**, so its 70 frames a
|
|
1394
|
+
second (the VGA's refresh, which it waits for) take 20 M a second -- a third of what is there, and the
|
|
1395
|
+
browser shows 70 frames a second.
|
|
1396
|
+
|
|
1397
|
+
**DOOM and Quake unchanged**: best of three against a copy of the previous build (`oracle5`), DOOM
|
|
1398
|
+
timedemo 93.3 vs 93.0 M a second, Quake 126.3 vs 127.7. Quake lock-stepped identical for 600 M
|
|
1399
|
+
instructions, memory equal. DOOM lock-steps identical to 175 M and then differs, by design: at 173 M it
|
|
1400
|
+
copies between VGA pages in write mode 1, which the previous build wrote as plain data, and reads the
|
|
1401
|
+
planes back.
|