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,428 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
//
|
|
3
|
+
// blockpack.js -- lays a block's transactions out as SQUARES on an integer
|
|
4
|
+
// grid, the look of mempool.space's block visualisation, implemented here
|
|
5
|
+
// from scratch.
|
|
6
|
+
//
|
|
7
|
+
// THE SCALE. One 1x1 grid unit stands for vbytesPerUnit() vbytes, chosen so a
|
|
8
|
+
// full block needs a little less than resolution x resolution units: the rest
|
|
9
|
+
// is headroom for rounding sides to whole units. A transaction's square has
|
|
10
|
+
// the side whose area is closest to its vsize in units, rounded a little
|
|
11
|
+
// generously (see sideFor), and never less than one unit.
|
|
12
|
+
//
|
|
13
|
+
// THE PACKING is first fit: each square goes to the lowest row where it fits,
|
|
14
|
+
// and within that row as far left as it fits. Transactions arrive richest
|
|
15
|
+
// first, so the richest settle on the lowest rows, and every small one drops
|
|
16
|
+
// into the lowest gap it fits -- including the gaps beside big squares -- so
|
|
17
|
+
// a real block packs densely.
|
|
18
|
+
//
|
|
19
|
+
// THE STABLE RE-PACK (packStable) lays a refresh out so that transactions
|
|
20
|
+
// already on the board keep their squares, and only what changed moves.
|
|
21
|
+
|
|
22
|
+
import { feeShade } from './feepalette.js';
|
|
23
|
+
|
|
24
|
+
// A full block needs this share of resolution^2 raw units.
|
|
25
|
+
const BLOCK_SHARE = 0.98;
|
|
26
|
+
// A square claims this much more area than its raw units. Most of a real
|
|
27
|
+
// block is transactions of 1.2-1.4 units that can only be drawn as ONE unit,
|
|
28
|
+
// so a block packed at exactly its raw units comes out well short of the
|
|
29
|
+
// grid (89% on a live block); scaling every square's area up by the same
|
|
30
|
+
// small factor gives that area back to the larger squares, keeping their
|
|
31
|
+
// sizes relative to each other exact. Measured on a live 3,051-transaction
|
|
32
|
+
// block (2026-09-11): at 96 units it packs 90 rows tall and 93.5% full, a
|
|
33
|
+
// 140 vB transaction is one unit at 96 and at 112 units, and a block of
|
|
34
|
+
// mid-sized transactions (200-6,200 vB) at 90% of the limit still packs
|
|
35
|
+
// inside a 40-unit grid.
|
|
36
|
+
const AREA_GAIN = 1.06;
|
|
37
|
+
|
|
38
|
+
export function vbytesPerUnit(blockLimit, resolution) {
|
|
39
|
+
const units = Math.max(1, Number(resolution) || 1) ** 2 * BLOCK_SHARE;
|
|
40
|
+
return Math.max(1e-9, Number(blockLimit) || 0) / units;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// the side whose square is nearest the transaction's (gained) area, in whole units
|
|
44
|
+
export function sideFor(vsize, vpu, gridWidth = Infinity) {
|
|
45
|
+
const units = Math.max(0, Number(vsize) || 0) / Math.max(1e-12, Number(vpu) || 0);
|
|
46
|
+
const side = Math.round(Math.sqrt(AREA_GAIN * units));
|
|
47
|
+
return Math.max(1, Math.min(Math.floor(gridWidth) || 1, side));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// the vsize whose square is exactly `side` units: its root lands on the whole
|
|
51
|
+
// number itself, the middle of the band of sizes that round to it, so no
|
|
52
|
+
// float error can tip it into a neighbour
|
|
53
|
+
export function vsizeForSide(side, vpu) {
|
|
54
|
+
const k = Math.max(1, Math.floor(Number(side)) || 1);
|
|
55
|
+
return (Number(vpu) * k * k) / AREA_GAIN;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// AREA-TRUE SIDES FOR THE DETAILED VIEW (operator, 2026-09-11: "Our packer sucks compared to
|
|
59
|
+
// mempool space. How do we improve?"). Measured on the live block that day: 6,699 transactions,
|
|
60
|
+
// 80% of them 139-140 vB, at 96 units (110.7 vB a unit). A 140 vB transaction is 1.27 units and
|
|
61
|
+
// sideFor draws it as ONE, so the block as drawn was 82% of its true area -- a full block stopped
|
|
62
|
+
// 77 rows up a 96-row board, the rest bare grid. Whole-unit squares can only be area-true on
|
|
63
|
+
// average if some of them round up: a transaction of u units is drawn at floor(sqrt(u)) or one
|
|
64
|
+
// more, the larger with the probability that makes its expected area exactly u. The coin is a
|
|
65
|
+
// hash of the txid, so a transaction keeps its side on every refresh (a running error carry is
|
|
66
|
+
// area-exact too, but one newcomer early in the order would resize everything after it). No
|
|
67
|
+
// AREA_GAIN here: the areas are already true.
|
|
68
|
+
function unitHash(key) {
|
|
69
|
+
let h = 0x9747b28c; // not feepalette's seed: size and shade stay independent
|
|
70
|
+
for (let i = 0; i < key.length; i++) { h ^= key.charCodeAt(i); h = Math.imul(h, 0x01000193); }
|
|
71
|
+
h ^= h >>> 15; h = Math.imul(h, 0x2c1b3c6d); h ^= h >>> 12; // mix, so similar ids spread
|
|
72
|
+
return (h >>> 0) / 4294967296;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function ditheredSide(vsize, vpu, gridWidth = Infinity, key = '') {
|
|
76
|
+
const units = Math.max(0, Number(vsize) || 0) / Math.max(1e-12, Number(vpu) || 0);
|
|
77
|
+
const lo = Math.max(1, Math.floor(Math.sqrt(units)));
|
|
78
|
+
const hi = lo + 1;
|
|
79
|
+
const p = Math.min(1, Math.max(0, (units - lo * lo) / (hi * hi - lo * lo)));
|
|
80
|
+
const s = key == null || key === '' ? Math.max(1, Math.round(Math.sqrt(units))) : (unitHash(String(key)) < p ? hi : lo);
|
|
81
|
+
return Math.max(1, Math.min(Math.floor(gridWidth) || 1, s));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const idOf = (tx) => (tx && typeof tx === 'object' ? tx.txid : tx);
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// The occupancy grid. Rows are unbounded upward: a square that does not fit
|
|
88
|
+
// below `height` is placed above it, so insert() always succeeds.
|
|
89
|
+
//
|
|
90
|
+
// For every cell the grid keeps `run`: how many free cells start there and
|
|
91
|
+
// continue to the right in the same row (0 for an occupied cell). A square of
|
|
92
|
+
// side s fits at (x, y) when run >= s at column x in each of its s rows, and
|
|
93
|
+
// a scan along a row can jump over a whole free run too short to use.
|
|
94
|
+
export class BlockLayout {
|
|
95
|
+
constructor({ width, height } = {}) {
|
|
96
|
+
this.width = Math.max(1, Math.floor(Number(width) || 1));
|
|
97
|
+
this.height = Math.max(1, Math.floor(Number(height) || this.width));
|
|
98
|
+
this.rows = 0;
|
|
99
|
+
this.cap = 0;
|
|
100
|
+
this.run = new Uint16Array(0);
|
|
101
|
+
this.free = new Uint32Array(0); // free cells per row
|
|
102
|
+
this.floor = 0; // no free cell below this row
|
|
103
|
+
this.top = 0; // one past the highest occupied row
|
|
104
|
+
this.at = new Map(); // txid -> { x, y, s }
|
|
105
|
+
this.grow(this.height);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
grow(rows) {
|
|
109
|
+
if (rows <= this.rows) return;
|
|
110
|
+
const W = this.width;
|
|
111
|
+
if (rows > this.cap) {
|
|
112
|
+
const cap = Math.max(rows, this.cap * 2, 16);
|
|
113
|
+
const run = new Uint16Array(cap * W);
|
|
114
|
+
run.set(this.run.subarray(0, this.rows * W));
|
|
115
|
+
const free = new Uint32Array(cap);
|
|
116
|
+
free.set(this.free.subarray(0, this.rows));
|
|
117
|
+
this.run = run; this.free = free; this.cap = cap;
|
|
118
|
+
}
|
|
119
|
+
for (let y = this.rows; y < rows; y++) {
|
|
120
|
+
const o = y * W;
|
|
121
|
+
for (let x = 0; x < W; x++) this.run[o + x] = W - x;
|
|
122
|
+
this.free[y] = W;
|
|
123
|
+
}
|
|
124
|
+
this.rows = rows;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
fits(x, y, s) {
|
|
128
|
+
if (x < 0 || y < 0 || x + s > this.width) return false;
|
|
129
|
+
const W = this.width;
|
|
130
|
+
for (let yy = y; yy < y + s; yy++) {
|
|
131
|
+
if (yy >= this.rows) return true; // rows not yet made are empty
|
|
132
|
+
if (this.run[yy * W + x] < s) return false;
|
|
133
|
+
}
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
fill(x, y, s, on) {
|
|
138
|
+
const W = this.width;
|
|
139
|
+
this.grow(y + s);
|
|
140
|
+
for (let yy = y; yy < y + s; yy++) {
|
|
141
|
+
const o = yy * W;
|
|
142
|
+
if (on) {
|
|
143
|
+
for (let xx = x; xx < x + s; xx++) this.run[o + xx] = 0;
|
|
144
|
+
this.free[yy] -= s;
|
|
145
|
+
// the free run ending at x is now shorter
|
|
146
|
+
let n = 0;
|
|
147
|
+
for (let xx = x - 1; xx >= 0 && this.run[o + xx] !== 0; xx--) this.run[o + xx] = ++n;
|
|
148
|
+
} else {
|
|
149
|
+
let n = x + s < W ? this.run[o + x + s] : 0;
|
|
150
|
+
for (let xx = x + s - 1; xx >= 0; xx--) {
|
|
151
|
+
if (xx < x && this.run[o + xx] === 0) break;
|
|
152
|
+
this.run[o + xx] = ++n;
|
|
153
|
+
}
|
|
154
|
+
this.free[yy] += s;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (on) {
|
|
158
|
+
this.top = Math.max(this.top, y + s);
|
|
159
|
+
while (this.floor < this.rows && this.free[this.floor] === 0) this.floor++;
|
|
160
|
+
} else {
|
|
161
|
+
this.floor = Math.min(this.floor, y);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// first fit: the lowest row, then the leftmost column, where s x s is free.
|
|
166
|
+
//
|
|
167
|
+
// TRIED AND REVERTED (2026-09-12): alternating the scan direction by row, to stop every row's
|
|
168
|
+
// leftover space landing on the same side. It reads well in Detailed, where a tile is one unit
|
|
169
|
+
// and the leftover is a cell or two -- and badly in Simple, where a row holds a few EQUAL pieces
|
|
170
|
+
// and the wide leftover then sat at opposite ends on alternate rows: a staircase with stranded
|
|
171
|
+
// columns ("why is packing messed up again"). The board's rightward lean is dealt with in the
|
|
172
|
+
// FLIGHT instead (blockscene3d flightDir), which is where it came from.
|
|
173
|
+
find(s) {
|
|
174
|
+
const W = this.width;
|
|
175
|
+
for (let y = this.floor; ; y++) {
|
|
176
|
+
if (y >= this.rows) return { x: 0, y };
|
|
177
|
+
if (this.free[y] < s) continue;
|
|
178
|
+
const o = y * W;
|
|
179
|
+
for (let x = 0; x + s <= W;) {
|
|
180
|
+
const r = this.run[o + x];
|
|
181
|
+
if (r < s) { x += r + 1; continue; } // this free run is too short: skip it and the cell ending it
|
|
182
|
+
let ok = true;
|
|
183
|
+
for (let yy = y + 1; yy < y + s && yy < this.rows; yy++) {
|
|
184
|
+
if (this.run[yy * W + x] < s) { ok = false; break; }
|
|
185
|
+
}
|
|
186
|
+
if (ok) return { x, y };
|
|
187
|
+
x++;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
insert(tx, side) {
|
|
193
|
+
const s = Math.max(1, Math.min(this.width, Math.floor(Number(side)) || 1));
|
|
194
|
+
const id = idOf(tx);
|
|
195
|
+
if (this.at.has(id)) this.remove(tx);
|
|
196
|
+
const { x, y } = this.find(s);
|
|
197
|
+
this.fill(x, y, s, true);
|
|
198
|
+
const pos = { x, y, s };
|
|
199
|
+
this.at.set(id, pos);
|
|
200
|
+
return { ...pos };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// occupy an explicit position; null (and nothing changes) when any of it is taken
|
|
204
|
+
place(tx, x, y, side) {
|
|
205
|
+
const s = Math.max(1, Math.floor(Number(side)) || 1);
|
|
206
|
+
const id = idOf(tx);
|
|
207
|
+
if (this.at.has(id)) this.remove(tx);
|
|
208
|
+
if (!Number.isInteger(x) || !Number.isInteger(y) || !this.fits(x, y, s)) return null;
|
|
209
|
+
this.fill(x, y, s, true);
|
|
210
|
+
const pos = { x, y, s };
|
|
211
|
+
this.at.set(id, pos);
|
|
212
|
+
return { ...pos };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
remove(tx) {
|
|
216
|
+
const id = idOf(tx);
|
|
217
|
+
const pos = this.at.get(id);
|
|
218
|
+
if (!pos) return false;
|
|
219
|
+
this.at.delete(id);
|
|
220
|
+
this.fill(pos.x, pos.y, pos.s, false);
|
|
221
|
+
if (pos.y + pos.s >= this.top) {
|
|
222
|
+
let t = 0;
|
|
223
|
+
for (const p of this.at.values()) t = Math.max(t, p.y + p.s);
|
|
224
|
+
this.top = t;
|
|
225
|
+
}
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
positionOf(tx) {
|
|
230
|
+
const p = this.at.get(idOf(tx));
|
|
231
|
+
return p ? { ...p } : null;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
function prepare(txs, vpu, width, dither = false) {
|
|
238
|
+
return (txs || []).map((tx) => {
|
|
239
|
+
const vsize = Math.max(1, Number(tx?.vsize) || 0);
|
|
240
|
+
const fee = Number(tx?.fee);
|
|
241
|
+
const rate = Number.isFinite(fee) && fee > 0 ? fee / vsize : 0;
|
|
242
|
+
return { txid: tx?.txid, vsize, rate, s: dither ? ditheredSide(vsize, vpu, width, tx?.txid ?? '') : sideFor(vsize, vpu, width) };
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const tileOf = (it, pos) => ({ txid: it.txid, x: pos.x, y: pos.y, s: pos.s, vsize: it.vsize, rate: it.rate, color: feeShade(it.rate, it.txid) });
|
|
247
|
+
|
|
248
|
+
export function packBlock(txs, { resolution = 80, blockLimit = 1000000, dither = false } = {}) {
|
|
249
|
+
const width = Math.max(1, Math.floor(Number(resolution) || 80));
|
|
250
|
+
const vpu = vbytesPerUnit(blockLimit, width);
|
|
251
|
+
const layout = new BlockLayout({ width, height: width });
|
|
252
|
+
const items = prepare(txs, vpu, width, dither);
|
|
253
|
+
const tiles = new Array(items.length);
|
|
254
|
+
let gridHeight = 0;
|
|
255
|
+
for (let i = 0; i < items.length; i++) {
|
|
256
|
+
const it = items[i];
|
|
257
|
+
const pos = layout.insert(it.txid, it.s);
|
|
258
|
+
tiles[i] = tileOf(it, pos, feeShade);
|
|
259
|
+
if (pos.y + pos.s > gridHeight) gridHeight = pos.y + pos.s;
|
|
260
|
+
}
|
|
261
|
+
return { tiles, gridWidth: width, gridHeight, vbytesPerUnit: vpu };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
// THE STABLE RE-PACK. `prev` is the board as drawn (tiles with txid, x, y, s);
|
|
266
|
+
// `txs` the next layout's transactions, richest first.
|
|
267
|
+
//
|
|
268
|
+
// * A transaction on the previous board with the same side keeps its square.
|
|
269
|
+
// * The tail's equal pieces are interchangeable (their ids name a slot, not a
|
|
270
|
+
// transaction), so a piece takes any previous piece's slot of its size.
|
|
271
|
+
// * Newcomers, and anything whose side changed, fill the free space first
|
|
272
|
+
// fit, in the order given.
|
|
273
|
+
// * When that leaves more off the board than a fresh pack would, the board
|
|
274
|
+
// is re-packed above a line, `keptBelow`: survivors wholly below it stay
|
|
275
|
+
// put and everything else is laid out again first fit. The line starts at
|
|
276
|
+
// the top of the board and comes down only as far as it must, so what
|
|
277
|
+
// moves is the cheap top of the board.
|
|
278
|
+
//
|
|
279
|
+
// Returns null when there is nothing to be stable against (an empty board)
|
|
280
|
+
// or when most of the block is new (a block was mined): the caller packs
|
|
281
|
+
// fresh. Otherwise { tiles, waiting, keptBelow }: the tiles that are on the
|
|
282
|
+
// board, in input order, and the vbytes that did not fit on it.
|
|
283
|
+
|
|
284
|
+
const SURVIVOR_SHARE = 0.5; // below this share of the block's vbytes kept, pack fresh
|
|
285
|
+
const WAIT_ALLOWANCE = 0.01; // may leave this share more waiting than a fresh pack
|
|
286
|
+
|
|
287
|
+
const isPiece = (id) => typeof id === 'string' && id.startsWith('aggregate');
|
|
288
|
+
|
|
289
|
+
export function packStable(prev, txs, opts = {}) {
|
|
290
|
+
if (!Array.isArray(prev) || !prev.length || !Array.isArray(txs) || !txs.length) return null;
|
|
291
|
+
const width = Math.max(1, Math.floor(Number(opts.resolution) || 80));
|
|
292
|
+
const height = width;
|
|
293
|
+
const vpu = vbytesPerUnit(opts.blockLimit ?? 1000000, width);
|
|
294
|
+
const items = prepare(txs, vpu, width, !!opts.dither);
|
|
295
|
+
|
|
296
|
+
// where each survivor was
|
|
297
|
+
const was = new Map();
|
|
298
|
+
const slots = new Map(); // side -> previous piece slots, lowest first
|
|
299
|
+
for (const t of prev) {
|
|
300
|
+
if (!t || !(t.s >= 1) || t.x < 0 || t.y < 0 || t.x + t.s > width || t.y + t.s > height) continue;
|
|
301
|
+
if (isPiece(t.txid)) {
|
|
302
|
+
if (!slots.has(t.s)) slots.set(t.s, []);
|
|
303
|
+
slots.get(t.s).push(t);
|
|
304
|
+
} else {
|
|
305
|
+
was.set(t.txid, t);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
for (const list of slots.values()) list.sort((a, b) => a.y - b.y || a.x - b.x);
|
|
309
|
+
let kept = 0, total = 0;
|
|
310
|
+
const seen = new Set();
|
|
311
|
+
for (const it of items) {
|
|
312
|
+
total += it.vsize;
|
|
313
|
+
if (seen.has(it.txid)) continue; // a duplicated id keeps one square
|
|
314
|
+
seen.add(it.txid);
|
|
315
|
+
let w = isPiece(it.txid) ? slots.get(it.s)?.shift() : was.get(it.txid);
|
|
316
|
+
if (w && w.s === it.s) { it.keep = w; kept += it.vsize; }
|
|
317
|
+
}
|
|
318
|
+
if (kept < total * SURVIVOR_SHARE) return null;
|
|
319
|
+
|
|
320
|
+
const attempt = (line) => {
|
|
321
|
+
const layout = new BlockLayout({ width, height });
|
|
322
|
+
const pos = new Array(items.length);
|
|
323
|
+
for (let i = 0; i < items.length; i++) {
|
|
324
|
+
const k = items[i].keep;
|
|
325
|
+
if (k && k.y + k.s <= line) pos[i] = layout.place(i, k.x, k.y, k.s);
|
|
326
|
+
}
|
|
327
|
+
let waiting = 0;
|
|
328
|
+
for (let i = 0; i < items.length; i++) {
|
|
329
|
+
if (pos[i]) continue;
|
|
330
|
+
const p = layout.insert(i, items[i].s);
|
|
331
|
+
if (p.y + p.s > height) { layout.remove(i); waiting += items[i].vsize; pos[i] = null; } else pos[i] = p;
|
|
332
|
+
}
|
|
333
|
+
return { pos, waiting, keptBelow: line };
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
// what a fresh pack would leave off the board: the line never needs to go lower than that
|
|
337
|
+
const fresh = attempt(0);
|
|
338
|
+
const allowed = fresh.waiting + total * WAIT_ALLOWANCE + 1e-9;
|
|
339
|
+
const step = Math.max(1, Math.round(height / 24));
|
|
340
|
+
let best = fresh;
|
|
341
|
+
for (let line = height; line > 0; line -= step) {
|
|
342
|
+
const r = attempt(line);
|
|
343
|
+
if (r.waiting <= allowed) { best = r; break; }
|
|
344
|
+
}
|
|
345
|
+
const tiles = [];
|
|
346
|
+
for (let i = 0; i < items.length; i++) if (best.pos[i]) tiles.push(tileOf(items[i], best.pos[i], feeShade));
|
|
347
|
+
return { tiles, waiting: best.waiting, keptBelow: best.keptBelow };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
// THE EXACT FILL (operator, 2026-09-12: "Simple viewer mode is what I use as
|
|
352
|
+
// default. It needs to be fucking perfect", then "packer is still fucked up").
|
|
353
|
+
//
|
|
354
|
+
// The Simple board is one block's worth: a few hundred real transactions,
|
|
355
|
+
// richest first, and ONE summed tail standing for the thousands behind them.
|
|
356
|
+
// The old path cut the tail into equal squares of a side chosen in advance,
|
|
357
|
+
// packed everything, and when it overflowed shrank the scale 5% at a time until
|
|
358
|
+
// it happened to fit -- so the last row was partial BY CONSTRUCTION. Measured
|
|
359
|
+
// on the live pool: the top row 55% full, 92 cells stranded, a torn edge.
|
|
360
|
+
//
|
|
361
|
+
// This packs the other way round. The scale is SOLVED: one unit is chosen so
|
|
362
|
+
// that the real transactions' squares plus the tail's area come to exactly
|
|
363
|
+
// resolution x resolution. The real transactions go down first-fit as before.
|
|
364
|
+
// Then every cell still empty is tiled with the largest square that fits, no
|
|
365
|
+
// bigger than `cap` -- so the tail stays a field of small equal-looking pieces
|
|
366
|
+
// (a 14x14 tail piece beside a 1x1 transaction would claim to be 196x bigger,
|
|
367
|
+
// which it is not; it was tried and rejected for exactly that) and the board
|
|
368
|
+
// fills to the last cell. Measured on the live pool and three synthetic ones:
|
|
369
|
+
// 1936 of 1936 cells, a flush top row, every time.
|
|
370
|
+
//
|
|
371
|
+
// `plain`: [{ txid, vsize, rate }] richest first. `tail`: { vbytes, rateAt(frac) }
|
|
372
|
+
// where rateAt maps a share of the tail (0 = richest) to a feerate, or null.
|
|
373
|
+
export function packExact(plain, tail, { resolution = 44, cap = 3 } = {}) {
|
|
374
|
+
const RES = Math.max(1, Math.floor(Number(resolution) || 44));
|
|
375
|
+
const total = RES * RES;
|
|
376
|
+
const items = (plain || []).map((c, i) => ({ txid: c.txid ?? `cell-${i}`, vsize: Math.max(1, Number(c.vsize) || 0), rate: Math.max(0, Number(c.rate) || 0) }));
|
|
377
|
+
const tailVb = tail ? Math.max(0, Number(tail.vbytes) || 0) : 0;
|
|
378
|
+
const realArea = (vpu) => items.reduce((a, c) => { const s = sideFor(c.vsize, vpu, RES); return a + s * s; }, 0);
|
|
379
|
+
// both terms fall as vpu rises, so bisect for the vpu that fills the grid exactly
|
|
380
|
+
let lo = 1, hi = 1e6;
|
|
381
|
+
for (let i = 0; i < 64; i++) { const m = (lo + hi) / 2; if (realArea(m) + tailVb / m > total) lo = m; else hi = m; }
|
|
382
|
+
let vpu = (lo + hi) / 2;
|
|
383
|
+
|
|
384
|
+
// the real transactions, first-fit; if first fit cannot honour the solved area (it is a
|
|
385
|
+
// heuristic, not a guarantee) the scale steps up a little and they are laid again
|
|
386
|
+
let layout, tiles;
|
|
387
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
388
|
+
layout = new BlockLayout({ width: RES, height: RES });
|
|
389
|
+
tiles = [];
|
|
390
|
+
let overflow = false;
|
|
391
|
+
for (const c of items) {
|
|
392
|
+
const s = sideFor(c.vsize, vpu, RES);
|
|
393
|
+
const pos = layout.insert(c.txid, s);
|
|
394
|
+
if (pos.y + pos.s > RES) { overflow = true; break; }
|
|
395
|
+
tiles.push({ txid: c.txid, x: pos.x, y: pos.y, s: pos.s, vsize: c.vsize, rate: c.rate, color: feeShade(c.rate, c.txid) });
|
|
396
|
+
}
|
|
397
|
+
if (!overflow) break;
|
|
398
|
+
vpu *= 1.04;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// the remainder, tiled to the last cell: low-left first, the largest square up to `cap`
|
|
402
|
+
const pieces = [];
|
|
403
|
+
let k = 0;
|
|
404
|
+
// BlockLayout.fits treats rows beyond the grid as empty (the layout grows), so the top edge
|
|
405
|
+
// is this loop's to guard: a piece may not reach past row RES
|
|
406
|
+
const room = (x, y, s) => y + s <= RES && layout.fits(x, y, s);
|
|
407
|
+
for (let y = 0; y < RES; y++) {
|
|
408
|
+
for (let x = 0; x < RES; x++) {
|
|
409
|
+
if (!room(x, y, 1)) continue;
|
|
410
|
+
let s = 1;
|
|
411
|
+
while (s + 1 <= cap && room(x, y, s + 1)) s++;
|
|
412
|
+
const id = `aggregate-${k++}`;
|
|
413
|
+
const pos = layout.place(id, x, y, s);
|
|
414
|
+
if (pos) pieces.push({ ...pos, txid: id });
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
// each piece stands for its share of the tail's vbytes, coloured richest-first by that share
|
|
418
|
+
const pieceArea = pieces.reduce((a, p) => a + p.s * p.s, 0) || 1;
|
|
419
|
+
let acc = 0;
|
|
420
|
+
for (const p of pieces) {
|
|
421
|
+
const vsize = tailVb * (p.s * p.s) / pieceArea;
|
|
422
|
+
const frac = tailVb > 0 ? (acc + vsize / 2) / tailVb : 0;
|
|
423
|
+
acc += vsize;
|
|
424
|
+
const rate = tail && typeof tail.rateAt === 'function' ? Math.max(0, Number(tail.rateAt(frac)) || 0) : Math.max(0, Number(tail?.rate) || 0);
|
|
425
|
+
tiles.push({ txid: p.txid, x: p.x, y: p.y, s: p.s, vsize, rate, color: feeShade(rate, p.txid) });
|
|
426
|
+
}
|
|
427
|
+
return { tiles, gridWidth: RES, gridHeight: RES, vbytesPerUnit: vpu, pieces: pieces.length };
|
|
428
|
+
}
|