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:
authorNsable-sh <nsable-sh@noreply.git.bitflash.network>2026-08-11 14:13:26 -0300
committerNsable-sh <nsable-sh@noreply.git.bitflash.network>2026-08-11 14:13:26 -0300
commit1839347459759fb3b23cc747ec4368b724fee008 (patch)
treee5f6f7e13b6dd9eb260d7c75bafa19d873435c5c /scripts/bitflash_chain.py
parenta83fae37099cb102beb5a4c174a4abe6ef3f24a4 (diff)
tools: harden chain audit scripts
Diffstat (limited to 'scripts/bitflash_chain.py')
-rw-r--r--scripts/bitflash_chain.py453
1 files changed, 453 insertions, 0 deletions
diff --git a/scripts/bitflash_chain.py b/scripts/bitflash_chain.py
new file mode 100644
index 0000000..f3688f1
--- /dev/null
+++ b/scripts/bitflash_chain.py
@@ -0,0 +1,453 @@
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 os
8import struct
9from pathlib import Path
10
11
12MAGIC = bytes([0xBF, 0x20, 0x5C, 0xFD])
13MAX_BLOCK_PAYLOAD = 32 * 1024 * 1024
14COIN = 100_000_000
15GENESIS_HASH = "5bd7cb255d814e48cebcdfb72da4dc87b34bd774227f8ceb546c5640f4bdc169"
16GENESIS_MERKLE = "1a45b4482532abb29b10e234d3f13132230525a339ecea91658ffa675a5b1325"
17GENESIS_TIME = 1_753_315_200
18GENESIS_BITS = 0x1F0FFFFF
19GENESIS_NONCE = 3141
20GENESIS_TEXT = "Bitflash 24/Jul/2026 Fair launch: one CPU one vote, no premine"
21
22
23class ParseError(Exception):
24 pass
25
26
27class Reader:
28 def __init__(self, data):
29 self.data = data
30 self.pos = 0
31
32 def take(self, n):
33 if self.pos + n > len(self.data):
34 raise ParseError("unexpected end of data")
35 out = self.data[self.pos:self.pos + n]
36 self.pos += n
37 return out
38
39 def u8(self):
40 return self.take(1)[0]
41
42 def u32(self):
43 return struct.unpack("<I", self.take(4))[0]
44
45 def i32(self):
46 return struct.unpack("<i", self.take(4))[0]
47
48 def i64(self):
49 return struct.unpack("<q", self.take(8))[0]
50
51
52def sha256(data):
53 return hashlib.sha256(data).digest()
54
55
56def sha256d(data):
57 return hashlib.sha256(hashlib.sha256(data).digest()).digest()
58
59
60def hash_hex(serialized):
61 return sha256d(serialized)[::-1].hex()
62
63
64def compact_size(r):
65 first = r.u8()
66 if first < 253:
67 return first
68 if first == 253:
69 return struct.unpack("<H", r.take(2))[0]
70 if first == 254:
71 return r.u32()
72 return struct.unpack("<Q", r.take(8))[0]
73
74
75def read_varbytes(r):
76 return r.take(compact_size(r))
77
78
79def ser_compact_size(n):
80 if n < 253:
81 return bytes([n])
82 if n <= 0xFFFF:
83 return b"\xfd" + struct.pack("<H", n)
84 if n <= 0xFFFFFFFF:
85 return b"\xfe" + struct.pack("<I", n)
86 return b"\xff" + struct.pack("<Q", n)
87
88
89def ser_varbytes(b):
90 return ser_compact_size(len(b)) + b
91
92
93def ser_tx(tx):
94 out = bytearray()
95 out += struct.pack("<i", tx["version"])
96 out += ser_compact_size(len(tx["vin"]))
97 for txin in tx["vin"]:
98 out += bytes.fromhex(txin["prevout_hash"])[::-1]
99 out += struct.pack("<I", txin["prevout_n"])
100 out += ser_varbytes(bytes.fromhex(txin["script_sig_hex"]))
101 out += struct.pack("<I", txin["sequence"])
102 out += ser_compact_size(len(tx["vout"]))
103 for txout in tx["vout"]:
104 out += struct.pack("<q", txout["value_satoshis"])
105 out += ser_varbytes(bytes.fromhex(txout["script_pub_key_hex"]))
106 out += struct.pack("<I", tx["lock_time"])
107 return bytes(out)
108
109
110def parse_tx(r):
111 start = r.pos
112 tx = {"version": r.i32(), "vin": [], "vout": [], "lock_time": 0}
113 for _ in range(compact_size(r)):
114 tx["vin"].append({
115 "prevout_hash": r.take(32)[::-1].hex(),
116 "prevout_n": r.u32(),
117 "script_sig_hex": read_varbytes(r).hex(),
118 "sequence": r.u32(),
119 })
120 for _ in range(compact_size(r)):
121 value = r.i64()
122 tx["vout"].append({
123 "value_satoshis": value,
124 "value_btf": format_money(value),
125 "script_pub_key_hex": read_varbytes(r).hex(),
126 })
127 tx["lock_time"] = r.u32()
128 raw = r.data[start:r.pos]
129 tx["txid"] = hash_hex(raw)
130 return tx
131
132
133def merkle_root(txids):
134 if not txids:
135 return "0" * 64
136 layer = [bytes.fromhex(x)[::-1] for x in txids]
137 while len(layer) > 1:
138 nxt = []
139 for i in range(0, len(layer), 2):
140 left = layer[i]
141 right = layer[i + 1] if i + 1 < len(layer) else left
142 nxt.append(sha256d(left + right))
143 layer = nxt
144 return layer[0][::-1].hex()
145
146
147def parse_block(payload, file_name="", offset=0):
148 r = Reader(payload)
149 header = r.take(80)
150 version, = struct.unpack("<i", header[0:4])
151 prev = header[4:36][::-1].hex()
152 merkle = header[36:68][::-1].hex()
153 ntime, bits, nonce = struct.unpack("<III", header[68:80])
154 txs = [parse_tx(r) for _ in range(compact_size(r))]
155 if r.pos != len(payload):
156 raise ParseError("trailing bytes inside block")
157 txids = [tx["txid"] for tx in txs]
158 return {
159 "hash": hash_hex(header),
160 "version": version,
161 "previous_hash": prev,
162 "merkle_root": merkle,
163 "computed_merkle_root": merkle_root(txids),
164 "time": ntime,
165 "time_utc": utc(ntime),
166 "bits": "0x%08x" % bits,
167 "nonce": nonce,
168 "tx_count": len(txs),
169 "transactions": txs,
170 "source_file": file_name,
171 "source_offset": offset,
172 "size": len(payload),
173 }
174
175
176def iter_block_files(datadir):
177 for path in sorted(Path(datadir).glob("blk*.dat")):
178 yield path
179
180
181def iter_blocks_from_file(path):
182 path = Path(path)
183 with path.open("rb") as f:
184 window = f.read(4)
185 offset = 0
186 while len(window) == 4:
187 if window != MAGIC:
188 nxt = f.read(1)
189 if not nxt:
190 break
191 window = window[1:] + nxt
192 offset += 1
193 continue
194
195 size_bytes = f.read(4)
196 if len(size_bytes) != 4:
197 break
198 size = struct.unpack("<I", size_bytes)[0]
199 if size == 0 or size > MAX_BLOCK_PAYLOAD:
200 offset += 1
201 f.seek(offset)
202 window = f.read(4)
203 continue
204 payload_offset = offset + 8
205 payload = f.read(size)
206 if len(payload) != size:
207 break
208 try:
209 yield parse_block(payload, path.name, payload_offset)
210 except ParseError:
211 offset += 1
212 f.seek(offset)
213 window = f.read(4)
214 continue
215
216 offset = f.tell()
217 window = f.read(4)
218
219
220def read_blocks_from_files(paths, max_blocks=0):
221 blocks = []
222 for path in paths:
223 for block in iter_blocks_from_file(path):
224 blocks.append(block)
225 if max_blocks and len(blocks) >= max_blocks:
226 return blocks
227 return blocks
228
229
230def read_blocks(datadir, max_blocks=0):
231 return read_blocks_from_files(iter_block_files(datadir), max_blocks)
232
233
234def select_main_chain(blocks):
235 by_hash = {}
236 for block in blocks:
237 by_hash.setdefault(block["hash"], block)
238 if GENESIS_HASH not in by_hash:
239 raise ParseError("genesis block not found in block files")
240
241 children = {}
242 for block in by_hash.values():
243 children.setdefault(block["previous_hash"], []).append(block)
244 for v in children.values():
245 v.sort(key=lambda b: (b["hash"], b["source_file"], b["source_offset"]))
246
247 best = []
248 stack = [(by_hash[GENESIS_HASH], [by_hash[GENESIS_HASH]])]
249 while stack:
250 block, path = stack.pop()
251 if len(path) > len(best) or (len(path) == len(best) and block["hash"] < best[-1]["hash"]):
252 best = path
253 for child in children.get(block["hash"], []):
254 stack.append((child, path + [child]))
255 return best
256
257
258def is_coinbase(tx):
259 return (
260 len(tx["vin"]) == 1
261 and tx["vin"][0]["prevout_hash"] == "0" * 64
262 and tx["vin"][0]["prevout_n"] == 0xFFFFFFFF
263 )
264
265
266def utxo_leaf_hash(outpoint, utxo):
267 payload = "|".join([
268 "BTFUTXO1",
269 outpoint,
270 str(utxo["value_satoshis"]),
271 utxo["script_pub_key_hex"],
272 str(utxo["created_height"]),
273 utxo["created_txid"],
274 ]).encode("ascii")
275 return sha256(payload)
276
277
278def utxo_root(utxos):
279 leaves = [utxo_leaf_hash(k, utxos[k]) for k in sorted(utxos)]
280 if not leaves:
281 return sha256(b"BTFUTXO1|empty").hex()
282 layer = leaves
283 while len(layer) > 1:
284 nxt = []
285 for i in range(0, len(layer), 2):
286 left = layer[i]
287 right = layer[i + 1] if i + 1 < len(layer) else left
288 nxt.append(sha256(b"BTFNODE1|" + left + right))
289 layer = nxt
290 return layer[0].hex()
291
292
293def apply_blocks(blocks, strict_duplicates=False):
294 utxos = {}
295 spent = 0
296 missing_spends = []
297 duplicate_outputs = []
298 total_coinbase = 0
299 total_fees = 0
300 normal_txs = 0
301
302 for height, block in enumerate(blocks):
303 if block["computed_merkle_root"] != block["merkle_root"]:
304 raise ParseError("bad merkle root at height %d" % height)
305 for tx in block["transactions"]:
306 coinbase = is_coinbase(tx)
307 input_value = 0
308 output_value = sum(out["value_satoshis"] for out in tx["vout"])
309 if coinbase:
310 total_coinbase += output_value
311 else:
312 normal_txs += 1
313 for txin in tx["vin"]:
314 prev = "%s:%d" % (txin["prevout_hash"], txin["prevout_n"])
315 prev_utxo = utxos.pop(prev, None)
316 if prev_utxo is None:
317 missing_spends.append({
318 "height": height,
319 "txid": tx["txid"],
320 "missing_outpoint": prev,
321 })
322 else:
323 spent += prev_utxo["value_satoshis"]
324 input_value += prev_utxo["value_satoshis"]
325 if input_value >= output_value:
326 total_fees += input_value - output_value
327 for n, out in enumerate(tx["vout"]):
328 key = "%s:%d" % (tx["txid"], n)
329 if key in utxos:
330 duplicate_outputs.append({"height": height, "outpoint": key})
331 if strict_duplicates:
332 raise ParseError("duplicate output at height %d: %s" % (height, key))
333 utxos[key] = {
334 "txid": tx["txid"],
335 "vout": n,
336 "value_satoshis": out["value_satoshis"],
337 "value_btf": out["value_btf"],
338 "script_pub_key_hex": out["script_pub_key_hex"],
339 "created_height": height,
340 "created_block_hash": block["hash"],
341 "created_txid": tx["txid"],
342 "coinbase": coinbase,
343 }
344
345 total_unspent = sum(u["value_satoshis"] for u in utxos.values())
346 return {
347 "utxos": utxos,
348 "stats": {
349 "utxo_count": len(utxos),
350 "spent_outputs": spent,
351 "total_unspent_satoshis": total_unspent,
352 "total_unspent_btf": format_money(total_unspent),
353 "total_coinbase_satoshis": total_coinbase,
354 "total_coinbase_btf": format_money(total_coinbase),
355 "coinbase_minus_unspent_satoshis": total_coinbase - total_unspent,
356 "coinbase_minus_unspent_btf": format_money(total_coinbase - total_unspent),
357 "total_fees_satoshis": total_fees,
358 "total_fees_btf": format_money(total_fees),
359 "normal_transactions": normal_txs,
360 "missing_spends": missing_spends,
361 "duplicate_outputs": duplicate_outputs,
362 },
363 }
364
365
366def genesis_transaction():
367 script_sig = (
368 bytes.fromhex("04ffff001d")
369 + b"\x01\x04"
370 + bytes([len(GENESIS_TEXT)])
371 + GENESIS_TEXT.encode("ascii")
372 )
373 pubkey_num = bytes.fromhex(
374 "5f1df16b2b704c8a578d0bbaf74d385cde12c11ee50455f3c438ef4c3fbcf649"
375 "b6de611feae06279a60939e028a8d65c10b73071a6f16719274855feb0fd8a6704"
376 )
377 script_pub_key = bytes([65]) + pubkey_num[::-1] + b"\xac"
378 tx = {
379 "version": 1,
380 "vin": [{
381 "prevout_hash": "0" * 64,
382 "prevout_n": 0xFFFFFFFF,
383 "script_sig_hex": script_sig.hex(),
384 "sequence": 0xFFFFFFFF,
385 }],
386 "vout": [{
387 "value_satoshis": 50 * COIN,
388 "value_btf": format_money(50 * COIN),
389 "script_pub_key_hex": script_pub_key.hex(),
390 }],
391 "lock_time": 0,
392 }
393 tx["txid"] = hash_hex(ser_tx(tx))
394 return tx
395
396
397def reconstructed_genesis():
398 tx = genesis_transaction()
399 header = (
400 struct.pack("<i", 1)
401 + bytes(32)
402 + bytes.fromhex(tx["txid"])[::-1]
403 + struct.pack("<III", GENESIS_TIME, GENESIS_BITS, GENESIS_NONCE)
404 )
405 return {
406 "hash": hash_hex(header),
407 "version": 1,
408 "previous_hash": "0" * 64,
409 "merkle_root": tx["txid"],
410 "computed_merkle_root": tx["txid"],
411 "time": GENESIS_TIME,
412 "time_utc": utc(GENESIS_TIME),
413 "bits": "0x%08x" % GENESIS_BITS,
414 "nonce": GENESIS_NONCE,
415 "tx_count": 1,
416 "transactions": [tx],
417 "source_file": "reconstructed-from-source-constants",
418 "source_offset": 0,
419 "size": 80 + len(ser_compact_size(1)) + len(ser_tx(tx)),
420 }
421
422
423def ser_block(block):
424 header = (
425 struct.pack("<i", block["version"])
426 + bytes.fromhex(block["previous_hash"])[::-1]
427 + bytes.fromhex(block["merkle_root"])[::-1]
428 + struct.pack("<III", block["time"], int(block["bits"], 16), block["nonce"])
429 )
430 txs = block["transactions"]
431 return header + ser_compact_size(len(txs)) + b"".join(ser_tx(tx) for tx in txs)
432
433
434def format_money(value):
435 sign = "-" if value < 0 else ""
436 value = abs(value)
437 return "%s%d.%08d" % (sign, value // COIN, value % COIN)
438
439
440def utc(ts):
441 return _dt.datetime.fromtimestamp(ts, _dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
442
443
444def default_datadir():
445 if os.name == "nt" and os.environ.get("APPDATA"):
446 return os.path.join(os.environ["APPDATA"], "Bitflash")
447 if os.environ.get("HOME"):
448 return os.path.join(os.environ["HOME"], ".bitflash")
449 return "."
450
451
452def canonical_bytes(report):
453 return (json.dumps(report, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + "\n").encode("ascii")