upleb.uk

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

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBitflash <admin@bitflash.network>2026-08-11 09:22:56 -0300
committerBitflash <admin@bitflash.network>2026-08-11 09:22:56 -0300
commit33554d8989d49a7a8d9580c187e4ade58a65b98b (patch)
treea7a7f387a5cf543bb12409e5ad126f50f3d9b497
parent6df689d0ccaa21fd6d4141138c050cdfc3bed886 (diff)
parenteb2838e3b12079f78f7d86a5b91beffd84b8a048 (diff)
Merge pull request 'tools: add deterministic UTXO commitment verifier' (#19) from utxo-set-commitment into main
-rw-r--r--README.md10
-rw-r--r--docs/utxo-commitment.md93
-rw-r--r--scripts/verify-utxo-set.py464
3 files changed, 567 insertions, 0 deletions
diff --git a/README.md b/README.md
index db35e04..d549337 100644
--- a/README.md
+++ b/README.md
@@ -73,6 +73,16 @@ scripts/verify-release.sh latest
73See [release verification](docs/release-verification.md) for the full release 73See [release verification](docs/release-verification.md) for the full release
74audit flow and the maintainer signing step. 74audit flow and the maintainer signing step.
75 75
76Bitflash also ships a deterministic UTXO-set commitment tool:
77
78```bash
79python3 scripts/verify-utxo-set.py --out utxo-report.json --json
80```
81
82It reconstructs the best chain from local block files, computes the current
83UTXO root and supply, and writes canonical JSON that independent auditors can
84compare or timestamp externally with OpenTimestamps. See
85[UTXO-set commitments](docs/utxo-commitment.md).
76Bitflash also ships a deterministic fair-launch verifier: 86Bitflash also ships a deterministic fair-launch verifier:
77 87
78```bash 88```bash
diff --git a/docs/utxo-commitment.md b/docs/utxo-commitment.md
new file mode 100644
index 0000000..ddf5450
--- /dev/null
+++ b/docs/utxo-commitment.md
@@ -0,0 +1,93 @@
1# Bitflash UTXO-set commitment
2
3`scripts/verify-utxo-set.py` reconstructs a deterministic commitment to the
4current UTXO set from local `blk*.dat` files.
5
6This is intentionally not a consensus rule yet. It is an audit tool: independent
7operators can run it against the same chain prefix and compare the resulting
8root and canonical JSON hash.
9
10```bash
11python3 scripts/verify-utxo-set.py --max-blocks 1000 --out utxo-report.json --json
12```
13
14Use `--max-blocks 0` or omit the option to scan every available block file:
15
16```bash
17python3 scripts/verify-utxo-set.py --out utxo-report.json --json
18```
19
20## What It Verifies
21
22The verifier:
23
24- parses Bitflash block files directly;
25- reconstructs the best chain rooted at the Bitflash genesis block;
26- ignores side blocks that may also be present in `blk*.dat`;
27- verifies every scanned block merkle root;
28- applies spends and creates outputs in deterministic order;
29- reports current UTXO count, current supply, mined coinbase total, fees, and
30 historical duplicate-output overwrites;
31- computes a deterministic UTXO root.
32
33## Commitment Algorithm
34
35The UTXO map is keyed by:
36
37```text
38txid:vout
39```
40
41Keys are sorted lexicographically. Each leaf is:
42
43```text
44SHA256("BTFUTXO1|" || outpoint || "|" || value_satoshis || "|" ||
45 script_pub_key_hex || "|" || created_height || "|" || created_txid)
46```
47
48The tree is then reduced pairwise. If a level has an odd number of hashes, the
49last hash is duplicated. Each parent is:
50
51```text
52SHA256("BTFNODE1|" || left_hash || right_hash)
53```
54
55The final hash is the UTXO-set root.
56
57## Deterministic Output
58
59`--json` and `--out` write canonical JSON with sorted keys, fixed separators,
60ASCII escaping, and one trailing newline. It contains no local path and no
61generation timestamp, so two auditors scanning the same chain prefix should
62produce byte-identical reports.
63
64```bash
65python3 scripts/verify-utxo-set.py --out utxo-report.json --json
66sha256sum utxo-report.json
67```
68
69## OpenTimestamps
70
71The canonical JSON can be timestamped in Bitcoin without adding any dependency
72to Bitflash consensus:
73
74```bash
75ots stamp utxo-report.json
76ots verify utxo-report.json.ots
77```
78
79## Historical Duplicates
80
81Bitflash inherited early Bitcoin behavior around duplicate transaction IDs.
82The verifier reports duplicate-output overwrites as warnings and uses
83last-write-wins semantics for the UTXO map, matching the legacy index model.
84Those warnings are part of the report so auditors can see the historical debt
85instead of relying on a polished summary.
86
87## Future Consensus Path
88
89This tool is the low-risk first step toward consensus commitments. A future
90hard-fork design can commit this root, or a Utreexo-style accumulator root, in
91coinbase or block headers after a fixed activation height. Until then this
92script provides reproducible supply and state commitments without changing what
93nodes accept.
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:]))