blockyard 0.0.9 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/CHANGELOG.md +359 -1
  2. package/README.md +48 -27
  3. package/SECURITY.md +2 -2
  4. package/bin/blockyard.js +2 -1
  5. package/docs/API.md +17 -15
  6. package/docs/ARCHITECTURE.md +128 -10
  7. package/docs/CONFIGURATION.md +39 -30
  8. package/docs/DEFECTS.md +4 -1
  9. package/docs/GETTING-STARTED.md +18 -8
  10. package/docs/INSTALL.md +97 -37
  11. package/docs/MEASUREMENTS.md +147 -0
  12. package/docs/PLAN-SCORCHED-YARD.md +456 -0
  13. package/docs/PLAN-SKIES.md +142 -0
  14. package/docs/SECURITY-AUDIT-2026-09-16.md +647 -0
  15. package/docs/SECURITY.md +58 -22
  16. package/docs/TROUBLESHOOTING.md +44 -5
  17. package/docs/USER-GUIDE.md +505 -35
  18. package/package.json +4 -2
  19. package/public/404.html +1 -1
  20. package/public/css/app.css +393 -82
  21. package/public/donate-qr.png +0 -0
  22. package/public/index.html +353 -109
  23. package/public/js/agents.js +228 -51
  24. package/public/js/app.js +131 -16
  25. package/public/js/blockanoid.js +15 -7
  26. package/public/js/blockout.js +15 -7
  27. package/public/js/blockscene3d.js +230 -38
  28. package/public/js/charts.js +21 -21
  29. package/public/js/depthchart.js +31 -27
  30. package/public/js/details3d.js +1481 -73
  31. package/public/js/doom.js +31 -0
  32. package/public/js/dosaudio.js +48 -0
  33. package/public/js/dosgame.js +389 -0
  34. package/public/js/dosio.js +186 -0
  35. package/public/js/dospc.js +1353 -0
  36. package/public/js/dosworker.js +196 -0
  37. package/public/js/explorer.js +7 -1
  38. package/public/js/livingsky.js +494 -0
  39. package/public/js/login.js +8 -2
  40. package/public/js/markets.js +46 -8
  41. package/public/js/mining.js +314 -36
  42. package/public/js/panels.js +41 -28
  43. package/public/js/pricechart.js +14 -13
  44. package/public/js/quake.js +20 -0
  45. package/public/js/safenext.js +14 -0
  46. package/public/js/scorched.js +1051 -0
  47. package/public/js/scorchedai.js +227 -0
  48. package/public/js/scorchedair.js +286 -0
  49. package/public/js/scorchedfx.js +376 -0
  50. package/public/js/scorchedshop.js +105 -0
  51. package/public/js/scorchedwind.js +69 -0
  52. package/public/js/scorchedyard.js +1338 -0
  53. package/public/js/settings.js +368 -100
  54. package/public/js/soundcard.js +459 -0
  55. package/public/js/tetrust.js +15 -6
  56. package/public/js/tetsound.js +35 -5
  57. package/public/js/theme.js +235 -0
  58. package/public/js/wolf3d.js +22 -0
  59. package/public/js/x86.js +1978 -0
  60. package/scripts/check.js +46 -0
  61. package/scripts/donate-qr.py +12 -9
  62. package/scripts/dos-bench.js +56 -0
  63. package/scripts/index-build.js +9 -2
  64. package/scripts/pool-map.js +152 -36
  65. package/scripts/setup.js +142 -22
  66. package/scripts/shots.mjs +27 -0
  67. package/scripts/smoke.sh +7 -6
  68. package/scripts/tls.js +31 -0
  69. package/scripts/ui.js +4 -2
  70. package/server/auth/sessions.js +33 -13
  71. package/server/chain/blockfile.js +64 -5
  72. package/server/chain/index/build.js +451 -58
  73. package/server/chain/index/heights.js +29 -3
  74. package/server/chain/index/live.js +13 -7
  75. package/server/chain/index/rows.js +6 -1
  76. package/server/chain/index/store.js +28 -5
  77. package/server/chain/index/worker.js +23 -11
  78. package/server/collect/logparse.js +65 -18
  79. package/server/collect/markets.js +76 -7
  80. package/server/collect/mining.js +32 -0
  81. package/server/collect/monitor.js +54 -12
  82. package/server/collect/network.js +305 -0
  83. package/server/config.js +53 -22
  84. package/server/http/api.js +119 -18
  85. package/server/http/games.js +77 -0
  86. package/server/http/server.js +30 -5
  87. package/server/http/sse.js +53 -7
  88. package/server/main.js +66 -11
  89. package/server/rpc/allowlist.js +26 -0
  90. package/server/rpc/client.js +30 -2
  91. package/server/store/audit.js +6 -1
  92. package/server/store/history.js +19 -3
  93. package/server/store/ledger.js +15 -4
  94. package/server/tls/selfsigned.js +160 -0
  95. package/systemd/blockyard.service +41 -8
  96. package/docs/PRIVATE-LEADERBOARD.md +0 -230
  97. package/docs/STATE-2026-09-09.md +0 -200
@@ -16,7 +16,8 @@
16
16
  // fees.chunk/chunkweight -- so the block being built is assembled from the mempool the
17
17
  // monitor already reads, and costs the node no call at all (server/collect/gbt.js).
18
18
 
19
- import { paint, COL } from './charts.js';
19
+ import { paint, COL, lineChart } from './charts.js';
20
+ import { INK } from './theme.js'; // the pie's labels and slice seams follow the theme
20
21
  import { blockTreemap, mempoolTreemap, rateColor as rateBucketColor } from './goggles.js';
21
22
  import { loadSettings, spaceOptions } from './settings.js';
22
23
  // 2026-09-10: the block and the pool now draw as lit solids on a square-packed
@@ -958,10 +959,10 @@ export function nextHud(nb, { meter = '', fresh = null, mempool = null } = {}, f
958
959
  const mins = fresh && Number.isFinite(fresh.seconds) ? Math.floor(fresh.seconds / 60) : null;
959
960
  const ageSec = nb.at ? Math.max(0, Math.round((Date.now() - nb.at) / 1000)) : null;
960
961
  const ec = nb.economy ?? null;
961
- const kv = (k, v) => `<dt>${k}</dt><dd>${v}</dd>`;
962
- return `${head(`<span class="hudk">#${nb.height ?? '?'}</span><span class="hudclock ${lvl}" title="time since the last block: how long this block has been accumulating${fresh?.basis === 'block time' ? ' (from the block timestamp)' : ''}">${mins != null ? `${mins}m` : '–'}</span>`)}
962
+ const kv = (k, v) => `<dt>${fmt.esc(k)}</dt><dd>${fmt.esc(v)}</dd>`; // text only (audit 2026-09-16, L2)
963
+ return `${head(`<span class="hudk">#${fmt.esc(nb.height ?? '?')}</span><span class="hudclock ${lvl}" title="time since the last block: how long this block has been accumulating${fresh?.basis === 'block time' ? ' (from the block timestamp)' : ''}">${mins != null ? `${mins}m` : '–'}</span>`)}
963
964
  ${meter}
964
- <div class="hudbig">${pct.toFixed(1)}<small>% full</small>${ec?.marginal?.rate != null ? `<span class="chip hot">marginal ~${ec.marginal.rate}/vB</span>` : '<span class="chip">not full</span>'}</div>
965
+ <div class="hudbig">${pct.toFixed(1)}<small>% full</small>${ec?.marginal?.rate != null ? `<span class="chip hot">marginal ~${fmt.esc(ec.marginal.rate)}/vB</span>` : '<span class="chip">not full</span>'}</div>
965
966
  <dl class="hudkv">
966
967
  ${kv('transactions', nb.txCount != null ? fmt.num(nb.txCount) : '–')}
967
968
  ${kv('fees', btc(nb.totalFeesSat))}
@@ -982,7 +983,7 @@ export function tipHud(tipHeight, row, prev, { avgGapSec = null, now = Date.now(
982
983
  const w = who(row);
983
984
  const f = blockFacts(row);
984
985
  const gap = prev?.time && row.time ? row.time - prev.time : null;
985
- const kv = (k, v) => `<dt>${k}</dt><dd>${v}</dd>`;
986
+ const kv = (k, v) => `<dt>${fmt.esc(k)}</dt><dd>${fmt.esc(v)}</dd>`; // text only (audit 2026-09-16, L2)
986
987
  return `${head}
987
988
  <div class="hudpool"><span class="bdot" data-pool="${w.idx}"></span><b data-pool-fg="${w.idx}">${fmt.esc(trunc(w.name, 22))}</b><span class="faint">${agoText(row.at ?? row.seenAt, now) ? `mined ${agoText(row.at ?? row.seenAt, now)}` : ''}</span></div>
988
989
  <div class="hudbar" title="${f.capPct != null ? `${f.capPct.toFixed(1)}% of the 4,000,000 WU cap` : 'weight unknown'}"><span data-w="${f.capPct == null ? 0 : f.capPct.toFixed(1)}" data-pool="${w.idx}"></span></div>
@@ -1051,44 +1052,321 @@ export function renderMining(s, state, h) {
1051
1052
  h.nextBlock?.();
1052
1053
  blockFlow(document.getElementById('mnFlow'), flowArgs(s, state), h.fmt);
1053
1054
  packagesView(document.getElementById('mnPackages'), a?.nextBlock?.packages, h.fmt);
1054
- const nb = a?.nextBlock ?? null;
1055
- h.mempoolDetail?.();
1056
- const mp = state?.mempoolDist ?? s?.mempool ?? {};
1057
- drawPoolViewer(h.canvas('gnMempoolTreemap'), s, state);
1058
- const mnote = document.getElementById('gnMempoolNote');
1059
- if (mnote) {
1060
- const cells = mp.cells ?? [];
1061
- const tail = cells.find((c) => c.aggregate) ?? null;
1062
- const total = mp.totalVsize ?? 0;
1063
- const drawn = cells.length - (tail ? 1 : 0);
1064
- if (cells.length) {
1065
- const parts = [`${fmtNum(drawn, h)} drawn`];
1066
- if (tail) parts.push(`+ 1 aggregate of ${fmtNum(tail.aggregate, h)} smaller ones`);
1067
- if (mp.cellCount != null) parts.push(`= ${fmtNum(mp.cellCount, h)} transactions as at the poll`);
1068
- if (mp.fetchedAt != null) parts.push(`${Math.round((Date.now() - mp.fetchedAt) / 1000)}s ago`);
1069
- const fits = total <= 1_000_000
1070
- ? '<b>everything waiting fits in a single block</b>'
1071
- : 'the line sits where one block runs out and everything right of it waits';
1072
- mnote.innerHTML = `${parts.join(', ')}; ${fmtNum(total, h)} vB waiting. One block is 1,000,000 vB, so ${fits}.`
1073
- + ' Ordered by feerate, because that is the order a miner takes them; colour is what each transaction pays.'
1074
- + (mp.stale ? ' <span class="warn">Last poll failed; this picture may be old.</span>' : '');
1075
- } else if (mp.stale) {
1076
- mnote.innerHTML = '<span class="warn">Detail stale</span> — the last poll of the full pool failed. Anything drawn above is the previous reading.';
1077
- } else if (mp.count != null && mp.count > 0) {
1078
- mnote.innerHTML = `<span class="warn">Cells not loaded yet</span> — ${fmtNum(mp.count, h)} transactions are waiting; the full pool is polled on the 20 s tier and this page has not received it.`;
1079
- } else if (mp.count === 0) {
1080
- mnote.textContent = 'The mempool is empty: nothing is waiting, so there is nothing to draw.';
1081
- } else {
1082
- mnote.textContent = 'Nothing to draw yet — the full pool is polled on the 20 s tier, not every second.';
1083
- }
1084
- }
1055
+ // (the Mempool space viewer left this page on 2026-09-15 -- it is on Overview, Block space and
1056
+ // Mempool -- and with it the full-pool poll this page used to ask for)
1085
1057
  feeLandscape(h.canvas('mnFeeLandscape'), a?.nextBlock ?? null, h.fmt);
1086
1058
  poolTable(document.getElementById('mnPools'), a, h.fmt);
1059
+ networkPanels(s?.network ?? null, h);
1060
+ const recent = document.getElementById('mnRecent');
1061
+ if (recent) { const html = recentBlocksHtml(a, h.fmt, { limit: 8 }); if (recent.__html !== html) { recent.innerHTML = html; recent.__html = html; } }
1062
+ renderExpand(s, h);
1087
1063
  const el = document.getElementById('mnCoverage');
1088
1064
  if (el) el.innerHTML = coveragePanel(a, h);
1089
1065
  applyMiningStyles(document);
1090
1066
  }
1091
1067
 
1068
+ // THE NETWORK ROW (2026-09-15): reward stats, the difficulty period, a week of pools as a donut,
1069
+ // a year of hashrate, the adjustments table. Everything is from the snapshot's `network`, which
1070
+ // collect/network.js fills from the node alone; a figure it has not gathered yet is a dash and
1071
+ // the note says how far the week of coinbases has got.
1072
+ const DONUT_COLORS = ['#e8306a', '#8b3fd9', '#5b4fd6', '#3a7be0', '#2f9ee6', '#26b8c8', '#22b7a0', '#3cba6c', '#8bc34a', '#c9d02a', '#f0c419', '#f39c1f', '#ee7b2f', '#c7c9d1', '#8d93a1'];
1073
+ function stat(k, v, s = '', cls = '') {
1074
+ return `<div class="ns"><span class="k">${k}</span><span class="v ${cls}">${v}</span>${s ? `<span class="s">${s}</span>` : ''}</div>`;
1075
+ }
1076
+ function signed(pct, dp = 2) {
1077
+ if (pct == null || !Number.isFinite(pct)) return { text: '–', cls: '' };
1078
+ return { text: `${pct >= 0 ? '▴ +' : '▾ '}${pct.toFixed(dp)}%`, cls: pct >= 0 ? 'up' : 'down' };
1079
+ }
1080
+ function shortDate(ms) {
1081
+ if (!Number.isFinite(ms)) return '–';
1082
+ return new Date(ms).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
1083
+ }
1084
+ function inWords(sec) {
1085
+ if (!Number.isFinite(sec)) return '–';
1086
+ const d = sec / 86400;
1087
+ if (d < 1) return `~${Math.max(1, Math.round(sec / 3600))} hours`;
1088
+ if (d < 60) return `~${Math.round(d)} days`;
1089
+ const y = Math.floor(d / 365.25), rest = Math.round(d - y * 365.25);
1090
+ return y ? `~${y} year${y > 1 ? 's' : ''}, ${rest} days` : `~${Math.round(d)} days`;
1091
+ }
1092
+ function agoWords(ms, now = Date.now()) {
1093
+ const d = (now - ms) / 86400000;
1094
+ if (d < 1) return `${Math.max(1, Math.round(d * 24))} hours ago`;
1095
+ if (d < 28) return `${Math.round(d)} days ago`;
1096
+ return `${Math.round(d / 7)} weeks ago`;
1097
+ }
1098
+ function tera(d) { return d == null || !Number.isFinite(d) ? '–' : `${(d / 1e12).toFixed(2)}T`; }
1099
+
1100
+ // the spot price for the dollar lines, asked for at most once a minute while the page is
1101
+ // painted, and only ever the cached one (/api/price never starts the exchange polling)
1102
+ const PRICE = { usd: null, at: 0, askedAt: 0, busy: false, off: false };
1103
+ function askPrice(h) {
1104
+ const now = Date.now();
1105
+ if (PRICE.busy || now - PRICE.askedAt < 60_000 || typeof h.api !== 'function') return;
1106
+ PRICE.busy = true; PRICE.askedAt = now;
1107
+ h.api('/api/price').then((d) => { PRICE.usd = d?.usd ?? null; PRICE.at = d?.at ?? now; PRICE.off = d?.polling === false || d?.enabled === false; }).catch(() => {}).finally(() => { PRICE.busy = false; h.render?.(); });
1108
+ }
1109
+ const usd = (v) => (v == null || !Number.isFinite(v) ? '' : `$${v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`);
1110
+
1111
+ // RECENT BLOCKS, as mempool.space lists them (height, pool, reward, fees): the attributed window
1112
+ // the ledger already carries, the reward being the height's subsidy plus the block's fees
1113
+ const SATS = 100_000_000;
1114
+ export function subsidyAt(height) {
1115
+ if (!Number.isFinite(height) || height < 0) return null;
1116
+ const halvings = Math.floor(height / 210_000);
1117
+ if (halvings >= 64) return 0;
1118
+ let sat = 50 * SATS;
1119
+ for (let i = 0; i < halvings; i++) sat = Math.floor(sat / 2);
1120
+ return sat;
1121
+ }
1122
+ export function recentBlocksHtml(a, F, { limit = 8, now = Date.now() } = {}) {
1123
+ const rows = (a?.recent ?? []).slice(0, limit);
1124
+ if (!rows.length) return '<div class="note tiny faint">no blocks attributed yet</div>';
1125
+ const esc = F.esc;
1126
+ return `<table class="t"><thead><tr><th>Height</th><th>Pool</th><th>Mined</th><th class="r">Reward</th><th class="r">Fees</th></tr></thead><tbody>${rows.map((r) => {
1127
+ const name = r.poolLabel ?? r.poolName ?? r.poolKey ?? '–';
1128
+ const sub = subsidyAt(r.height), reward = sub != null && r.totalfee != null ? sub + r.totalfee : null;
1129
+ return `<tr><td><a href="#explorer/block/${r.height}">#${F.num(r.height)}</a></td><td title="${esc(r.tagText ?? '')}">${esc(String(name).length > 22 ? `${String(name).slice(0, 21)}…` : name)}</td><td>${r.at ? F.ago(r.at, now) : '–'}</td><td class="r">${reward != null ? `${(reward / SATS).toFixed(3)} BTC` : '–'}</td><td class="r">${r.totalfee != null ? `${(r.totalfee / SATS).toFixed(4)} BTC` : '–'}</td></tr>`;
1130
+ }).join('')}</tbody></table>`;
1131
+ }
1132
+
1133
+ // "VIEW MORE »" (operator, 2026-09-15, of mempool.space: "the 'View more >>' that open up a panel
1134
+ // to see a full screen view of just that panel"): one of four cards, full screen, drawn from the
1135
+ // same snapshot every frame while open. Esc, the scrim or the button close it.
1136
+ const EXPAND = { kind: null, s: null, h: null };
1137
+ const EXPAND_TITLE = { pools: ['Pools', 'the last week’s blocks, by coinbase'], hashrate: ['Hashrate & difficulty', 'a year, one sample a day'], blocks: ['Recent blocks', 'the attributed window, newest first'], adjustments: ['Adjustments', 'each period’s first block against the one before'] };
1138
+ export function openExpand(kind) {
1139
+ const wrap = document.getElementById('expandWrap');
1140
+ if (!wrap || !EXPAND_TITLE[kind]) return;
1141
+ EXPAND.kind = kind;
1142
+ const [t, src] = EXPAND_TITLE[kind];
1143
+ const title = document.getElementById('expandTitle'), s = document.getElementById('expandSrc'), body = document.getElementById('expandBody');
1144
+ if (title) title.textContent = t;
1145
+ if (s) s.textContent = src;
1146
+ if (body) {
1147
+ body.innerHTML = kind === 'pools' ? '<div class="netstats" id="xpStats"></div><canvas class="chart donut" id="xpDonut"></canvas><div id="xpTable"></div>'
1148
+ : kind === 'hashrate' ? '<div class="netstats" id="xpStats"></div><canvas class="chart big" id="xpChart"></canvas>'
1149
+ : '<div id="xpTable"></div>';
1150
+ }
1151
+ wrap.classList.remove('hidden');
1152
+ if (EXPAND.s && EXPAND.h) renderExpand(EXPAND.s, EXPAND.h);
1153
+ }
1154
+ export function closeExpand() {
1155
+ EXPAND.kind = null;
1156
+ document.getElementById('expandWrap')?.classList.add('hidden');
1157
+ }
1158
+ let expandBound = false;
1159
+ function bindExpand() {
1160
+ if (expandBound || typeof document === 'undefined' || typeof document.addEventListener !== 'function' || !document.getElementById('expandWrap')) return;
1161
+ expandBound = true;
1162
+ document.addEventListener('click', (e) => {
1163
+ const a = e.target.closest?.('[data-expand]');
1164
+ if (a) { e.preventDefault(); openExpand(a.dataset.expand); }
1165
+ });
1166
+ document.getElementById('expandClose')?.addEventListener('click', closeExpand);
1167
+ document.getElementById('expandScrim')?.addEventListener('click', closeExpand);
1168
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && EXPAND.kind) closeExpand(); });
1169
+ }
1170
+ export function renderExpand(s, h) {
1171
+ EXPAND.s = s; EXPAND.h = h;
1172
+ if (!EXPAND.kind) return;
1173
+ const n = s?.network ?? null, F = h.fmt;
1174
+ const put = (id, html) => { const el = document.getElementById(id); if (el && el.__html !== html) { el.innerHTML = html; el.__html = html; } };
1175
+ if (EXPAND.kind === 'pools') {
1176
+ const p = n?.pools ?? {};
1177
+ put('xpStats', stat('Pools luck', p.luckPct == null ? '–' : `${p.luckPct.toFixed(2)}%`, p.blocks ? `${p.blocks} found · ${Math.round(p.expected)} expected` : '') + stat('Blocks (1w)', p.blocks ? F.num(p.blocks) : '–') + stat('Pools count', p.count ? String(p.count) : '–'));
1178
+ poolDonut(h.canvas('xpDonut'), p.pools ?? [], F);
1179
+ put('xpTable', (p.pools ?? []).length ? `<table class="t"><thead><tr><th>Pool</th><th class="r">Blocks</th><th class="r">Share</th><th>Labelled</th></tr></thead><tbody>${p.pools.map((x) => `<tr><td>${F.esc(x.name)}</td><td class="r">${F.num(x.blocks)}</td><td class="r">${x.sharePct.toFixed(2)}%</td><td>${x.labelled ? 'curated map' : '<span class="faint">coinbase text</span>'}</td></tr>`).join('')}</tbody></table>` : '');
1180
+ } else if (EXPAND.kind === 'hashrate') {
1181
+ const hr = n?.hashrate ?? {}, a = n?.adjustment;
1182
+ put('xpStats', stat('Hashrate (1w)', hr.networkHashPs != null ? F.eh(hr.networkHashPs / 1e18) : '–', 'getnetworkhashps, 1008 blocks') + stat('Difficulty', tera(n?.difficulty), a ? `period from #${F.num(a.epochStart)}` : '') + stat('Samples', hr.series?.length ? `${hr.series.length}<small>days</small>` : '–', 'one block header a day'));
1183
+ hashrateChart(h.canvas('xpChart'), hr.series ?? [], F);
1184
+ } else if (EXPAND.kind === 'blocks') {
1185
+ put('xpTable', recentBlocksHtml(s?.attribution, F, { limit: 200 }));
1186
+ } else if (EXPAND.kind === 'adjustments') {
1187
+ put('xpTable', adjustmentsHtml(n?.adjustments ?? [], F));
1188
+ }
1189
+ }
1190
+ function adjustmentsHtml(rows, F) {
1191
+ if (!rows.length) return '<div class="note tiny faint">reading the periods\' first blocks…</div>';
1192
+ return `<table class="t"><thead><tr><th>Height</th><th>Adjusted</th><th class="r">Difficulty</th><th class="r">Change</th></tr></thead><tbody>${rows.map((x) => { const c = signed(x.changePct); return `<tr><td>#${F.num(x.height)}</td><td>${agoWords(x.time * 1000)}</td><td class="r">${tera(x.difficulty)}</td><td class="r ${c.cls}">${c.text.replace(/^[▴▾] /, '')}</td></tr>`; }).join('')}</tbody></table>`;
1193
+ }
1194
+ function hashrateChart(canvas, series, F) {
1195
+ if (!canvas) return;
1196
+ if (series.length > 2) {
1197
+ const pts = series.map((x) => ({ t: x.t, v: x.hashrate / 1e18 }));
1198
+ const dLo = Math.min(...series.map((x) => x.difficulty / 1e12)), dHi = Math.max(...series.map((x) => x.difficulty / 1e12));
1199
+ const smooth = pts.map((_, i) => { const w = pts.slice(Math.max(0, i - 6), i + 1); return { t: pts[i].t, v: w.reduce((s, q) => s + q.v, 0) / w.length }; });
1200
+ lineChart(canvas, [
1201
+ { label: 'hashrate, daily', points: pts, color: 'rgba(160,200,80,0.45)', width: 1 },
1202
+ { label: 'hashrate, 7-day mean', points: smooth, color: '#f0c419', width: 2 },
1203
+ { label: `difficulty ${dLo.toFixed(0)}T–${dHi.toFixed(0)}T`, points: series.map((x) => ({ t: x.t, v: x.difficulty / 1e12 })), color: '#e8306a', width: 2, axis: 'right' },
1204
+ ], {
1205
+ fmtY: (v) => (v >= 1000 ? `${(v / 1000).toFixed(2)}Z` : `${v.toFixed(0)}E`), fmtRight: () => '', left: 40,
1206
+ fmtX: (t) => new Date(t).toLocaleDateString('en-US', { month: 'short' }),
1207
+ min: Math.min(...pts.map((q) => q.v)) * 0.85, max: Math.max(...pts.map((q) => q.v)) * 1.05, zeroBase: false,
1208
+ rightMin: dLo * 0.97, rightMax: dHi * 1.03,
1209
+ });
1210
+ } else paint(canvas, { when: null, draw: () => {}, placeholder: 'reading a year of block headers…' });
1211
+ }
1212
+
1213
+ export function networkPanels(n, h) {
1214
+ bindExpand();
1215
+ const F = h.fmt, esc = F.esc;
1216
+ askPrice(h);
1217
+ const put = (id, html) => { const el = document.getElementById(id); if (el && el.__html !== html) { el.innerHTML = html; el.__html = html; } };
1218
+ if (!n) {
1219
+ for (const id of ['mnRewards', 'mnAdjust', 'mnPoolsWeek', 'mnHashrate']) put(id, '<div class="note tiny faint">not in this snapshot yet — the node has not been asked</div>');
1220
+ return;
1221
+ }
1222
+ // reward stats
1223
+ const r = n.rewards ?? {};
1224
+ // the dollar lines (operator, 2026-09-15: "Add dollar figures"): under each figure while a
1225
+ // price is at hand; a faint note instead while market polling is off
1226
+ const px = PRICE.usd;
1227
+ const dollars = (sat) => (px != null && sat != null ? `<span class="usd">${usd(sat / 1e8 * px)}</span>` : '');
1228
+ put('mnRewards', r.blocks
1229
+ ? stat('Miners reward', `${(r.minersRewardSat / 1e8).toFixed(2)}<small>BTC</small>`, `${dollars(r.minersRewardSat)}${r.blocks} blocks · #${r.from}–#${r.to}`)
1230
+ + stat('Avg block fees', `${(r.avgBlockFeeSat / 1e8).toFixed(4)}<small>BTC/block</small>`, dollars(r.avgBlockFeeSat))
1231
+ + stat('Avg tx fee', `${r.avgTxFeeSat == null ? '–' : F.num(r.avgTxFeeSat)}<small>sats/tx</small>`, `${dollars(r.avgTxFeeSat)}${F.num(r.txs)} transactions`)
1232
+ + (px == null && PRICE.off ? '<div class="note tiny faint usdnote">dollar figures need market polling (Display settings → Markets &amp; Price)</div>' : '')
1233
+ : '<div class="note tiny faint">reading the last 144 blocks…</div>');
1234
+ const src = document.getElementById('mnRewardsSrc'); if (src) src.textContent = `last ${r.blocks || 144} blocks · getblockstats`;
1235
+ // the difficulty period
1236
+ const a = n.adjustment, hv = n.halving;
1237
+ if (a) {
1238
+ const est = signed(a.estimatePct), prev = signed(a.previousPct);
1239
+ put('mnAdjust',
1240
+ stat('Remaining', `${F.num(a.remaining)}<small>blocks</small>`, `in ${inWords(a.etaSec)} · ${a.into} of 2016 mined`)
1241
+ + stat('Estimate', `<span class="${est.cls}">${est.text}</span>`, `previous: <span class="${prev.cls}">${prev.text}</span>`)
1242
+ + stat('Next halving', shortDate(hv?.at), hv ? `#${F.num(hv.nextHeight)} · in ${inWords(hv.etaSec)}` : ''));
1243
+ } else put('mnAdjust', '<div class="note tiny faint">reading the period\'s first block…</div>');
1244
+ // pools over the week
1245
+ const p = n.pools ?? {};
1246
+ const luck = p.luckPct == null ? '–' : `${p.luckPct.toFixed(2)}%`;
1247
+ put('mnPoolsWeek', stat('Pools luck', luck, p.blocks ? `${p.blocks} found · ${Math.round(p.expected)} expected` : '')
1248
+ + stat('Blocks (1w)', p.blocks ? F.num(p.blocks) : '–')
1249
+ + stat('Pools count', p.count ? String(p.count) : '–'));
1250
+ const note = document.getElementById('mnPoolsWeekNote');
1251
+ if (note) {
1252
+ const left = p.todo ?? 0;
1253
+ note.textContent = left > 0
1254
+ ? `Filling in: ${F.num(p.filled ?? 0)} coinbases read so far, ${F.num(left)} to go (eight every few seconds, behind the live polls). Shares are of the blocks read so far.`
1255
+ : p.blocks ? `${F.num(p.blocks)} blocks in the last seven days, every coinbase read from this node; a name appears only where the curated pool map or your alias file says so.` : '';
1256
+ }
1257
+ poolDonut(h.canvas('mnPoolDonut'), p.pools ?? [], F);
1258
+ // hashrate and difficulty
1259
+ const hr = n.hashrate ?? {};
1260
+ put('mnHashrate', stat('Hashrate (1w)', hr.networkHashPs != null ? F.eh(hr.networkHashPs / 1e18) : '–', 'getnetworkhashps, 1008 blocks')
1261
+ + stat('Difficulty', tera(n.difficulty), a ? `period from #${F.num(a.epochStart)}` : '')
1262
+ + stat('Samples', hr.series?.length ? `${hr.series.length}<small>days</small>` : '–', 'one block header a day'));
1263
+ hashrateChart(h.canvas('mnHashChart'), hr.series ?? [], F);
1264
+ // the adjustments table
1265
+ put('mnAdjustments', adjustmentsHtml((n.adjustments ?? []).slice(0, 6), F)); // six on the card; every period the server carries under View more
1266
+ }
1267
+
1268
+ function poolDonut(canvas, pools, F) {
1269
+ // THE LABELLED PIE (operator, 2026-09-15, with the reference: "I want this view. With pool
1270
+ // names clustered with colored lines linking to their pie slice"): every pool named beside the
1271
+ // pie, the big ones on the side their slice faces, the small ones stacked where there is room,
1272
+ // each name joined to its own slice by a leader in the slice's colour. Slices under half a
1273
+ // percent are gathered into "Other (x.xx%)". Labels on a side are laid out top to bottom at
1274
+ // their slice's height and pushed apart where they would overlap, so the leaders fan out.
1275
+ if (!canvas?.getContext) return;
1276
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
1277
+ const w = canvas.clientWidth || 520, h = canvas.clientHeight || 440;
1278
+ if (canvas.width !== Math.round(w * dpr) || canvas.height !== Math.round(h * dpr)) { canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr); }
1279
+ const ctx = canvas.getContext('2d');
1280
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
1281
+ ctx.clearRect(0, 0, w, h);
1282
+ const total = pools.reduce((s, p) => s + p.blocks, 0);
1283
+ const cx = w / 2, cy = h / 2;
1284
+ // THE PIE FITS ITS LABELS (operator, 2026-09-15, of a 975px window: "surely it could look better
1285
+ // at this sizing"): the radius is what is left after the widest label on each side and its
1286
+ // leader, measured, so no name is ever cut at the card's edge; where that leaves too small a
1287
+ // pie the names are shortened and the font drops a size first
1288
+ const gap = 34;
1289
+ const nameOf = (p, max) => (p.other ? p.name : p.name.length > max ? `${p.name.slice(0, max - 1)}…` : p.name);
1290
+ const preview = pools.map((p) => ({ ...p, other: false }));
1291
+ let font = 12.5, maxName = 16, R = 0, withPct = true;
1292
+ for (const [f, m, pc] of [[12.5, 16, true], [11.5, 13, true], [11, 14, false]]) {
1293
+ font = f; maxName = m; withPct = pc;
1294
+ ctx.font = `${f}px system-ui, sans-serif`;
1295
+ const widths = preview.map((p) => ctx.measureText(nameOf(p, m)).width + (pc ? ctx.measureText(' 100.0%').width : 0) + 8);
1296
+ const widest = widths.length ? Math.max(...widths) : 0;
1297
+ R = Math.min(h / 2 - 14, cx - gap - widest - 6);
1298
+ if (R >= 90 || f === 11) break; // the last step drops the shares (the reference has none) for a bigger pie
1299
+ }
1300
+ R = Math.max(50, R);
1301
+ const LH = font + 4, r = R * 0.28;
1302
+ if (!total) { ctx.fillStyle = COL.text; ctx.font = `${font}px system-ui, sans-serif`; ctx.textAlign = 'center'; ctx.fillText('no blocks read yet', cx, cy); return; }
1303
+ // slices: the named ones, and the rest under a threshold as one "Other". The threshold starts at
1304
+ // half a percent and rises until each side's stack of labels FITS THE CANVAS (operator,
1305
+ // 2026-09-15: "getting cut off on mining screen left bottom" -- fourteen names at 16px need
1306
+ // 230px, and a short card gave them 220), so on a small card the small pools fold into Other
1307
+ // rather than run off the bottom
1308
+ const build = (threshold) => {
1309
+ const small = pools.filter((p) => (p.blocks / total) * 100 < threshold);
1310
+ const big = pools.filter((p) => (p.blocks / total) * 100 >= threshold);
1311
+ const slices = [...big, ...(small.length ? [{ name: `Other (${(small.reduce((s, p) => s + p.blocks, 0) / total * 100).toFixed(2)}%)`, blocks: small.reduce((s, p) => s + p.blocks, 0), other: true }] : [])];
1312
+ let a0 = -Math.PI / 2;
1313
+ return slices.map((p, i) => {
1314
+ const a1 = a0 + (p.blocks / total) * Math.PI * 2, mid = (a0 + a1) / 2;
1315
+ const out = { p, i, a0, a1, mid, color: p.other ? '#8d93a1' : DONUT_COLORS[i % DONUT_COLORS.length] };
1316
+ a0 = a1;
1317
+ return out;
1318
+ });
1319
+ };
1320
+ let laid = build(0.5);
1321
+ for (const threshold of [1, 2, 3, 5, 8, 12]) {
1322
+ const perSide = [1, -1].map((dir) => laid.filter((x) => (Math.cos(x.mid) >= 0 ? 1 : -1) === dir).length);
1323
+ if (Math.max(...perSide) * LH <= h - 6) break;
1324
+ laid = build(threshold);
1325
+ }
1326
+ // the pie
1327
+ for (const s of laid) {
1328
+ ctx.beginPath(); ctx.arc(cx, cy, R, s.a0, s.a1); ctx.arc(cx, cy, r, s.a1, s.a0, true); ctx.closePath();
1329
+ ctx.fillStyle = s.color; ctx.fill();
1330
+ ctx.strokeStyle = INK.panel; ctx.lineWidth = 1; ctx.stroke();
1331
+ }
1332
+ // the labels: each side laid out top to bottom, pushed apart to LH
1333
+ const side = (dir) => {
1334
+ const list = laid.filter((s) => (Math.cos(s.mid) >= 0 ? 1 : -1) === dir).map((s) => ({ s, y: cy + Math.sin(s.mid) * (R + 12), ax: cx + Math.cos(s.mid) * R, ay: cy + Math.sin(s.mid) * R }));
1335
+ list.sort((p, q) => p.y - q.y);
1336
+ for (let k = 1; k < list.length; k++) if (list[k].y < list[k - 1].y + LH) list[k].y = list[k - 1].y + LH;
1337
+ // keep them on the canvas: push the stack up from the bottom, then down from the top
1338
+ const over = list.length ? list[list.length - 1].y - (h - LH / 2) : 0;
1339
+ if (over > 0) for (const l of list) l.y -= over;
1340
+ for (let k = 0; k < list.length; k++) if (list[k].y < LH / 2) list[k].y = LH / 2; else if (k && list[k].y < list[k - 1].y + LH) list[k].y = list[k - 1].y + LH;
1341
+ return list;
1342
+ };
1343
+ ctx.font = `${font}px system-ui, sans-serif`; ctx.textBaseline = 'middle'; ctx.lineWidth = 1.5;
1344
+ for (const dir of [1, -1]) {
1345
+ const labelX = cx + dir * (R + gap), edgeX = cx + dir * (R + gap - 8);
1346
+ ctx.textAlign = dir > 0 ? 'left' : 'right';
1347
+ for (const l of side(dir)) {
1348
+ const name = nameOf(l.s.p, maxName);
1349
+ ctx.strokeStyle = l.s.color;
1350
+ ctx.beginPath(); ctx.moveTo(l.ax, l.ay); ctx.lineTo(edgeX, l.y); ctx.lineTo(labelX - dir * 3, l.y); ctx.stroke();
1351
+ ctx.fillStyle = INK.label;
1352
+ ctx.fillText(name, labelX, l.y);
1353
+ // the share, faint, after the name on the right side and before it on the left
1354
+ if (withPct) {
1355
+ const pct = `${(l.s.p.blocks / total * 100).toFixed(1)}%`;
1356
+ ctx.fillStyle = INK.axisText; ctx.font = `${font - 1.5}px ${'var(--mono), monospace'}`;
1357
+ const nameW = ctx.measureText(name).width;
1358
+ if (dir > 0) ctx.fillText(pct, labelX + nameW + 8, l.y); else ctx.fillText(pct, labelX - nameW - 8, l.y);
1359
+ ctx.font = `${font}px system-ui, sans-serif`;
1360
+ }
1361
+ }
1362
+ }
1363
+ ctx.textAlign = 'center';
1364
+ ctx.fillStyle = COL.text; ctx.font = `600 ${font + 1}px system-ui, sans-serif`;
1365
+ ctx.fillText(`${F.num(total)}`, cx, cy - 7);
1366
+ ctx.font = `${font - 2}px system-ui, sans-serif`; ctx.fillStyle = INK.axisText;
1367
+ ctx.fillText('blocks', cx, cy + 8);
1368
+ }
1369
+
1092
1370
  function coveragePanel(a, h) {
1093
1371
  const esc = h.fmt.esc;
1094
1372
  const row = (k, v) => `<dt>${esc(k)}</dt><dd>${v}</dd>`;
@@ -30,7 +30,7 @@ export function renderChain(s, state, h) {
30
30
  placeholder: 'no tip samples yet',
31
31
  });
32
32
  h.setText('chTipNote', s.tip?.headers != null && s.tip?.height != null
33
- ? `Applied ${fmt.num(s.tip.height)} of ${fmt.num(s.tip.headers)} announced headers. Headers-first means this reaches 100% only when the last block lands.`
33
+ ? `<span title="Headers-first: this reaches 100% only when the last block lands">${fmt.num(s.tip.height)} of ${fmt.num(s.tip.headers)} announced headers applied</span>`
34
34
  : 'no header count reported yet');
35
35
 
36
36
  const gap = (b.gap ?? []).filter((p) => Number.isFinite(p.v));
@@ -55,10 +55,11 @@ export function renderChain(s, state, h) {
55
55
  // reads as "broken panel" unless the panel says otherwise: this figure sat empty
56
56
  // for a day because the request asked for statistics the endpoint has never had.
57
57
  const sizeRow = (s.blocks?.recent ?? [])[0] ?? null;
58
+ // one line (2026-09-15, the packing pass): the basis sentence is the note's title, the figures its text
58
59
  h.setText('chSizeNote', sizeRow?.sizeBasis
59
- ? `<span class="mono">total_size</span> ${sizeRow.sizeBasis}.`
60
+ ? `<span class="mono" title="${fmt.esc(sizeRow.sizeBasis)}">total_size</span>`
60
61
  + (sizeRow.medianTxSize != null && sizeRow.swtotalSize != null && sizeRow.size != null
61
- ? ` This block: median tx ${fmt.bytes(sizeRow.medianTxSize, 0)}, witness ${fmt.bytes(sizeRow.swtotalSize, 0)} of ${fmt.bytes(sizeRow.size, 0)}.`
62
+ ? ` · this block: median tx ${fmt.bytes(sizeRow.medianTxSize, 0)}, witness ${fmt.bytes(sizeRow.swtotalSize, 0)} of ${fmt.bytes(sizeRow.size, 0)}`
62
63
  : '')
63
64
  : (sizeRow?.sizeMissing ?? 'no block stats collected yet — the monitor has not seen a new height since it started'));
64
65
  drawFromSeries(h, 'chFeeChart', b.fee, COL.ok, (v) => fmt.short(v), { fmtTip: (v) => `${fmt.sats(v)} sat` });
@@ -71,10 +72,13 @@ export function renderChain(s, state, h) {
71
72
  placeholder: 'getchaintxstats has not answered yet',
72
73
  });
73
74
  const cs = s.chaintxstats;
74
- h.setText('chTxStats', cs ? `<dt>window</dt><dd>${fmt.num(cs.window_block_count ?? 0)} blocks</dd>
75
- <dt>txs in window</dt><dd>${fmt.num(cs.window_tx_count ?? 0)}</dd>
76
- <dt>all-time txs</dt><dd>${fmt.num(cs.txcount ?? 0)}</dd>
77
- <dt>rate</dt><dd>${cs.txrate != null ? cs.txrate.toFixed(2) + ' tx/s' : '–'}</dd>` : '<dt>–</dt><dd>not yet fetched</dd>');
75
+ // ONE WRAPPING LINE (2026-09-15, the packing pass): as a four-column grid these overflowed a
76
+ // 330px card and as two columns they made the card the row's tallest
77
+ const item = (k, v) => `<span><span class="k">${k}</span><b>${v}</b></span>`;
78
+ h.setText('chTxStats', cs
79
+ ? item('window', `${fmt.num(cs.window_block_count ?? 0)} blocks`) + item('txs in window', fmt.num(cs.window_tx_count ?? 0))
80
+ + item('all-time txs', fmt.num(cs.txcount ?? 0)) + item('rate', cs.txrate != null ? `${cs.txrate.toFixed(2)} tx/s` : '–')
81
+ : '<span class="faint">not yet fetched</span>');
78
82
 
79
83
  h.setText('chState', kv([
80
84
  ['chain', s.chain ?? '–'],
@@ -89,7 +93,7 @@ export function renderChain(s, state, h) {
89
93
  ['pruned', s.pruned == null ? '–' : String(s.pruned)],
90
94
  ['chain work', s.chainwork ? fmt.hash(s.chainwork.replace(/^0+/, '') || '0', 6) : '–'],
91
95
  ['IBD', s.ibd == null ? '–' : String(s.ibd)],
92
- ['monitor uptime', s.uptimeSec != null ? fmt.uptime(s.uptimeSec * 1000) : '–'],
96
+ ['uptime', s.uptimeSec != null ? fmt.uptime(s.uptimeSec * 1000) : '–'],
93
97
  ]));
94
98
 
95
99
  const u = s.utxo ?? {};
@@ -107,8 +111,8 @@ export function renderChain(s, state, h) {
107
111
  // hundreds of blocks a second is not a hashrate), and the reason travels with it
108
112
  // so the dash is an explanation rather than a mystery.
109
113
  ['network hash', s.hashrateEstEh != null ? fmt.eh(s.hashrateEstEh)
110
- : `<span title="${fmt.esc(s.hashrateNote ?? '')}">–</span>`],
111
- ...(s.hashrateEstEh == null && s.hashrateNote ? [['hash rate note', `<span class="tiny faint">${fmt.esc(s.hashrateNote)}</span>`]] : []),
114
+ : raw(`<span title="${fmt.esc(s.hashrateNote ?? '')}">–</span>`)],
115
+ ...(s.hashrateEstEh == null && s.hashrateNote ? [['hash rate note', raw(`<span class="tiny faint">${fmt.esc(s.hashrateNote)}</span>`)]] : []),
112
116
  ['avg interval', s.avgBlockGapSec != null ? fmt.ageSec(s.avgBlockGapSec) : '–'],
113
117
  ['reorgs seen', String(s.blocks?.reorgs ?? 0)],
114
118
  ]));
@@ -348,7 +352,7 @@ export function peerTableHtml(rows, fmt, { now = Date.now(), tip = null } = {})
348
352
  const faint = (s) => ` <span class="faint">${fmt.esc(s)}</span>`;
349
353
  const body = sorted.map((p) => `<tr>
350
354
  <td>${p.inbound ? '<span class="badge muted">in</span>' : '<span class="badge">out</span>'}</td>
351
- <td>${fmt.esc(p.addr ?? '–')}${p.network ? faint(p.network) : ''}</td>
355
+ <td class="addr" title="${fmt.esc(p.addr ?? '')}">${fmt.esc(p.addr ?? '–')}${p.network ? faint(p.network) : ''}</td>
352
356
  <td class="w">${fmt.esc(ua(p.subver))}${p.version ? faint(String(p.version)) : ''}${svc(p).map((t) => ` <span class="badge muted">${t}</span>`).join('')}</td>
353
357
  <td class="r">${Number.isFinite(p.conntime) ? fmt.uptime((now / 1000 - p.conntime) * 1000) : '–'}</td>
354
358
  <td class="r">${since(p.lastrecv)}</td>
@@ -472,7 +476,7 @@ export function renderNode(s, state, h) {
472
476
  h.setText('ndRpc', kv([
473
477
  ['endpoint', rpc.url ?? '–'],
474
478
  ['cookie source', rpc.cookieSource ? fmt.hash(rpc.cookieSource, 14) : '–'],
475
- ['in flight', '1 (by design)'],
479
+ ['in flight', '1'], // one request at a time is the design (the note under the chart says so); '(by design)' was cut off in the two-column list
476
480
  ['calls (60 s window)', String(rpc.ratePerSec ?? 0) + '/s'],
477
481
  ['total calls', fmt.num(rpc.calls)],
478
482
  ['batches', fmt.num(rpc.batches)],
@@ -517,7 +521,7 @@ export function renderNode(s, state, h) {
517
521
  const lh = t.health ?? {};
518
522
  h.setText('ndTail', kv([
519
523
  ['file', t.source === 'disabled'
520
- ? '<span class="faint">disabled — running on RPC only</span>'
524
+ ? raw('<span class="faint">disabled — running on RPC only</span>')
521
525
  : (t.file ? fmt.hash(t.file, 16) : 'not configured')],
522
526
  ['exists', t.exists == null ? '–' : String(t.exists)],
523
527
  ['size', t.size != null ? fmt.bytes(t.size, 1) : '–'],
@@ -532,10 +536,10 @@ export function renderNode(s, state, h) {
532
536
  // The two figures that separate "this node is quiet" from "we are tailing the
533
537
  // wrong file". A tail that stops moving is invisible in every other panel.
534
538
  ['lines matched', lh.ratio != null
535
- ? `${Math.round(lh.ratio * 100)}%${lh.lines ? ` <span class="faint tiny">(${fmt.num(lh.parsed)}/${fmt.num(lh.lines)} last window)</span>` : ''}`
536
- : '<span class="faint">not checked yet</span>'],
539
+ ? raw(`${Math.round(lh.ratio * 100)}%${lh.lines ? ` <span class="faint tiny">(${fmt.num(lh.parsed)}/${fmt.num(lh.lines)} last window)</span>` : ''}`)
540
+ : raw('<span class="faint">not checked yet</span>')],
537
541
  ['last new bytes', lh.lastGrowthAt
538
- ? `${fmt.ago(lh.lastGrowthAt)} <span class="faint tiny">(warns after ${lh.staleAfterMs ? Math.round(lh.staleAfterMs / 60000) : '?'} min)</span>`
542
+ ? raw(`${fmt.esc(fmt.ago(lh.lastGrowthAt))} <span class="faint tiny">(warns after ${lh.staleAfterMs ? Math.round(lh.staleAfterMs / 60000) : '?'} min)</span>`)
539
543
  : '–'],
540
544
  ]));
541
545
 
@@ -822,8 +826,17 @@ function drawFromSeries(h, id, points, color, fmtY, extra = {}) {
822
826
  });
823
827
  }
824
828
 
825
- function kv(pairs) {
826
- return pairs.map(([k, v]) => `<dt>${String(k)}</dt><dd>${String(v)}</dd>`).join('');
829
+ // ESCAPED BY DEFAULT (audit 2026-09-16, L2/L3). kv() used to write every value as markup, and some
830
+ // values are the node's own strings passed straight through -- `chain`, `pruned`, the RPC endpoint,
831
+ // the cookie path. A hostile or misconfigured node returned `chain` with an <img> appended and it
832
+ // became an element on the Chain page (the CSP stopped the script; the element was still there).
833
+ // Now a value is text unless it is wrapped in raw(), which says at the call site "this is markup
834
+ // this file built, with its own dynamic parts already escaped".
835
+ const RAW = Symbol('raw');
836
+ export const raw = (html) => ({ [RAW]: String(html) });
837
+ export function kv(pairs) {
838
+ const cell = (v) => (v && typeof v === 'object' && RAW in v ? v[RAW] : F().esc(v));
839
+ return pairs.map(([k, v]) => `<dt>${F().esc(k)}</dt><dd>${cell(v)}</dd>`).join('');
827
840
  }
828
841
 
829
842
  function median(a) {
@@ -884,8 +897,8 @@ export function initChainDrill(h) {
884
897
  return;
885
898
  }
886
899
  const rows = [
887
- ['txid', `<span class="mono tiny select-all">${fmt.esc(d.txid ?? txid)}</span> <a class="xlink" href="#explorer/tx/${encodeURIComponent(d.txid ?? txid)}">open in the explorer ›</a>`],
888
- ['in', d.inMempool ? '<span class="warn">mempool (unconfirmed)</span>' : `block ${fmt.esc(d.blockHash ?? '?')}`],
900
+ ['txid', raw(`<span class="mono tiny select-all">${fmt.esc(d.txid ?? txid)}</span> <a class="xlink" href="#explorer/tx/${encodeURIComponent(d.txid ?? txid)}">open in the explorer ›</a>`)],
901
+ ['in', d.inMempool ? raw('<span class="warn">mempool (unconfirmed)</span>') : `block ${d.blockHash ?? '?'}`],
889
902
  ['confirmations', d.confirmations == null ? '– (in the pool)' : fmt.num(d.confirmations)],
890
903
  ['size / vsize / weight', [d.size, d.vsize, d.weight].map((v) => (v == null ? '–' : fmt.num(v))).join(' / ')],
891
904
  ['locktime', d.locktime ?? 0],
@@ -894,8 +907,8 @@ export function initChainDrill(h) {
894
907
  ['total out', d.totalOutSat != null ? `${fmt.num(d.totalOutSat)} sat` : '–'],
895
908
  ];
896
909
  const io = (rows2, label) => `<div class="note tiny mt-6"><b>${label}</b></div><table class="t"><tbody>${rows2}</tbody></table>`;
897
- const inRows = (d.inputs ?? []).map((v) => `<tr><td class=\"mono tiny w\">${fmt.esc(String(v.txid ?? 'coinbase').slice(0, 16))}…:${v.vout ?? '–'}</td><td class=\"tiny faint\">${fmt.esc(v.scriptSigType ?? '')} ${fmt.esc(v.scriptSigAsm ?? '')}</td></tr>`).join('');
898
- const outRows = (d.outputs ?? []).map((v) => `<tr><td class=\"r faint\">${v.n ?? '–'}</td><td class=\"mono tiny\">${fmt.esc(String(v.address ?? v.scriptPubKeyType ?? '–'))}</td><td class=\"r\">${v.value == null ? '–' : fmt.num(v.value)}</td></tr>`).join('');
910
+ const inRows = (d.inputs ?? []).map((v) => `<tr><td class=\"mono tiny w\">${fmt.esc(String(v.txid ?? 'coinbase').slice(0, 16))}…:${fmt.esc(v.vout ?? '–')}</td><td class=\"tiny faint\">${fmt.esc(v.scriptSigType ?? '')} ${fmt.esc(v.scriptSigAsm ?? '')}</td></tr>`).join('');
911
+ const outRows = (d.outputs ?? []).map((v) => `<tr><td class=\"r faint\">${fmt.esc(v.n ?? '–')}</td><td class=\"mono tiny\">${fmt.esc(String(v.address ?? v.scriptPubKeyType ?? '–'))}</td><td class=\"r\">${v.value == null ? '–' : fmt.num(v.value)}</td></tr>`).join('');
899
912
  box.innerHTML = `<div class=\"drill\">
900
913
  <dl class=\"kv\">${kv(rows)}</dl>
901
914
  ${inRows ? io(inRows, `inputs (${d.inputsTotal})`) : ''}
@@ -932,20 +945,20 @@ export function initChainDrill(h) {
932
945
  const b = d.header ?? {};
933
946
  const st = d.stats ?? {};
934
947
  if (hdr()) hdr().innerHTML = kv([
935
- ['height', b.height == null ? '–' : `<a class="xlink" href="#explorer/block/${b.height}">${fmt.num(b.height)}</a> <span class="tiny faint">open in the explorer</span>`],
936
- ['hash', `<span class=\"mono tiny select-all\">${fmt.esc(String(b.hash ?? '').slice(0, 24))}…</span>`],
937
- ['confirmations', b.confirmations == null ? '–' : `${fmt.num(b.confirmations)}${b.confirmations === 0 ? ' <span class=\"bad\">(not on the best chain)</span>' : ''}`],
948
+ ['height', b.height == null ? '–' : raw(`<a class="xlink" href="#explorer/block/${encodeURIComponent(Number(b.height))}">${fmt.esc(fmt.num(b.height))}</a> <span class="tiny faint">open in the explorer</span>`)],
949
+ ['hash', raw(`<span class=\"mono tiny select-all\">${fmt.esc(String(b.hash ?? '').slice(0, 24))}…</span>`)],
950
+ ['confirmations', b.confirmations == null ? '–' : raw(`${fmt.esc(fmt.num(b.confirmations))}${b.confirmations === 0 ? ' <span class=\"bad\">(not on the best chain)</span>' : ''}`)],
938
951
  ['time', b.time ? `${new Date(b.time * 1000).toISOString().replace('T', ' ').slice(0, 19)}Z` : '–'],
939
952
  ['age', b.time ? fmt.ago(b.time * 1000) : '–'],
940
953
  ['size / weight', `${b.size == null ? '–' : fmt.bytes(b.size, 0)} / ${b.weight == null ? '–' : fmt.num(b.weight)} WU`],
941
954
  ['transactions', fmt.num(b.nTx ?? b.txCount ?? 0)],
942
- ['fees', st.totalfee == null ? (d.statsError ? '<span class=\"warn\">getblockstats failed</span>' : '–') : `${fmt.num(st.totalfee)} sat`],
955
+ ['fees', st.totalfee == null ? (d.statsError ? raw('<span class=\"warn\">getblockstats failed</span>') : '–') : `${fmt.num(st.totalfee)} sat`],
943
956
  ['median fee', st.medianfee == null ? '–' : `${fmt.num(st.medianfee)} sat`],
944
957
  ['fee rate p10/50/90', (st.feerate_percentiles ?? []).length ? st.feerate_percentiles.map((v) => Number(v).toFixed(1)).join(' / ') : '–'],
945
958
  ['subsidy', st.subsidy == null ? '–' : `${fmt.num(st.subsidy)} sat`],
946
959
  ['utxo delta', st.utxo_increase == null ? '–' : fmt.num(st.utxo_increase)],
947
- ['prev / next', `<span class=\"mono tiny\">${fmt.esc(String(b.previousblockhash ?? '–').slice(0, 10))}… / ${fmt.esc(String(b.nextblockhash ?? 'none').slice(0, 10))}…</span>`],
948
- ['merkle root', `<span class=\"mono tiny\">${fmt.esc(String(b.merkleRoot ?? '–').slice(0, 16))}…</span>`],
960
+ ['prev / next', raw(`<span class=\"mono tiny\">${fmt.esc(String(b.previousblockhash ?? '–').slice(0, 10))}… / ${fmt.esc(String(b.nextblockhash ?? 'none').slice(0, 10))}…</span>`)],
961
+ ['merkle root', raw(`<span class=\"mono tiny\">${fmt.esc(String(b.merkleRoot ?? '–').slice(0, 16))}…</span>`)],
949
962
  ]);
950
963
  const ids = d.txids ?? [];
951
964
  if (list()) {