blockyard 0.0.1 → 0.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +679 -0
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +172 -4
- package/SECURITY.md +38 -0
- package/bin/blockyard.js +40 -0
- package/config/pool-map.json +2620 -0
- package/docs/API.md +1575 -0
- package/docs/ARCHITECTURE.md +1307 -0
- package/docs/AUTO-UPDATE.md +269 -0
- package/docs/CONFIGURATION.md +840 -0
- package/docs/DEFECTS.md +813 -0
- package/docs/EFFECTS-AGENTS.md +448 -0
- package/docs/GETTING-STARTED.md +202 -0
- package/docs/INSTALL.md +490 -0
- package/docs/MEASUREMENTS.md +1254 -0
- package/docs/PRIVATE-LEADERBOARD.md +230 -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 +195 -0
- package/docs/STATE-2026-09-09.md +200 -0
- package/docs/TROUBLESHOOTING.md +298 -0
- package/docs/USER-GUIDE.md +1022 -0
- package/package.json +53 -5
- package/public/404.html +9 -0
- package/public/css/app.css +1785 -0
- package/public/index.html +893 -0
- package/public/js/about.js +112 -0
- package/public/js/agents.js +964 -0
- package/public/js/app.js +1312 -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 +2678 -0
- package/public/js/breakout.js +224 -0
- package/public/js/charts.js +635 -0
- package/public/js/depthchart.js +311 -0
- package/public/js/details3d.js +2957 -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 +83 -0
- package/public/js/markets.js +357 -0
- package/public/js/mining.js +1138 -0
- package/public/js/panels.js +966 -0
- package/public/js/pricechart.js +188 -0
- package/public/js/settings.js +1014 -0
- package/public/js/tetris.js +226 -0
- package/public/js/tetrust.js +356 -0
- package/public/js/tetsound.js +175 -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 +20 -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 +410 -0
- package/scripts/shots.mjs +272 -0
- package/scripts/smoke.sh +327 -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 +193 -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 +2516 -0
- package/server/collect/nextblock.js +275 -0
- package/server/collect/sync.js +386 -0
- package/server/config.js +620 -0
- package/server/http/api.js +1275 -0
- package/server/http/explorer.js +418 -0
- package/server/http/server.js +412 -0
- package/server/http/sse.js +176 -0
- package/server/http/static.js +212 -0
- package/server/main.js +628 -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/util/fmt.js +29 -0
- package/systemd/blockyard.service +100 -0
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
// A stand-in for the Bitcoin node: enough of the JSON-RPC surface, plus a log file in
|
|
2
|
+
// the node's own format, so the monitor can be developed, demoed and tested
|
|
3
|
+
// without pointing a browser at a mainnet node that is syncing real blocks.
|
|
4
|
+
//
|
|
5
|
+
// It is honest about being synthetic: `fake:true`, chain "regtest-ish", and it
|
|
6
|
+
// answers the same field sets the real node was observed to return (getpeerinfo
|
|
7
|
+
// [] while getconnectioncount is non-zero, getnettotals all-zero, mempool
|
|
8
|
+
// entries with vsize/weight/time/fees.base and nothing else). Those quirks are
|
|
9
|
+
// the interesting part of the integration, so a fake that "worked better" than
|
|
10
|
+
// the real thing would test nothing.
|
|
11
|
+
import http from 'node:http';
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
|
|
17
|
+
const SYNTH_TIP = 900000;
|
|
18
|
+
|
|
19
|
+
export class FakeNode {
|
|
20
|
+
constructor(opts = {}) {
|
|
21
|
+
this.port = opts.port ?? 18331;
|
|
22
|
+
this.host = opts.host ?? '127.0.0.1';
|
|
23
|
+
this.authUser = opts.authUser ?? 'fake';
|
|
24
|
+
this.authPass = opts.authPass ?? 'fake';
|
|
25
|
+
this.logFile = opts.logFile ?? null;
|
|
26
|
+
// IBD mode: start well behind the tip and advance, so the sync bar is seen
|
|
27
|
+
// moving instead of pinned at a boring 100%.
|
|
28
|
+
this.ibd = opts.ibd ?? true;
|
|
29
|
+
this.catchupBlocksPerSec = opts.catchupBlocksPerSec ?? 12;
|
|
30
|
+
this.headers = SYNTH_TIP;
|
|
31
|
+
this.blocks = this.ibd ? Math.floor(SYNTH_TIP * 0.42) : SYNTH_TIP;
|
|
32
|
+
this.startedAt = Date.now();
|
|
33
|
+
this.realBlockTime = 1788826793;
|
|
34
|
+
this.mempool = seedMempool(1200);
|
|
35
|
+
this.bytesSent = 0;
|
|
36
|
+
this.bytesRecv = 0;
|
|
37
|
+
this.prevHash = '0'.repeat(64);
|
|
38
|
+
this.logTimer = null;
|
|
39
|
+
this.requests = 0;
|
|
40
|
+
this.batchRequests = 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
start() {
|
|
44
|
+
this.server = http.createServer((req, res) => this.handle(req, res));
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
this.server.once('error', (err) => reject(new Error(
|
|
47
|
+
err.code === 'EADDRINUSE'
|
|
48
|
+
? `fake node could not bind ${this.host}:${this.port} - something is already listening there (set FAKE_PORT=<port> to pick another)`
|
|
49
|
+
: `fake node could not bind ${this.host}:${this.port}: ${err.message}`,
|
|
50
|
+
)));
|
|
51
|
+
this.server.listen(this.port, this.host, () => resolve(this));
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
stop() {
|
|
56
|
+
if (this.logTimer) clearInterval(this.logTimer);
|
|
57
|
+
return new Promise((resolve) => (this.server ? this.server.close(resolve) : resolve()));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
get url() { return `http://${this.host}:${this.port}`; }
|
|
61
|
+
|
|
62
|
+
handle(req, res) {
|
|
63
|
+
const chunks = [];
|
|
64
|
+
req.on('data', (c) => chunks.push(c));
|
|
65
|
+
req.on('end', () => {
|
|
66
|
+
this.requests += 1;
|
|
67
|
+
const auth = req.headers.authorization ?? '';
|
|
68
|
+
const want = Buffer.from(`${this.authUser}:${this.authPass}`).toString('base64');
|
|
69
|
+
if (!auth.endsWith(want)) {
|
|
70
|
+
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="fake"' });
|
|
71
|
+
return res.end('unauthorized');
|
|
72
|
+
}
|
|
73
|
+
let body;
|
|
74
|
+
try { body = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch {
|
|
75
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
76
|
+
return res.end(JSON.stringify({ result: null, error: { code: -32700, message: 'parse error' }, id: null }));
|
|
77
|
+
}
|
|
78
|
+
const isBatch = Array.isArray(body);
|
|
79
|
+
if (isBatch) this.batchRequests += 1;
|
|
80
|
+
const items = isBatch ? body : [body];
|
|
81
|
+
const replies = items.map((it) => this.reply(it));
|
|
82
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
83
|
+
res.end(JSON.stringify(isBatch ? replies : replies[0]));
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
reply(it) {
|
|
88
|
+
if (it.method === 'estimatesmartfee') {
|
|
89
|
+
// The targets all arrive as separate batch elements; answer per element.
|
|
90
|
+
const t = it.params?.[0] ?? 6;
|
|
91
|
+
const base = { 1: 0.000032, 2: 0.0000215, 6: 0.00001506, 24: 0.00000921, 144: 0.00000605 }[t] ?? 0.00001;
|
|
92
|
+
return { result: { feerate: jitter(base, 0.06), blocks: t }, error: null, id: it.id };
|
|
93
|
+
}
|
|
94
|
+
const fn = this.methods[it.method];
|
|
95
|
+
if (!fn) return { result: null, error: { code: -32601, message: `Method not found: ${it.method}` }, id: it.id };
|
|
96
|
+
try {
|
|
97
|
+
return { result: fn.call(this, ...(it.params ?? [])), error: null, id: it.id };
|
|
98
|
+
} catch (err) {
|
|
99
|
+
return { result: null, error: { code: err.code ?? -1, message: err.message }, id: it.id };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Height from a height or a hash.
|
|
105
|
+
*
|
|
106
|
+
* hashFor() is one-way, so the reverse lookup is a bounded scan of the recent
|
|
107
|
+
* chain -- 3,000 hash computations, which is nothing next to what it buys: the
|
|
108
|
+
* drill-down (/api/block?hash=…) and its tests need hash -> height to work
|
|
109
|
+
* against the fake without the fake keeping a table of every block it invented.
|
|
110
|
+
*/
|
|
111
|
+
resolveHeight(heightOrHash) {
|
|
112
|
+
if (typeof heightOrHash === 'number' && Number.isFinite(heightOrHash)) return heightOrHash;
|
|
113
|
+
const want = String(heightOrHash ?? '');
|
|
114
|
+
if (!/^[0-9a-fA-F]{64}$/.test(want)) return null;
|
|
115
|
+
const from = Math.max(0, this.blocks - 3000);
|
|
116
|
+
for (let h = this.blocks; h >= from; h--) if (hashFor(h) === want) return h;
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The same txid list getblock() reports for this height, derived identically. */
|
|
121
|
+
txidsOf(h) {
|
|
122
|
+
if (!Number.isFinite(h) || h < 1 || h > this.blocks) return [];
|
|
123
|
+
const seed = hashFor(h);
|
|
124
|
+
const rnd = mulberry(seedNum(seed));
|
|
125
|
+
const txCount = Math.min(600, 2500 + Math.floor(rnd() * 5000));
|
|
126
|
+
return Array.from({ length: txCount }, (_, i) => hashFor(seedNum(seed) * 1009 + i * 31 + 7));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
get methods() {
|
|
130
|
+
const self = this;
|
|
131
|
+
return {
|
|
132
|
+
help() { return { fake: true, methods: Object.keys(self.methods).sort() }; },
|
|
133
|
+
uptime() { return Math.floor((Date.now() - self.startedAt) / 1000) + 51321; },
|
|
134
|
+
getconnectioncount() { return 13; },
|
|
135
|
+
getblockchaininfo() {
|
|
136
|
+
return {
|
|
137
|
+
chain: 'regtest',
|
|
138
|
+
blocks: self.blocks,
|
|
139
|
+
headers: self.headers,
|
|
140
|
+
bestblockhash: hashFor(self.blocks),
|
|
141
|
+
bits: '207fffff',
|
|
142
|
+
difficulty: 127450789715843.1,
|
|
143
|
+
time: self.realBlockTime - (self.headers - self.blocks) * 600,
|
|
144
|
+
mediantime: self.realBlockTime - (self.headers - self.blocks) * 600 - 3100,
|
|
145
|
+
verificationprogress: Math.min(1, self.blocks / self.headers * 0.999 + 0.001),
|
|
146
|
+
initialblockdownload: self.blocks < self.headers,
|
|
147
|
+
chainwork: '0'.repeat(24) + (self.blocks * 2).toString(16).padStart(8, '0'),
|
|
148
|
+
size_on_disk: 767108807210 - (self.headers - self.blocks) * 1_000_000,
|
|
149
|
+
pruned: false,
|
|
150
|
+
warnings: [],
|
|
151
|
+
fake: true,
|
|
152
|
+
};
|
|
153
|
+
},
|
|
154
|
+
getmempoolinfo() {
|
|
155
|
+
const bytes = self.mempool.reduce((a, t) => a + t.vsize, 0);
|
|
156
|
+
const usage = bytes * 3.2;
|
|
157
|
+
return {
|
|
158
|
+
loaded: true, size: self.mempool.length, bytes, usage: Math.round(usage),
|
|
159
|
+
total_fee: +(self.mempool.reduce((a, t) => a + t.feeBtc, 0)).toFixed(8),
|
|
160
|
+
maxmempool: 314572800, mempoolminfee: 0.000001, minrelaytxfee: 0.000001,
|
|
161
|
+
incrementalrelayfee: 0.000001, unbroadcastcount: 0, permitbaremultisig: true,
|
|
162
|
+
maxdatacarriersize: 100000,
|
|
163
|
+
};
|
|
164
|
+
},
|
|
165
|
+
getrawmempool(verbose = false) {
|
|
166
|
+
if (!verbose) return self.mempool.map((t) => t.txid);
|
|
167
|
+
// Exactly the field set the real node returns -- no depends, no
|
|
168
|
+
// ancestorcount, no modifiedfees.
|
|
169
|
+
return Object.fromEntries(self.mempool.map((t) => [t.txid, {
|
|
170
|
+
vsize: t.vsize, weight: t.vsize * 4, time: t.time, fees: { base: t.feeBtc },
|
|
171
|
+
}]));
|
|
172
|
+
},
|
|
173
|
+
getnetworkinfo() {
|
|
174
|
+
return {
|
|
175
|
+
version: 1, subversion: '/BitcoinMachineCode:0.0.1/fake/', protocolversion: 70016,
|
|
176
|
+
localservices: '0000000000000009', localservicesnames: ['NETWORK', 'WITNESS'],
|
|
177
|
+
localrelay: true, timeoffset: 0, networkactive: true,
|
|
178
|
+
connections: 13, connections_in: 0, connections_out: 13,
|
|
179
|
+
networks: [
|
|
180
|
+
{ name: 'ipv4', limited: false, reachable: true, proxy: '', proxy_randomize_credentials: false },
|
|
181
|
+
{ name: 'ipv6', limited: false, reachable: false, proxy: '', proxy_randomize_credentials: false },
|
|
182
|
+
{ name: 'onion', limited: false, reachable: false, proxy: '', proxy_randomize_credentials: false },
|
|
183
|
+
{ name: 'i2p', limited: false, reachable: false, proxy: '', proxy_randomize_credentials: false },
|
|
184
|
+
{ name: 'cjdns', limited: false, reachable: true, proxy: '', proxy_randomize_credentials: false },
|
|
185
|
+
],
|
|
186
|
+
relayfee: 0.000001, incrementalfee: 0.000001, localaddresses: [], warnings: [],
|
|
187
|
+
};
|
|
188
|
+
},
|
|
189
|
+
getmininginfo() {
|
|
190
|
+
return { blocks: self.blocks, bits: '207fffff', difficulty: 127450789715843.1, networkhashps: 8.519e20, pooledtx: self.mempool.length, chain: 'regtest', warnings: [] };
|
|
191
|
+
},
|
|
192
|
+
getnettotals() {
|
|
193
|
+
// Deliberately all-zero, like the live node: this is the quirk that
|
|
194
|
+
// forces the bandwidth panel to rely on the log.
|
|
195
|
+
return {
|
|
196
|
+
totalbytesrecv: 0, totalbytessent: 0, timemillis: Date.now(),
|
|
197
|
+
uploadtarget: { timeframe: 86400, target: 0, target_reached: false, serve_historical_blocks: true, bytes_left_in_cycle: 0, time_left_in_cycle: 0 },
|
|
198
|
+
};
|
|
199
|
+
},
|
|
200
|
+
// The real node answers [] here while reporting 13 connections. Keep that.
|
|
201
|
+
getpeerinfo() { return []; },
|
|
202
|
+
getchaintips() { return [{ height: self.blocks, hash: hashFor(self.blocks), branchlen: 0, status: 'active' }]; },
|
|
203
|
+
getindexinfo() {
|
|
204
|
+
return {
|
|
205
|
+
txindex: { synced: true, best_block_height: self.blocks },
|
|
206
|
+
'basic block filter index': { synced: false, best_block_height: self.blocks - 1634 },
|
|
207
|
+
coinstatsindex: { synced: false, best_block_height: self.blocks - 80 },
|
|
208
|
+
};
|
|
209
|
+
},
|
|
210
|
+
gettxoutsetinfo() {
|
|
211
|
+
return {
|
|
212
|
+
height: self.blocks - 73, bestblock: hashFor(self.blocks - 73), txouts: 165316904 + Math.floor(Math.random() * 4000),
|
|
213
|
+
bogosize: 12950262954, muhash: '9ddbf44fac20436426429fa6cd260dc8fd11a8603c101d8e2bd88af8f683282a',
|
|
214
|
+
total_amount: 20080751.13288641,
|
|
215
|
+
};
|
|
216
|
+
},
|
|
217
|
+
getchaintxstats(nblocks = 120) {
|
|
218
|
+
return {
|
|
219
|
+
time: self.realBlockTime, txcount: 1434232299 + self.blocks,
|
|
220
|
+
window_final_block_hash: hashFor(self.blocks), window_final_block_height: self.blocks,
|
|
221
|
+
window_block_count: nblocks, window_tx_count: 600000 + Math.floor(Math.random() * 40000),
|
|
222
|
+
window_interval: nblocks * 600, txrate: +(2.4 + Math.random() * 0.8).toFixed(4),
|
|
223
|
+
};
|
|
224
|
+
},
|
|
225
|
+
getdeploymentinfo() {
|
|
226
|
+
return {
|
|
227
|
+
hash: hashFor(self.blocks), height: self.blocks,
|
|
228
|
+
deployments: {
|
|
229
|
+
bip34: { active: true, height: 1, signalling_since: null },
|
|
230
|
+
segwit: { active: true, height: 830000, signalling_since: 481824 },
|
|
231
|
+
taproot: { active: true, height: 709632, signalling_since: 709632 },
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
},
|
|
235
|
+
getrpcinfo() {
|
|
236
|
+
return { active_commands: [{ method: 'getrpcinfo', duration: 0 }], logpath: '/tmp/fake-bitcoind.log' };
|
|
237
|
+
},
|
|
238
|
+
getblockstats(heightOrHash) {
|
|
239
|
+
const h = self.resolveHeight(heightOrHash);
|
|
240
|
+
if (h > self.blocks || h < 1) { const e = new Error(`No such block (height ${h})`); e.code = -8; throw e; }
|
|
241
|
+
const seed = hashFor(h);
|
|
242
|
+
const rnd = mulberry(seedNum(seed));
|
|
243
|
+
const txs = 2500 + Math.floor(rnd() * 5000);
|
|
244
|
+
const size = 300000 + Math.floor(rnd() * 1400000);
|
|
245
|
+
const totalfee = 200000 + Math.floor(rnd() * 2500000);
|
|
246
|
+
return {
|
|
247
|
+
avgfee: Math.round(totalfee / txs), avgfeerate: Math.round(totalfee / (size / 1000)),
|
|
248
|
+
avgtxsize: Math.round(size / txs),
|
|
249
|
+
blockhash: seed, height: h,
|
|
250
|
+
ins: Math.floor(txs * 1.2), maxfee: totalfee, maxfeerate: 40 + Math.floor(rnd() * 400),
|
|
251
|
+
maxtxsize: Math.round(size / 40), medianfee: 40 + Math.floor(rnd() * 60),
|
|
252
|
+
mediantime: self.realBlockTime - (self.blocks - h) * 600, mediantxsize: 220,
|
|
253
|
+
minfee: 14, minfeerate: 0, mintxsize: 150,
|
|
254
|
+
outs: Math.floor(txs * 2.1), subsidy: 312500000,
|
|
255
|
+
swtotal_size: Math.floor(size * 0.98), swtotal_weight: Math.floor(size * 4.6), swtxs: Math.floor(txs * 0.98),
|
|
256
|
+
time: self.realBlockTime - (self.blocks - h) * 600,
|
|
257
|
+
total_out: 4.26e11, total_size: size, total_weight: Math.floor(size * 2.49),
|
|
258
|
+
totalfee, txs, utxo_increase: Math.floor(txs * 0.96), utxo_size_inc: 400000,
|
|
259
|
+
feerate_percentiles: [0, 1, 1, Math.round(rnd() * 4), Math.round(2 + rnd() * 60)],
|
|
260
|
+
};
|
|
261
|
+
},
|
|
262
|
+
getblockhash(h) {
|
|
263
|
+
if (h < 1 || h > self.blocks) { const e = new Error('Block height out of range'); e.code = -8; throw e; }
|
|
264
|
+
return hashFor(h);
|
|
265
|
+
},
|
|
266
|
+
/**
|
|
267
|
+
* getblock, verbosity 0 and 1 only.
|
|
268
|
+
*
|
|
269
|
+
* verbosity 2 deliberately REFUSES, in the same spirit as the zeroed
|
|
270
|
+
* getnettotals: the real node answers ~11 MB of hex for one block (MEASUREMENTS
|
|
271
|
+
* §6), so a fake that answered it politely would let a caller get away with the
|
|
272
|
+
* most expensive call in this API. A test can now prove the monitor never asks.
|
|
273
|
+
*/
|
|
274
|
+
getblock(hash, verbosity = 1) {
|
|
275
|
+
const h = self.resolveHeight(hash);
|
|
276
|
+
if (h == null || h < 1 || h > self.blocks) { const e = new Error(`Block not found (hash ${String(hash).slice(0, 16)}…)`); e.code = -5; throw e; }
|
|
277
|
+
if (verbosity >= 2) { const e = new Error('verbosity 2 is not simulated: the real node returns ~11 MB of hex per block and omits fee/deltafee anyway'); e.code = -8; throw e; }
|
|
278
|
+
const seed = hashFor(h);
|
|
279
|
+
const rnd = mulberry(seedNum(seed));
|
|
280
|
+
const txCount = Math.min(600, 2500 + Math.floor(rnd() * 5000));
|
|
281
|
+
const tx = Array.from({ length: txCount }, (_, i) => hashFor(seedNum(seed) * 1009 + i * 31 + 7));
|
|
282
|
+
const time = self.realBlockTime - (self.blocks - h) * 600;
|
|
283
|
+
if (verbosity === 0) return seed;
|
|
284
|
+
return {
|
|
285
|
+
hash: seed, confirmations: self.blocks - h + 1, height: h, version: 548044800, versionHex: '20aa8000',
|
|
286
|
+
merkleroot: hashFor(seedNum(seed) * 977 + 3), time, mediantime: time - 600,
|
|
287
|
+
nonce: 187462942, bits: '207fffff', difficulty: 1,
|
|
288
|
+
chainwork: '0'.repeat(64), nTx: tx.length,
|
|
289
|
+
previousblockhash: h > 1 ? hashFor(h - 1) : undefined,
|
|
290
|
+
size: 300000 + Math.floor(rnd() * 1400000), weight: Math.floor((300000 + Math.floor(rnd() * 1400000)) * 4),
|
|
291
|
+
tx,
|
|
292
|
+
};
|
|
293
|
+
},
|
|
294
|
+
/**
|
|
295
|
+
* getrawtransaction with verbosity 1: the mempool first, then the block the
|
|
296
|
+
* caller names (matching Core, which needs the block hash for anything that has
|
|
297
|
+
* left the pool). Unknown ids fail the way Core fails, with -5.
|
|
298
|
+
*/
|
|
299
|
+
getrawtransaction(txid, verbose = false, blockHash = null) {
|
|
300
|
+
const inPool = self.mempool.find((t) => t.txid === txid);
|
|
301
|
+
const h = blockHash ? self.resolveHeight(blockHash) : null;
|
|
302
|
+
const blockTx = h != null ? self.txidsOf(h).includes(txid) : false;
|
|
303
|
+
if (!inPool && !blockTx) {
|
|
304
|
+
const e = new Error('Transaction not found'); e.code = -5; throw e;
|
|
305
|
+
}
|
|
306
|
+
const seed = seedNum(txid);
|
|
307
|
+
const rnd = mulberry(seed);
|
|
308
|
+
const vin = Array.from({ length: 1 + Math.floor(rnd() * 3) }, (_, i) => ({
|
|
309
|
+
txid: i === 0 && blockTx ? '00'.repeat(32) : hashFor(seed * 7 + i),
|
|
310
|
+
vout: i,
|
|
311
|
+
sequence: 4294967293,
|
|
312
|
+
scriptSig: { asm: '', hex: '' },
|
|
313
|
+
txinwitness: ['30440220' + txid.slice(0, 56), '02' + txid.slice(0, 62)],
|
|
314
|
+
}));
|
|
315
|
+
const vout = Array.from({ length: 2 + Math.floor(rnd() * 3) }, (_, i) => ({
|
|
316
|
+
value: +(rnd() * 0.5).toFixed(8), n: i,
|
|
317
|
+
scriptPubKey: { asm: `OP_DUP OP_HASH160 ${txid.slice(0, 40)} OP_EQUALVERIFY OP_CHECKSIG`, hex: '76a914' + txid.slice(0, 40) + '88ac', reqSigs: 1, type: 'pubkeyhash', addresses: [`bc1q${txid.slice(0, 38)}`] },
|
|
318
|
+
}));
|
|
319
|
+
const decoded = {
|
|
320
|
+
txid, hash: txid, version: 2, size: 110 + vin.length * 40 + vout.length * 34, vsize: 92 + vin.length * 26 + vout.length * 31,
|
|
321
|
+
weight: 400 + vin.length * 104 + vout.length * 124, locktime: 0, vin, vout,
|
|
322
|
+
};
|
|
323
|
+
if (blockTx) {
|
|
324
|
+
decoded.blockhash = blockHash;
|
|
325
|
+
decoded.height = h;
|
|
326
|
+
decoded.confirmations = self.blocks - h + 1;
|
|
327
|
+
decoded.blocktime = self.realBlockTime - (self.blocks - h) * 600;
|
|
328
|
+
decoded.time = decoded.blocktime;
|
|
329
|
+
}
|
|
330
|
+
return verbose ? decoded : 'rawhex';
|
|
331
|
+
},
|
|
332
|
+
getblockheader(h) {
|
|
333
|
+
const hh = typeof h === 'number' ? h : self.blocks;
|
|
334
|
+
return { hash: hashFor(hh), confirmations: 1, height: hh, version: 548044800, versionHex: '20aa8000', merkleroot: hashFor(hh + 1), time: self.realBlockTime, mediantime: self.realBlockTime - 3000, nonce: 1, bits: '207fffff', target: '7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff', difficulty: 1, chainwork: '0'.repeat(32), nTx: 1, previousblockhash: hashFor(hh - 1) };
|
|
335
|
+
},
|
|
336
|
+
sendrawtransaction(hexstring) {
|
|
337
|
+
if (self.sentRefuse) { const e = new Error('Transaction rejected'); e.code = -25; throw e; }
|
|
338
|
+
if (typeof hexstring !== 'string' || hexstring.length < 20) { const e = new Error('TX decode failed'); e.code = -22; throw e; }
|
|
339
|
+
return hashFor(crc32(hexstring));
|
|
340
|
+
},
|
|
341
|
+
testmempoolaccept(rawtxs = []) {
|
|
342
|
+
return rawtxs.map((hex) => ({ txid: hashFor(crc32(hex)), wtxid: hashFor(crc32(hex) + 1), vsize: 140, fees: { base: 0.000002 }, 'allowed-tools': [], allowed: true }));
|
|
343
|
+
},
|
|
344
|
+
savemempool() { return null; },
|
|
345
|
+
estimatesmartfee(target = 6) { return { feerate: 0.00001506, blocks: target }; },
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Drive the chain forward and emit node-shaped log lines, so the tailer, the
|
|
350
|
+
// parsers and the sync bar all have something live to chew on.
|
|
351
|
+
startTicker({ intervalMs = 1000, logEveryMs = 2000 } = {}) {
|
|
352
|
+
let lastTick = Date.now();
|
|
353
|
+
let carryBlocks = 0;
|
|
354
|
+
let lastLog = 0;
|
|
355
|
+
let totalNet = 0;
|
|
356
|
+
let totalDisk = 0;
|
|
357
|
+
const peers = ['136.38.88.88:8333', '193.223.81.8:8333', '186.226.151.18:8333', '208.161.116.211:8333', '46.172.230.198:8333', '69.9.138.92:8333'];
|
|
358
|
+
this.timer = setInterval(() => {
|
|
359
|
+
const now = Date.now();
|
|
360
|
+
const dt = (now - lastTick) / 1000;
|
|
361
|
+
lastTick = now;
|
|
362
|
+
if (this.blocks < this.headers) {
|
|
363
|
+
carryBlocks += this.catchupBlocksPerSec * dt;
|
|
364
|
+
const step = Math.floor(carryBlocks);
|
|
365
|
+
if (step > 0) { carryBlocks -= step; this.blocks = Math.min(this.headers, this.blocks + step); }
|
|
366
|
+
}
|
|
367
|
+
churnMempool(this.mempool);
|
|
368
|
+
if (now - lastLog >= logEveryMs) {
|
|
369
|
+
lastLog = now;
|
|
370
|
+
const rate = this.blocks < this.headers ? 380_000 + Math.random() * 90_000 : 220 + Math.random() * 900;
|
|
371
|
+
const tickBytes = rate * (logEveryMs / 1000);
|
|
372
|
+
totalNet += tickBytes;
|
|
373
|
+
totalDisk += tickBytes * 0.98;
|
|
374
|
+
const lines = [];
|
|
375
|
+
lines.push(`[dlc] -- network recv this tick: ${human(tickBytes)} (${human(rate)}/s) | total recv: ${human(totalNet)} || disk write this tick: ${human(tickBytes * 0.98)} (${human(rate * 0.98)}/s) | total written: ${human(totalDisk)} --`);
|
|
376
|
+
lines.push(`[dlc] -- peers banned this run: ${Math.floor(Math.random() * 3)} of ${100 + Math.floor(Math.random() * 20)} --`);
|
|
377
|
+
lines.push(`[tx_accept] last ${Math.round(logEveryMs / 1000)}s: +${Math.floor(Math.random() * 30)} accepted (mempool ${this.mempool.length}) | rejected: ${Math.floor(Math.random() * 40)} missing-inputs, 0 invalid, ${Math.floor(Math.random() * 6)} policy | 0 already confirmed`);
|
|
378
|
+
const legs = peers.slice(0, 4 + Math.floor(Math.random() * 2)).map((p, i) => `${i}:${p} +${1 + Math.floor(Math.random() * 25)}`).join(', ');
|
|
379
|
+
lines.push(`[txrelay] last ${Math.round(logEveryMs / 1000)}s: +${Math.floor(Math.random() * 90)} tx accepted via legs [${legs}] (mempool ${this.mempool.length})`);
|
|
380
|
+
if (Math.random() < 0.18) lines.push(`[dial] ${randAddr()} connected over v2`);
|
|
381
|
+
// The identity lines. They are the only place this node's user agent, protocol
|
|
382
|
+
// version and peer-side height appear at all (getpeerinfo answers [] on the
|
|
383
|
+
// deployed build), and the peers page now has a table for them -- so the fake
|
|
384
|
+
// has to print them or that table is exercised by nobody.
|
|
385
|
+
if (Math.random() < 0.25) {
|
|
386
|
+
const p = peers[Math.floor(Math.random() * peers.length)];
|
|
387
|
+
const n = 1 + Math.floor(Math.random() * 8);
|
|
388
|
+
lines.push(`[dl] outbound ${n} = ${p.replace(/:\d+$/, ':8333')} (fd ${60 + n}) proto=70016 services=0xc49 ua="/Satoshi:3${1 + Math.floor(Math.random() * 4)}.0.0/" height=${this.headers - Math.floor(Math.random() * 5)} addrv2=1`);
|
|
389
|
+
}
|
|
390
|
+
if (Math.random() < 0.12) lines.push(`[dl:2] ${randAddr()}:8333 connection dropped (revents 0x11); re-dialing`);
|
|
391
|
+
this.log(lines.join('\n') + '\n');
|
|
392
|
+
}
|
|
393
|
+
this.emitHeartbeat();
|
|
394
|
+
}, intervalMs);
|
|
395
|
+
this.timer.unref?.();
|
|
396
|
+
return this;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
emitHeartbeat() {
|
|
400
|
+
if (!this.logFile) return;
|
|
401
|
+
const up = Math.floor((Date.now() - this.startedAt) / 1000) + 51321;
|
|
402
|
+
this.log(`[dl] heartbeat: tip=${this.blocks} peers=12/16 txouts=165335351 uptime=${fmtUptime(up)}\n`);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
log(line) {
|
|
406
|
+
if (!this.logFile) return;
|
|
407
|
+
try {
|
|
408
|
+
const stamp = new Date().toISOString().replace('T', ' ').slice(0, 23);
|
|
409
|
+
fs.appendFileSync(this.logFile, line.trimEnd().split('\n').map((l) => `${stamp} ${l}`).join('\n') + '\n');
|
|
410
|
+
} catch { /* the monitor must not die because a fixture could not be written */ }
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Advance one block on demand, for tests that need a deterministic tip change.
|
|
414
|
+
mine(count = 1) {
|
|
415
|
+
this.blocks = Math.min(this.headers, this.blocks + count);
|
|
416
|
+
return this.blocks;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ---------------------------------------------------------------- fixtures
|
|
421
|
+
|
|
422
|
+
function seedMempool(n) {
|
|
423
|
+
const out = [];
|
|
424
|
+
const now = Math.floor(Date.now() / 1000);
|
|
425
|
+
let rnd = mulberry(12345);
|
|
426
|
+
for (let i = 0; i < n; i++) {
|
|
427
|
+
// A long-tailed feerate distribution, because that is what a real pool looks
|
|
428
|
+
// like and a uniform one would make the log histogram look wrong.
|
|
429
|
+
const u = rnd();
|
|
430
|
+
const feerate = 0.5 * Math.exp(u * Math.log(1200));
|
|
431
|
+
const vsize = 100 + Math.floor(rnd() * 900);
|
|
432
|
+
out.push({
|
|
433
|
+
txid: hexn(64, i * 7919 + Math.floor(rnd() * 1e6)),
|
|
434
|
+
vsize,
|
|
435
|
+
feeBtc: +(feerate * vsize / 1e8).toFixed(8),
|
|
436
|
+
time: now - Math.floor(Math.pow(rnd(), 2) * 3 * 86400),
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
return out;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function churnMempool(pool) {
|
|
443
|
+
const now = Math.floor(Date.now() / 1000);
|
|
444
|
+
const add = Math.floor(Math.random() * 25);
|
|
445
|
+
for (let i = 0; i < add; i++) {
|
|
446
|
+
const vsize = 100 + Math.floor(Math.random() * 900);
|
|
447
|
+
const feerate = 0.5 * Math.exp(Math.random() * Math.log(1500));
|
|
448
|
+
pool.push({ txid: hexn(64, Date.now() + i * 31 + Math.floor(Math.random() * 1e6)), vsize, feeBtc: +(feerate * vsize / 1e8).toFixed(8), time: now });
|
|
449
|
+
}
|
|
450
|
+
const drop = Math.floor(Math.random() * 20);
|
|
451
|
+
for (let i = 0; i < drop && pool.length > 200; i++) pool.splice(Math.floor(Math.random() * pool.length), 1);
|
|
452
|
+
if (pool.length > 6000) pool.splice(0, pool.length - 6000);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function jitter(v, pct) { return +(v * (1 + (Math.random() - 0.5) * 2 * pct)).toFixed(8); }
|
|
456
|
+
|
|
457
|
+
function human(bytes) {
|
|
458
|
+
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
459
|
+
let v = bytes;
|
|
460
|
+
let i = 0;
|
|
461
|
+
while (v >= 1000 && i < u.length - 1) { v /= 1000; i += 1; }
|
|
462
|
+
return i === 0 ? `${Math.round(v)}${u[i]}` : `${v.toFixed(1)}${u[i]}`;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function fmtUptime(sec) {
|
|
466
|
+
const d = Math.floor(sec / 86400);
|
|
467
|
+
const h = Math.floor((sec % 86400) / 3600);
|
|
468
|
+
const m = Math.floor((sec % 3600) / 60);
|
|
469
|
+
const s = sec % 60;
|
|
470
|
+
return [d, h, m, s].map((x) => String(x).padStart(2, '0')).join(':');
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function randAddr() {
|
|
474
|
+
return `${Math.floor(Math.random() * 223) + 1}.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 254) + 1}`;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function mulberry(seed) {
|
|
478
|
+
let a = seed >>> 0;
|
|
479
|
+
return () => {
|
|
480
|
+
a = (a + 0x6d2b79f5) >>> 0;
|
|
481
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
482
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
483
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function seedNum(hex) {
|
|
488
|
+
let n = 0;
|
|
489
|
+
for (let i = 0; i < Math.min(8, hex.length); i++) n = (n * 33 + hex.charCodeAt(i)) >>> 0;
|
|
490
|
+
return n;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function hexn(len, seed) {
|
|
494
|
+
const rnd = mulberry(seed >>> 0);
|
|
495
|
+
let out = '';
|
|
496
|
+
while (out.length < len) out += Math.floor(rnd() * 16).toString(16);
|
|
497
|
+
return out.slice(0, len);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function hashFor(height) {
|
|
501
|
+
// Deterministic per height, and leading-zero shaped like a real hash so the UI
|
|
502
|
+
// looks like it is talking to a node.
|
|
503
|
+
const body = hexn(58, (height >>> 0) * 2654435761);
|
|
504
|
+
return `0000000000${body}`.slice(0, 64);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function crc32(str) {
|
|
508
|
+
let c = 0xffffffff;
|
|
509
|
+
for (let i = 0; i < str.length; i++) {
|
|
510
|
+
c ^= str.charCodeAt(i);
|
|
511
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
512
|
+
}
|
|
513
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
export async function startFakeNode(opts) {
|
|
517
|
+
const n = new FakeNode(opts);
|
|
518
|
+
if (n.logFile) {
|
|
519
|
+
try { fs.mkdirSync(path.dirname(n.logFile), { recursive: true }); } catch { /* exists */ }
|
|
520
|
+
}
|
|
521
|
+
await n.start();
|
|
522
|
+
n.startTicker();
|
|
523
|
+
return n;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === fs.realpathSync(process.argv[1])) {
|
|
527
|
+
const port = Number(process.env.FAKE_PORT || 18331);
|
|
528
|
+
const logFile = process.env.FAKE_LOG || path.join(os.tmpdir(), 'blockyard-fake', 'bitcoin.main.log');
|
|
529
|
+
const node = await startFakeNode({ port, logFile, ibd: process.env.FAKE_IBD !== '0' });
|
|
530
|
+
process.stdout.write(`fake node on ${node.url} (cookie ${node.authUser}:${node.authPass}; log ${logFile}; ibd=${node.ibd})\n`);
|
|
531
|
+
const bye = () => node.stop().then(() => process.exit(0));
|
|
532
|
+
process.on('SIGINT', bye);
|
|
533
|
+
process.on('SIGTERM', bye);
|
|
534
|
+
}
|