upleb.uk

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

summaryrefslogtreecommitdiff
path: root/scripts/verify-utxo-set.py
diff options
context:
space:
mode:
Diffstat (limited to 'scripts/verify-utxo-set.py')
-rw-r--r--scripts/verify-utxo-set.py464
1 files changed, 464 insertions, 0 deletions
diff --git a/scripts/verify-utxo-set.py b/scripts/verify-utxo-set.py
new file mode 100644
index 0000000..12b024f
--- /dev/null
+++ b/scripts/verify-utxo-set.py
@@ -0,0 +1,464 @@
1#!/usr/bin/env python3
2"""Build a deterministic Bitflash UTXO-set commitment from local blk*.dat.
3
4This is deliberately outside consensus. It lets auditors reconstruct the chain
5state from disk blocks and compare the same canonical UTXO root independently.
6"""
7
8import argparse
9import datetime as _dt
10import hashlib
11import json
12import os
13import struct
14import sys
15from pathlib import Path
16
17
18MAGIC = bytes([0xBF, 0x20, 0x5C, 0xFD])
19COIN = 100_000_000
20GENESIS_HASH = "5bd7cb255d814e48cebcdfb72da4dc87b34bd774227f8ceb546c5640f4bdc169"
21GENESIS_MERKLE = "1a45b4482532abb29b10e234d3f13132230525a339ecea91658ffa675a5b1325"
22
23
24class ParseError(Exception):
25 pass
26
27
28class Reader:
29 def __init__(self, data):
30 self.data = data
31 self.pos = 0
32
33 def take(self, n):
34 if self.pos + n > len(self.data):
35 raise ParseError("unexpected end of data")
36 out = self.data[self.pos:self.pos + n]
37 self.pos += n
38 return out
39
40 def u8(self):
41 return self.take(1)[0]
42
43 def u32(self):
44 return struct.unpack("<I", self.take(4))[0]
45
46 def i32(self):
47 return struct.unpack("<i", self.take(4))[0]
48
49 def i64(self):
50 return struct.unpack("<q", self.take(8))[0]
51
52
53def sha256(data):
54 return hashlib.sha256(data).digest()
55
56
57def sha256d(data):
58 return hashlib.sha256(hashlib.sha256(data).digest()).digest()
59
60
61def hash_hex(serialized):
62 return sha256d(serialized)[::-1].hex()
63
64
65def compact_size(r):
66 first = r.u8()
67 if first < 253:
68 return first
69 if first == 253:
70 return struct.unpack("<H", r.take(2))[0]
71 if first == 254:
72 return r.u32()
73 return struct.unpack("<Q", r.take(8))[0]
74
75
76def read_varbytes(r):
77 return r.take(compact_size(r))
78
79
80def ser_compact_size(n):
81 if n < 253:
82 return bytes([n])
83 if n <= 0xFFFF:
84 return b"\xfd" + struct.pack("<H", n)
85 if n <= 0xFFFFFFFF:
86 return b"\xfe" + struct.pack("<I", n)
87 return b"\xff" + struct.pack("<Q", n)
88
89
90def ser_varbytes(b):
91 return ser_compact_size(len(b)) + b
92
93
94def ser_tx(tx):
95 out = bytearray()
96 out += struct.pack("<i", tx["version"])
97 out += ser_compact_size(len(tx["vin"]))
98 for txin in tx["vin"]:
99 out += bytes.fromhex(txin["prevout_hash"])[::-1]
100 out += struct.pack("<I", txin["prevout_n"])
101 out += ser_varbytes(bytes.fromhex(txin["script_sig_hex"]))
102 out += struct.pack("<I", txin["sequence"])
103 out += ser_compact_size(len(tx["vout"]))
104 for txout in tx["vout"]:
105 out += struct.pack("<q", txout["value_satoshis"])
106 out += ser_varbytes(bytes.fromhex(txout["script_pub_key_hex"]))
107 out += struct.pack("<I", tx["lock_time"])
108 return bytes(out)
109
110
111def parse_tx(r):
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 tx["txid"] = hash_hex(ser_tx(tx))
129 return tx
130
131
132def merkle_root(txids):
133 if not txids:
134 return "0" * 64
135 layer = [bytes.fromhex(x)[::-1] for x in txids]
136 while len(layer) > 1:
137 nxt = []
138 for i in range(0, len(layer), 2):
139 left = layer[i]
140 right = layer[i + 1] if i + 1 < len(layer) else left
141 nxt.append(sha256d(left + right))
142 layer = nxt
143 return layer[0][::-1].hex()
144
145
146def parse_block(payload, file_name="", offset=0):
147 r = Reader(payload)
148 header = r.take(80)
149 version, = struct.unpack("<i", header[0:4])
150 prev = header[4:36][::-1].hex()
151 merkle = header[36:68][::-1].hex()
152 ntime, bits, nonce = struct.unpack("<III", header[68:80])
153 txs = [parse_tx(r) for _ in range(compact_size(r))]
154 if r.pos != len(payload):
155 raise ParseError("trailing bytes inside block")
156 txids = [tx["txid"] for tx in txs]
157 return {
158 "hash": hash_hex(header),
159 "version": version,
160 "previous_hash": prev,
161 "merkle_root": merkle,
162 "computed_merkle_root": merkle_root(txids),
163 "time": ntime,
164 "time_utc": utc(ntime),
165 "bits": "0x%08x" % bits,
166 "nonce": nonce,
167 "tx_count": len(txs),
168 "transactions": txs,
169 "source_file": file_name,
170 "source_offset": offset,
171 }
172
173
174def read_blocks(datadir, max_blocks):
175 blocks = []
176 for path in sorted(Path(datadir).glob("blk*.dat")):
177 data = path.read_bytes()
178 pos = 0
179 while True:
180 idx = data.find(MAGIC, pos)
181 if idx < 0:
182 break
183 if idx + 8 > len(data):
184 break
185 size = struct.unpack("<I", data[idx + 4:idx + 8])[0]
186 start = idx + 8
187 end = start + size
188 if end > len(data):
189 break
190 blocks.append(parse_block(data[start:end], path.name, start))
191 if max_blocks and len(blocks) >= max_blocks:
192 return blocks
193 pos = end
194 return blocks
195
196
197def select_main_chain(blocks):
198 by_hash = {}
199 for block in blocks:
200 by_hash.setdefault(block["hash"], block)
201 if GENESIS_HASH not in by_hash:
202 raise ParseError("genesis block not found in block files")
203
204 children = {}
205 for block in by_hash.values():
206 children.setdefault(block["previous_hash"], []).append(block)
207 for v in children.values():
208 v.sort(key=lambda b: (b["hash"], b["source_file"], b["source_offset"]))
209
210 best = []
211 stack = [(by_hash[GENESIS_HASH], [by_hash[GENESIS_HASH]])]
212 while stack:
213 block, path = stack.pop()
214 if len(path) > len(best) or (len(path) == len(best) and block["hash"] < best[-1]["hash"]):
215 best = path
216 for child in children.get(block["hash"], []):
217 stack.append((child, path + [child]))
218 return best
219
220
221def is_coinbase(tx):
222 return (
223 len(tx["vin"]) == 1
224 and tx["vin"][0]["prevout_hash"] == "0" * 64
225 and tx["vin"][0]["prevout_n"] == 0xFFFFFFFF
226 )
227
228
229def utxo_leaf_hash(outpoint, utxo):
230 # Text fields are length-delimited by separators and all variable data is
231 # hex or decimal ASCII. That keeps the commitment language-neutral.
232 payload = "|".join([
233 "BTFUTXO1",
234 outpoint,
235 str(utxo["value_satoshis"]),
236 utxo["script_pub_key_hex"],
237 str(utxo["created_height"]),
238 utxo["created_txid"],
239 ]).encode("ascii")
240 return sha256(payload)
241
242
243def utxo_root(utxos):
244 leaves = [utxo_leaf_hash(k, utxos[k]) for k in sorted(utxos)]
245 if not leaves:
246 return sha256(b"BTFUTXO1|empty").hex()
247 layer = leaves
248 while len(layer) > 1:
249 nxt = []
250 for i in range(0, len(layer), 2):
251 left = layer[i]
252 right = layer[i + 1] if i + 1 < len(layer) else left
253 nxt.append(sha256(b"BTFNODE1|" + left + right))
254 layer = nxt
255 return layer[0].hex()
256
257
258def apply_blocks(blocks):
259 utxos = {}
260 spent = 0
261 missing_spends = []
262 duplicate_outputs = []
263 total_coinbase = 0
264 total_fees = 0
265 normal_txs = 0
266
267 for height, block in enumerate(blocks):
268 if block["computed_merkle_root"] != block["merkle_root"]:
269 raise ParseError("bad merkle root at height %d" % height)
270 for tx_index, tx in enumerate(block["transactions"]):
271 coinbase = is_coinbase(tx)
272 input_value = 0
273 output_value = sum(out["value_satoshis"] for out in tx["vout"])
274 if coinbase:
275 total_coinbase += output_value
276 else:
277 normal_txs += 1
278 for txin in tx["vin"]:
279 prev = "%s:%d" % (txin["prevout_hash"], txin["prevout_n"])
280 prev_utxo = utxos.pop(prev, None)
281 if prev_utxo is None:
282 missing_spends.append({
283 "height": height,
284 "txid": tx["txid"],
285 "missing_outpoint": prev,
286 })
287 else:
288 spent += prev_utxo["value_satoshis"]
289 input_value += prev_utxo["value_satoshis"]
290 if input_value >= output_value:
291 total_fees += input_value - output_value
292 for n, out in enumerate(tx["vout"]):
293 key = "%s:%d" % (tx["txid"], n)
294 if key in utxos:
295 duplicate_outputs.append({"height": height, "outpoint": key})
296 utxos[key] = {
297 "txid": tx["txid"],
298 "vout": n,
299 "value_satoshis": out["value_satoshis"],
300 "value_btf": out["value_btf"],
301 "script_pub_key_hex": out["script_pub_key_hex"],
302 "created_height": height,
303 "created_block_hash": block["hash"],
304 "created_txid": tx["txid"],
305 "coinbase": coinbase,
306 }
307
308 total_unspent = sum(u["value_satoshis"] for u in utxos.values())
309 return {
310 "utxos": utxos,
311 "stats": {
312 "utxo_count": len(utxos),
313 "spent_outputs": spent,
314 "total_unspent_satoshis": total_unspent,
315 "total_unspent_btf": format_money(total_unspent),
316 "total_coinbase_satoshis": total_coinbase,
317 "total_coinbase_btf": format_money(total_coinbase),
318 "coinbase_minus_unspent_satoshis": total_coinbase - total_unspent,
319 "coinbase_minus_unspent_btf": format_money(total_coinbase - total_unspent),
320 "total_fees_satoshis": total_fees,
321 "total_fees_btf": format_money(total_fees),
322 "normal_transactions": normal_txs,
323 "missing_spends": missing_spends,
324 "duplicate_outputs": duplicate_outputs,
325 },
326 }
327
328
329def format_money(value):
330 sign = "-" if value < 0 else ""
331 value = abs(value)
332 return "%s%d.%08d" % (sign, value // COIN, value % COIN)
333
334
335def utc(ts):
336 return _dt.datetime.fromtimestamp(ts, _dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
337
338
339def default_datadir():
340 if os.name == "nt" and os.environ.get("APPDATA"):
341 return os.path.join(os.environ["APPDATA"], "Bitflash")
342 if os.environ.get("HOME"):
343 return os.path.join(os.environ["HOME"], ".bitflash")
344 return "."
345
346
347def canonical_bytes(report):
348 return (json.dumps(report, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + "\n").encode("ascii")
349
350
351def build_report(raw_blocks, include_utxos=False):
352 blocks = select_main_chain(raw_blocks)
353 state = apply_blocks(blocks)
354 utxos = state["utxos"]
355 stats = state["stats"]
356 tip = blocks[-1]
357 root = utxo_root(utxos)
358 checks = {
359 "genesis_hash_matches_source": blocks[0]["hash"] == GENESIS_HASH,
360 "genesis_merkle_matches_source": blocks[0]["merkle_root"] == GENESIS_MERKLE,
361 "all_scanned_merkle_roots_match": all(b["computed_merkle_root"] == b["merkle_root"] for b in blocks),
362 "no_missing_spends": len(stats["missing_spends"]) == 0,
363 }
364 warnings = {
365 "legacy_duplicate_output_overwrites": len(stats["duplicate_outputs"]),
366 }
367 report = {
368 "schema": "bitflash-utxo-set-commitment-1",
369 "deterministic": True,
370 "chain": {
371 "name": "Bitflash",
372 "ticker": "BTF",
373 "message_start_hex": MAGIC.hex(),
374 "coin": COIN,
375 },
376 "commitment": {
377 "algorithm": "sorted-outpoint-merkle-sha256",
378 "leaf_domain": "BTFUTXO1",
379 "node_domain": "BTFNODE1",
380 "root": root,
381 },
382 "scanned": {
383 "raw_block_count": len(raw_blocks),
384 "side_blocks_ignored": len(raw_blocks) - len(blocks),
385 "main_chain_block_count": len(blocks),
386 "first_height": 0,
387 "last_height": len(blocks) - 1,
388 "tip_hash": tip["hash"],
389 "tip_time": tip["time"],
390 "tip_time_utc": tip["time_utc"],
391 },
392 "stats": stats,
393 "checks": checks,
394 "warnings": warnings,
395 "verdict": "UTXO_COMMITMENT_VERIFIED" if all(checks.values()) else "CHECKS_FAILED",
396 }
397 if include_utxos:
398 report["utxos"] = {k: utxos[k] for k in sorted(utxos)}
399 return report
400
401
402def print_text(report):
403 data_hash = hashlib.sha256(canonical_bytes(report)).hexdigest()
404 print("Bitflash UTXO-set commitment verifier")
405 print("schema: %s" % report["schema"])
406 print("canonical report sha256: %s" % data_hash)
407 print()
408 print("Commitment")
409 print(" root: %s" % report["commitment"]["root"])
410 print(" algo: %s" % report["commitment"]["algorithm"])
411 print()
412 print("Scanned")
413 print(" main chain blocks: %d" % report["scanned"]["main_chain_block_count"])
414 print(" raw disk blocks: %d" % report["scanned"]["raw_block_count"])
415 print(" side ignored: %d" % report["scanned"]["side_blocks_ignored"])
416 print(" height: %d..%d" % (report["scanned"]["first_height"], report["scanned"]["last_height"]))
417 print(" tip: %s" % report["scanned"]["tip_hash"])
418 print()
419 print("Supply")
420 print(" utxos: %d" % report["stats"]["utxo_count"])
421 print(" supply: %s BTF" % report["stats"]["total_unspent_btf"])
422 print(" mined: %s BTF" % report["stats"]["total_coinbase_btf"])
423 print(" delta: %s BTF" % report["stats"]["coinbase_minus_unspent_btf"])
424 print(" fees: %s BTF" % report["stats"]["total_fees_btf"])
425 if report["warnings"]["legacy_duplicate_output_overwrites"]:
426 print(" legacy duplicate overwrites: %d" % report["warnings"]["legacy_duplicate_output_overwrites"])
427 print()
428 print("Checks")
429 for key in sorted(report["checks"]):
430 print(" %s %s" % ("OK " if report["checks"][key] else "FAIL", key))
431 print()
432 print("Verdict: %s" % report["verdict"])
433 print()
434 print("OpenTimestamps")
435 print(" canonical JSON can be stamped later with: ots stamp <report.json>")
436
437
438def main(argv):
439 ap = argparse.ArgumentParser(description=__doc__)
440 ap.add_argument("--datadir", default=default_datadir(), help="Bitflash data directory")
441 ap.add_argument("--max-blocks", type=int, default=0,
442 help="number of blocks to scan; 0 means all block files")
443 ap.add_argument("--include-utxos", action="store_true",
444 help="include the full sorted UTXO map in JSON output")
445 ap.add_argument("--json", action="store_true", help="print canonical JSON")
446 ap.add_argument("--out", help="write canonical JSON to this path")
447 args = ap.parse_args(argv)
448
449 blocks = read_blocks(args.datadir, args.max_blocks)
450 if not blocks:
451 raise SystemExit("no Bitflash blocks found in %s" % args.datadir)
452 report = build_report(blocks, include_utxos=args.include_utxos)
453 data = canonical_bytes(report)
454 if args.out:
455 Path(args.out).write_bytes(data)
456 if args.json:
457 sys.stdout.buffer.write(data)
458 else:
459 print_text(report)
460 return 0 if report["verdict"] == "UTXO_COMMITMENT_VERIFIED" else 1
461
462
463if __name__ == "__main__":
464 raise SystemExit(main(sys.argv[1:]))