upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/scripts/bitflash_chain.py
diff options
context:
space:
mode:
authorbitflash-maintainer <noreply@bitflash.invalid>2026-09-05 22:46:52 +0000
committerbitflash-maintainer <noreply@bitflash.invalid>2026-09-05 22:46:52 +0000
commitceae3d076b5ad11ee84729ae43f14e9312896646 (patch)
tree7e9132aa871fdc01cb95268d2f5ea3bb11853ddf /scripts/bitflash_chain.py
parentbe63e83d79e7e00e6b8ca889a70e6a19886aa1d8 (diff)
parent66e0b9158d050c46b1ff2509faeee3f4573cac1c (diff)
Bring main onto the line this project actually ships
main had drifted to a history with no ancestor in common with the tree we build and deploy. It sat at be63e83 (2026-08-09) with 28 commits; the shipped line, deploy/soak-2026-08-21, had 383. The two shared nothing -- git merge-base returns no result at all -- so anyone cloning this repository landed on a default branch that was neither current nor related to the running node. The 28 commits on main were audited before this merge: every one is already present in the tree by content, including the two that are hardest to see because they were rewritten afterwards -- the payout reorg-safety work (PayoutBlockStillOnChain, since evolved to re-check on every retry rather than once at maturity) and the nostr relay certificate verification (SSL_set1_host, now with a Windows root-store path). Nothing on main is lost by this merge. Recorded as a merge rather than a force-push: main only ever moves forward, its old history stays reachable as the first parent, and this is revertible. The tree is taken wholesale from the merged branch, so main's content is now exactly what was built and tested. What this brings onto main: - compactblock: BIP152-style compact block relay ported from upstream 1.2.23, primitives and unit test only, with the RAND_bytes failure path closed and the reconstruction allocation bounded. - upstream/main 17741d9: isolated testnet parameters and genesis, optional 2 MB large pages for RandomX. Mainnet genesis verified byte-identical through upstream's BuildGenesisBlock() refactor. - deploy: every image built on bookworm so binaries run on Debian 12, and the container build now honours the RandomX and libsecp256k1 pins it had been silently ignoring since before 1.2.15. Gates at this tree: 15/15 selftests, compactblock's 17 checks, consensus constants unchanged against e4f898c apart from the pre-existing MAX_BLOCK_SIZE_GEN, mainnet genesis intact, money-log check ok.
Diffstat (limited to 'scripts/bitflash_chain.py')
-rw-r--r--scripts/bitflash_chain.py450
1 files changed, 450 insertions, 0 deletions
diff --git a/scripts/bitflash_chain.py b/scripts/bitflash_chain.py
new file mode 100644
index 0000000..4a3231c
--- /dev/null
+++ b/scripts/bitflash_chain.py
@@ -0,0 +1,450 @@
1#!/usr/bin/env python3
2"""Shared Bitflash block/chain parser for deterministic audit tools."""
3
4import datetime as _dt
5import hashlib
6import json
7import mmap
8import os
9import struct
10from pathlib import Path
11
12
13MAGIC = bytes([0xBF, 0x20, 0x5C, 0xFD])
14MAX_BLOCK_PAYLOAD = 32 * 1024 * 1024
15UTXO_LEAF_DOMAIN = "BTFUTXO1"
16UTXO_NODE_DOMAIN_TAG = "BTFNODE1"
17UTXO_NODE_DOMAIN = (UTXO_NODE_DOMAIN_TAG + "|").encode("ascii")
18COIN = 100_000_000
19GENESIS_HASH = "5bd7cb255d814e48cebcdfb72da4dc87b34bd774227f8ceb546c5640f4bdc169"
20GENESIS_MERKLE = "1a45b4482532abb29b10e234d3f13132230525a339ecea91658ffa675a5b1325"
21GENESIS_TIME = 1_753_315_200
22GENESIS_BITS = 0x1F0FFFFF
23GENESIS_NONCE = 3141
24GENESIS_TEXT = "Bitflash 24/Jul/2026 Fair launch: one CPU one vote, no premine"
25
26
27class ParseError(Exception):
28 pass
29
30
31class Reader:
32 def __init__(self, data):
33 self.data = data
34 self.pos = 0
35
36 def take(self, n):
37 if self.pos + n > len(self.data):
38 raise ParseError("unexpected end of data")
39 out = self.data[self.pos:self.pos + n]
40 self.pos += n
41 return out
42
43 def u8(self):
44 return self.take(1)[0]
45
46 def u32(self):
47 return struct.unpack("<I", self.take(4))[0]
48
49 def i32(self):
50 return struct.unpack("<i", self.take(4))[0]
51
52 def i64(self):
53 return struct.unpack("<q", self.take(8))[0]
54
55
56def sha256(data):
57 return hashlib.sha256(data).digest()
58
59
60def sha256d(data):
61 return hashlib.sha256(hashlib.sha256(data).digest()).digest()
62
63
64def hash_hex(serialized):
65 return sha256d(serialized)[::-1].hex()
66
67
68def compact_size(r):
69 first = r.u8()
70 if first < 253:
71 return first
72 if first == 253:
73 return struct.unpack("<H", r.take(2))[0]
74 if first == 254:
75 return r.u32()
76 return struct.unpack("<Q", r.take(8))[0]
77
78
79def read_varbytes(r):
80 return r.take(compact_size(r))
81
82
83def ser_compact_size(n):
84 if n < 253:
85 return bytes([n])
86 if n <= 0xFFFF:
87 return b"\xfd" + struct.pack("<H", n)
88 if n <= 0xFFFFFFFF:
89 return b"\xfe" + struct.pack("<I", n)
90 return b"\xff" + struct.pack("<Q", n)
91
92
93def ser_varbytes(b):
94 return ser_compact_size(len(b)) + b
95
96
97def ser_tx(tx):
98 out = bytearray()
99 out += struct.pack("<i", tx["version"])
100 out += ser_compact_size(len(tx["vin"]))
101 for txin in tx["vin"]:
102 out += bytes.fromhex(txin["prevout_hash"])[::-1]
103 out += struct.pack("<I", txin["prevout_n"])
104 out += ser_varbytes(bytes.fromhex(txin["script_sig_hex"]))
105 out += struct.pack("<I", txin["sequence"])
106 out += ser_compact_size(len(tx["vout"]))
107 for txout in tx["vout"]:
108 out += struct.pack("<q", txout["value_satoshis"])
109 out += ser_varbytes(bytes.fromhex(txout["script_pub_key_hex"]))
110 out += struct.pack("<I", tx["lock_time"])
111 return bytes(out)
112
113
114def parse_tx(r):
115 start = r.pos
116 tx = {"version": r.i32(), "vin": [], "vout": [], "lock_time": 0}
117 for _ in range(compact_size(r)):
118 tx["vin"].append({
119 "prevout_hash": r.take(32)[::-1].hex(),
120 "prevout_n": r.u32(),
121 "script_sig_hex": read_varbytes(r).hex(),
122 "sequence": r.u32(),
123 })
124 for _ in range(compact_size(r)):
125 value = r.i64()
126 tx["vout"].append({
127 "value_satoshis": value,
128 "value_btf": format_money(value),
129 "script_pub_key_hex": read_varbytes(r).hex(),
130 })
131 tx["lock_time"] = r.u32()
132 raw = r.data[start:r.pos]
133 tx["txid"] = hash_hex(raw)
134 return tx
135
136
137def merkle_root(txids):
138 if not txids:
139 return "0" * 64
140 layer = [bytes.fromhex(x)[::-1] for x in txids]
141 while len(layer) > 1:
142 nxt = []
143 for i in range(0, len(layer), 2):
144 left = layer[i]
145 right = layer[i + 1] if i + 1 < len(layer) else left
146 nxt.append(sha256d(left + right))
147 layer = nxt
148 return layer[0][::-1].hex()
149
150
151def parse_block(payload, file_name="", offset=0):
152 r = Reader(payload)
153 header = r.take(80)
154 version, = struct.unpack("<i", header[0:4])
155 prev = header[4:36][::-1].hex()
156 merkle = header[36:68][::-1].hex()
157 ntime, bits, nonce = struct.unpack("<III", header[68:80])
158 txs = [parse_tx(r) for _ in range(compact_size(r))]
159 if r.pos != len(payload):
160 raise ParseError("trailing bytes inside block")
161 txids = [tx["txid"] for tx in txs]
162 return {
163 "hash": hash_hex(header),
164 "version": version,
165 "previous_hash": prev,
166 "merkle_root": merkle,
167 "computed_merkle_root": merkle_root(txids),
168 "time": ntime,
169 "time_utc": utc(ntime),
170 "bits": "0x%08x" % bits,
171 "nonce": nonce,
172 "tx_count": len(txs),
173 "transactions": txs,
174 "source_file": file_name,
175 "source_offset": offset,
176 "size": len(payload),
177 }
178
179
180def iter_block_files(datadir):
181 for path in sorted(Path(datadir).glob("blk*.dat")):
182 yield path
183
184
185def iter_blocks_from_file(path):
186 path = Path(path)
187 with path.open("rb") as f:
188 f.seek(0, os.SEEK_END)
189 file_size = f.tell()
190 if file_size == 0:
191 return
192 f.seek(0)
193 mm = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
194 try:
195 pos = 0
196 while True:
197 offset = mm.find(MAGIC, pos)
198 if offset < 0 or offset + 8 > file_size:
199 break
200 size = struct.unpack("<I", mm[offset + 4:offset + 8])[0]
201 payload_offset = offset + 8
202 end = payload_offset + size
203 if size == 0 or size > MAX_BLOCK_PAYLOAD or end > file_size:
204 pos = offset + 1
205 continue
206 payload = mm[payload_offset:end]
207 try:
208 yield parse_block(payload, path.name, payload_offset)
209 except ParseError:
210 pos = offset + 1
211 continue
212 pos = end
213 finally:
214 mm.close()
215
216
217def read_blocks_from_files(paths, max_blocks=0):
218 blocks = []
219 for path in paths:
220 for block in iter_blocks_from_file(path):
221 blocks.append(block)
222 if max_blocks and len(blocks) >= max_blocks:
223 return blocks
224 return blocks
225
226
227def read_blocks(datadir, max_blocks=0):
228 return read_blocks_from_files(iter_block_files(datadir), max_blocks)
229
230
231def select_main_chain(blocks):
232 by_hash = {}
233 for block in blocks:
234 by_hash.setdefault(block["hash"], block)
235 if GENESIS_HASH not in by_hash:
236 raise ParseError("genesis block not found in block files")
237
238 children = {}
239 for block in by_hash.values():
240 children.setdefault(block["previous_hash"], []).append(block)
241 for v in children.values():
242 v.sort(key=lambda b: (b["hash"], b["source_file"], b["source_offset"]))
243
244 best = []
245 stack = [(by_hash[GENESIS_HASH], [by_hash[GENESIS_HASH]])]
246 while stack:
247 block, path = stack.pop()
248 if len(path) > len(best) or (len(path) == len(best) and block["hash"] < best[-1]["hash"]):
249 best = path
250 for child in children.get(block["hash"], []):
251 stack.append((child, path + [child]))
252 return best
253
254
255def is_coinbase(tx):
256 return (
257 len(tx["vin"]) == 1
258 and tx["vin"][0]["prevout_hash"] == "0" * 64
259 and tx["vin"][0]["prevout_n"] == 0xFFFFFFFF
260 )
261
262
263def utxo_leaf_hash(outpoint, utxo):
264 payload = "|".join([
265 UTXO_LEAF_DOMAIN,
266 outpoint,
267 str(utxo["value_satoshis"]),
268 utxo["script_pub_key_hex"],
269 str(utxo["created_height"]),
270 utxo["created_txid"],
271 ]).encode("ascii")
272 return sha256(payload)
273
274
275def utxo_root(utxos):
276 leaves = [utxo_leaf_hash(k, utxos[k]) for k in sorted(utxos)]
277 if not leaves:
278 return sha256(b"BTFUTXO1|empty").hex()
279 layer = leaves
280 while len(layer) > 1:
281 nxt = []
282 for i in range(0, len(layer), 2):
283 left = layer[i]
284 right = layer[i + 1] if i + 1 < len(layer) else left
285 nxt.append(sha256(UTXO_NODE_DOMAIN + left + right))
286 layer = nxt
287 return layer[0].hex()
288
289
290def apply_blocks(blocks, strict_duplicates=False):
291 utxos = {}
292 spent = 0
293 missing_spends = []
294 duplicate_outputs = []
295 total_coinbase = 0
296 total_fees = 0
297 normal_txs = 0
298
299 for height, block in enumerate(blocks):
300 if block["computed_merkle_root"] != block["merkle_root"]:
301 raise ParseError("bad merkle root at height %d" % height)
302 for tx in block["transactions"]:
303 coinbase = is_coinbase(tx)
304 input_value = 0
305 output_value = sum(out["value_satoshis"] for out in tx["vout"])
306 if coinbase:
307 total_coinbase += output_value
308 else:
309 normal_txs += 1
310 for txin in tx["vin"]:
311 prev = "%s:%d" % (txin["prevout_hash"], txin["prevout_n"])
312 prev_utxo = utxos.pop(prev, None)
313 if prev_utxo is None:
314 missing_spends.append({
315 "height": height,
316 "txid": tx["txid"],
317 "missing_outpoint": prev,
318 })
319 else:
320 spent += prev_utxo["value_satoshis"]
321 input_value += prev_utxo["value_satoshis"]
322 if input_value >= output_value:
323 total_fees += input_value - output_value
324 for n, out in enumerate(tx["vout"]):
325 key = "%s:%d" % (tx["txid"], n)
326 if key in utxos:
327 if strict_duplicates:
328 raise ParseError("duplicate output at height %d: %s" % (height, key))
329 duplicate_outputs.append({"height": height, "outpoint": key})
330 utxos[key] = {
331 "txid": tx["txid"],
332 "vout": n,
333 "value_satoshis": out["value_satoshis"],
334 "value_btf": out["value_btf"],
335 "script_pub_key_hex": out["script_pub_key_hex"],
336 "created_height": height,
337 "created_block_hash": block["hash"],
338 "created_txid": tx["txid"],
339 "coinbase": coinbase,
340 }
341
342 total_unspent = sum(u["value_satoshis"] for u in utxos.values())
343 return {
344 "utxos": utxos,
345 "stats": {
346 "utxo_count": len(utxos),
347 "spent_outputs": spent,
348 "total_unspent_satoshis": total_unspent,
349 "total_unspent_btf": format_money(total_unspent),
350 "total_coinbase_satoshis": total_coinbase,
351 "total_coinbase_btf": format_money(total_coinbase),
352 "coinbase_minus_unspent_satoshis": total_coinbase - total_unspent,
353 "coinbase_minus_unspent_btf": format_money(total_coinbase - total_unspent),
354 "total_fees_satoshis": total_fees,
355 "total_fees_btf": format_money(total_fees),
356 "normal_transactions": normal_txs,
357 "missing_spends": missing_spends,
358 "duplicate_outputs": duplicate_outputs,
359 },
360 }
361
362
363def genesis_transaction():
364 script_sig = (
365 bytes.fromhex("04ffff001d")
366 + b"\x01\x04"
367 + bytes([len(GENESIS_TEXT)])
368 + GENESIS_TEXT.encode("ascii")
369 )
370 pubkey_num = bytes.fromhex(
371 "5f1df16b2b704c8a578d0bbaf74d385cde12c11ee50455f3c438ef4c3fbcf649"
372 "b6de611feae06279a60939e028a8d65c10b73071a6f16719274855feb0fd8a6704"
373 )
374 script_pub_key = bytes([65]) + pubkey_num[::-1] + b"\xac"
375 tx = {
376 "version": 1,
377 "vin": [{
378 "prevout_hash": "0" * 64,
379 "prevout_n": 0xFFFFFFFF,
380 "script_sig_hex": script_sig.hex(),
381 "sequence": 0xFFFFFFFF,
382 }],
383 "vout": [{
384 "value_satoshis": 50 * COIN,
385 "value_btf": format_money(50 * COIN),
386 "script_pub_key_hex": script_pub_key.hex(),
387 }],
388 "lock_time": 0,
389 }
390 tx["txid"] = hash_hex(ser_tx(tx))
391 return tx
392
393
394def reconstructed_genesis():
395 tx = genesis_transaction()
396 header = (
397 struct.pack("<i", 1)
398 + bytes(32)
399 + bytes.fromhex(tx["txid"])[::-1]
400 + struct.pack("<III", GENESIS_TIME, GENESIS_BITS, GENESIS_NONCE)
401 )
402 return {
403 "hash": hash_hex(header),
404 "version": 1,
405 "previous_hash": "0" * 64,
406 "merkle_root": tx["txid"],
407 "computed_merkle_root": tx["txid"],
408 "time": GENESIS_TIME,
409 "time_utc": utc(GENESIS_TIME),
410 "bits": "0x%08x" % GENESIS_BITS,
411 "nonce": GENESIS_NONCE,
412 "tx_count": 1,
413 "transactions": [tx],
414 "source_file": "reconstructed-from-source-constants",
415 "source_offset": 0,
416 "size": 80 + len(ser_compact_size(1)) + len(ser_tx(tx)),
417 }
418
419
420def ser_block(block):
421 header = (
422 struct.pack("<i", block["version"])
423 + bytes.fromhex(block["previous_hash"])[::-1]
424 + bytes.fromhex(block["merkle_root"])[::-1]
425 + struct.pack("<III", block["time"], int(block["bits"], 16), block["nonce"])
426 )
427 txs = block["transactions"]
428 return header + ser_compact_size(len(txs)) + b"".join(ser_tx(tx) for tx in txs)
429
430
431def format_money(value):
432 sign = "-" if value < 0 else ""
433 value = abs(value)
434 return "%s%d.%08d" % (sign, value // COIN, value % COIN)
435
436
437def utc(ts):
438 return _dt.datetime.fromtimestamp(ts, _dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
439
440
441def default_datadir():
442 if os.name == "nt" and os.environ.get("APPDATA"):
443 return os.path.join(os.environ["APPDATA"], "Bitflash")
444 if os.environ.get("HOME"):
445 return os.path.join(os.environ["HOME"], ".bitflash")
446 return "."
447
448
449def canonical_bytes(report):
450 return (json.dumps(report, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + "\n").encode("ascii")