upleb.uk

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

summaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
Diffstat (limited to 'scripts')
-rw-r--r--scripts/bitflash-mining-calculator.py206
-rw-r--r--scripts/bitflash_chain.py450
-rwxr-xr-xscripts/build-explorer.py376
-rw-r--r--scripts/build-pool-index.py174
-rwxr-xr-xscripts/check-money-log-lines.sh38
-rwxr-xr-x[-rw-r--r--]scripts/make-release-checksums.sh2
-rwxr-xr-xscripts/package-windows-tor.sh187
-rw-r--r--scripts/prove-utxo.py186
-rw-r--r--scripts/smoke-managed-tor-onion.ps1174
-rw-r--r--scripts/verify-fair-launch.py166
-rwxr-xr-x[-rw-r--r--]scripts/verify-release.sh108
-rw-r--r--scripts/verify-utxo-proof.py146
-rw-r--r--scripts/verify-utxo-set.py131
13 files changed, 2294 insertions, 50 deletions
diff --git a/scripts/bitflash-mining-calculator.py b/scripts/bitflash-mining-calculator.py
new file mode 100644
index 0000000..1fcc1a9
--- /dev/null
+++ b/scripts/bitflash-mining-calculator.py
@@ -0,0 +1,206 @@
1#!/usr/bin/env python3
2"""Estimate Bitflash mining rewards and electricity cost.
3
4This is a local WhatToMine-style calculator for Bitflash. It does not fetch
5prices, does not talk to the node, and does not affect consensus.
6"""
7
8import argparse
9import json
10import math
11import sys
12
13
14HASHES_AT_DIFF_1 = 4096.0
15DEFAULT_BLOCK_TIME = 120.0
16DEFAULT_BLOCK_REWARD = 50.0
17
18
19UNITS = {
20 "h": 1.0,
21 "h/s": 1.0,
22 "hs": 1.0,
23 "kh": 1_000.0,
24 "kh/s": 1_000.0,
25 "khs": 1_000.0,
26 "mh": 1_000_000.0,
27 "mh/s": 1_000_000.0,
28 "mhs": 1_000_000.0,
29 "gh": 1_000_000_000.0,
30 "gh/s": 1_000_000_000.0,
31 "ghs": 1_000_000_000.0,
32}
33
34
35def parse_hashrate(value, unit):
36 try:
37 n = float(value)
38 except ValueError:
39 raise SystemExit("invalid hashrate: %s" % value)
40 mul = UNITS.get(unit.lower())
41 if mul is None:
42 raise SystemExit("unknown hashrate unit: %s" % unit)
43 if n < 0:
44 raise SystemExit("hashrate must not be negative")
45 return n * mul
46
47
48def fmt_hashrate(hps):
49 units = [("GH/s", 1_000_000_000.0), ("MH/s", 1_000_000.0), ("kH/s", 1_000.0), ("H/s", 1.0)]
50 for name, div in units:
51 if hps >= div:
52 return "%.4g %s" % (hps / div, name)
53 return "%.4g H/s" % hps
54
55
56def fmt_money(x):
57 return "%.8f" % x
58
59
60def estimate(args):
61 miner_hps = parse_hashrate(args.hashrate, args.hashrate_unit)
62 if args.network_hashrate is not None:
63 network_hps = parse_hashrate(args.network_hashrate, args.network_hashrate_unit)
64 difficulty = network_hps * args.block_time / HASHES_AT_DIFF_1
65 network_source = "network_hashrate"
66 else:
67 if args.difficulty is None:
68 raise SystemExit("provide either --network-hashrate or --difficulty")
69 difficulty = float(args.difficulty)
70 if difficulty <= 0:
71 raise SystemExit("difficulty must be positive")
72 network_hps = difficulty * HASHES_AT_DIFF_1 / args.block_time
73 network_source = "difficulty"
74
75 if network_hps <= 0:
76 raise SystemExit("network hashrate must be positive")
77
78 block_reward = float(args.block_reward)
79 block_time = float(args.block_time)
80 pool_fee_percent = float(args.pool_fee)
81 price = float(args.price)
82 watts = float(args.watts)
83 kwh_cost = float(args.kwh_cost)
84 if block_reward < 0 or block_time <= 0 or pool_fee_percent < 0 or price < 0 or watts < 0 or kwh_cost < 0:
85 raise SystemExit("numeric inputs must be non-negative; block time must be positive")
86
87 share = miner_hps / network_hps
88 blocks_per_day = 86400.0 / block_time
89 gross_btf_day = blocks_per_day * block_reward * share
90 net_btf_day = gross_btf_day * max(0.0, 1.0 - pool_fee_percent / 100.0)
91 revenue_day = net_btf_day * price
92 cost_day = watts / 1000.0 * 24.0 * kwh_cost
93 profit_day = revenue_day - cost_day
94 expected_seconds_per_block = math.inf if miner_hps <= 0 else difficulty * HASHES_AT_DIFF_1 / miner_hps
95
96 periods = {
97 "hour": 1.0 / 24.0,
98 "day": 1.0,
99 "week": 7.0,
100 "month_30d": 30.0,
101 }
102 rows = {}
103 for name, days in periods.items():
104 rows[name] = {
105 "btf": net_btf_day * days,
106 "revenue": revenue_day * days,
107 "electricity_cost": cost_day * days,
108 "profit": profit_day * days,
109 }
110
111 return {
112 "schema": "bitflash-mining-calculator-1",
113 "network_source": network_source,
114 "inputs": {
115 "miner_hashrate_hps": miner_hps,
116 "miner_hashrate": fmt_hashrate(miner_hps),
117 "network_hashrate_hps": network_hps,
118 "network_hashrate": fmt_hashrate(network_hps),
119 "difficulty": difficulty,
120 "block_time_seconds": block_time,
121 "block_reward_btf": block_reward,
122 "pool_fee_percent": pool_fee_percent,
123 "price_per_btf": price,
124 "power_watts": watts,
125 "electricity_per_kwh": kwh_cost,
126 },
127 "estimates": {
128 "miner_network_share": share,
129 "blocks_per_day": blocks_per_day,
130 "expected_time_to_solo_block_seconds": expected_seconds_per_block,
131 "expected_time_to_solo_block_hours": expected_seconds_per_block / 3600.0,
132 "gross_btf_per_day": gross_btf_day,
133 "net_btf_per_day": net_btf_day,
134 "periods": rows,
135 },
136 }
137
138
139def print_text(result):
140 i = result["inputs"]
141 e = result["estimates"]
142 print("Bitflash mining calculator")
143 print("schema: %s" % result["schema"])
144 print()
145 print("Network")
146 print(" source: %s" % result["network_source"])
147 print(" hashrate: %s" % i["network_hashrate"])
148 print(" difficulty: %.8f" % i["difficulty"])
149 print(" block time: %.0fs" % i["block_time_seconds"])
150 print(" reward: %s BTF" % fmt_money(i["block_reward_btf"]))
151 print()
152 print("Miner")
153 print(" hashrate: %s" % i["miner_hashrate"])
154 print(" share: %.8f%%" % (e["miner_network_share"] * 100.0))
155 if math.isinf(e["expected_time_to_solo_block_seconds"]):
156 print(" solo block: never")
157 else:
158 print(" solo block: %.2f hours expected average" % e["expected_time_to_solo_block_hours"])
159 print()
160 print("Economics")
161 print(" price: $%.8f / BTF" % i["price_per_btf"])
162 print(" power: %.2f W" % i["power_watts"])
163 print(" kWh cost: $%.4f" % i["electricity_per_kwh"])
164 print(" pool fee: %.2f%%" % i["pool_fee_percent"])
165 print()
166 print("Estimated rewards")
167 print(" %-9s %16s %12s %12s %12s" % ("period", "BTF", "revenue", "power", "profit"))
168 for name in ["hour", "day", "week", "month_30d"]:
169 row = e["periods"][name]
170 print(" %-9s %16s $%11.4f $%11.4f $%11.4f" % (
171 name,
172 fmt_money(row["btf"]),
173 row["revenue"],
174 row["electricity_cost"],
175 row["profit"],
176 ))
177 print()
178 print("Note: this is an expectation, not a promise. Solo mining variance can be huge.")
179
180
181def main(argv):
182 ap = argparse.ArgumentParser(description=__doc__)
183 ap.add_argument("--hashrate", required=True, help="miner hashrate number")
184 ap.add_argument("--hashrate-unit", default="kh/s", choices=sorted(UNITS), help="miner hashrate unit")
185 ap.add_argument("--network-hashrate", help="network hashrate number")
186 ap.add_argument("--network-hashrate-unit", default="kh/s", choices=sorted(UNITS), help="network hashrate unit")
187 ap.add_argument("--difficulty", type=float, help="Bitflash difficulty; diff 1 expects 4096 hashes")
188 ap.add_argument("--block-time", type=float, default=DEFAULT_BLOCK_TIME, help="target block time in seconds")
189 ap.add_argument("--block-reward", type=float, default=DEFAULT_BLOCK_REWARD, help="block reward in BTF")
190 ap.add_argument("--pool-fee", type=float, default=0.0, help="pool fee percent")
191 ap.add_argument("--price", type=float, default=0.0, help="price per BTF in USD or your chosen fiat unit")
192 ap.add_argument("--watts", type=float, default=0.0, help="miner or whole-system power in watts")
193 ap.add_argument("--kwh-cost", type=float, default=0.0, help="electricity cost per kWh")
194 ap.add_argument("--json", action="store_true", help="print JSON")
195 args = ap.parse_args(argv)
196
197 result = estimate(args)
198 if args.json:
199 print(json.dumps(result, sort_keys=True, indent=2))
200 else:
201 print_text(result)
202 return 0
203
204
205if __name__ == "__main__":
206 raise SystemExit(main(sys.argv[1:]))
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")
diff --git a/scripts/build-explorer.py b/scripts/build-explorer.py
new file mode 100755
index 0000000..dbb8e17
--- /dev/null
+++ b/scripts/build-explorer.py
@@ -0,0 +1,376 @@
1#!/usr/bin/env python3
2"""Build a static Bitflash block explorer from local blk*.dat files."""
3
4import argparse
5import hashlib
6import json
7import shutil
8import sys
9from pathlib import Path
10
11import bitflash_chain as chain
12
13
14ADDRESS_VERSION = 25
15B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
16
17
18def b58check(payload):
19 v = bytes([ADDRESS_VERSION]) + payload
20 v += chain.sha256d(v)[:4]
21 n = int.from_bytes(v, "big")
22 s = ""
23 while n > 0:
24 n, r = divmod(n, 58)
25 s = B58[r] + s
26 for byte in v:
27 if byte == 0:
28 s = "1" + s
29 else:
30 break
31 return s
32
33
34def hash160(b):
35 return hashlib.new("ripemd160", hashlib.sha256(b).digest()).digest()
36
37
38def spk_info(script_hex):
39 try:
40 spk = bytes.fromhex(script_hex)
41 except ValueError:
42 return {"type": "malformed", "hex": script_hex[:80]}
43 if (
44 len(spk) == 25 and spk[0] == 0x76 and spk[1] == 0xA9
45 and spk[2] == 0x14 and spk[23] == 0x88 and spk[24] == 0xAC
46 ):
47 return {"type": "p2pkh", "address": b58check(spk[3:23])}
48 if len(spk) >= 35 and spk[-1] == 0xAC:
49 if spk[0] == 0x41 and len(spk) == 67:
50 pk = spk[1:66]
51 return {"type": "p2pk", "pubkey": pk.hex(), "address": b58check(hash160(pk))}
52 if spk[0] == 0x21 and len(spk) == 35:
53 pk = spk[1:34]
54 return {"type": "p2pk", "pubkey": pk.hex(), "address": b58check(hash160(pk))}
55 return {"type": "other", "hex": script_hex[:80]}
56
57
58def explorer_tx(tx):
59 vin = []
60 for txin in tx["vin"]:
61 coinbase = (
62 txin["prevout_hash"] == "0" * 64
63 and txin["prevout_n"] == 0xFFFFFFFF
64 )
65 vin.append({
66 "coinbase": coinbase,
67 "prev": None if coinbase else txin["prevout_hash"],
68 "vout": None if coinbase else txin["prevout_n"],
69 })
70 vout = []
71 for out in tx["vout"]:
72 vout.append(dict({"value": out["value_satoshis"]}, **spk_info(out["script_pub_key_hex"])))
73 return {"txid": tx["txid"], "vin": vin, "vout": vout, "size": len(chain.ser_tx(tx))}
74
75
76def parse_args(argv):
77 ap = argparse.ArgumentParser(description=__doc__)
78 ap.add_argument("--datadir", help="Bitflash data directory containing blk*.dat")
79 ap.add_argument("--max-blocks", type=int, default=0,
80 help="number of blocks to scan; 0 means all block files")
81 ap.add_argument("paths", nargs="+",
82 help="with --datadir: <out-dir>; otherwise: <blk*.dat>... <out-dir>")
83 args = ap.parse_args(argv)
84 if args.datadir:
85 if len(args.paths) != 1:
86 raise SystemExit("usage with --datadir: build-explorer.py --datadir DATADIR <out-dir>")
87 args.block_files = None
88 args.out_dir = args.paths[0]
89 else:
90 if len(args.paths) < 2:
91 raise SystemExit("usage: build-explorer.py <blk*.dat>... <out-dir>")
92 args.block_files = args.paths[:-1]
93 args.out_dir = args.paths[-1]
94 return args
95
96
97def load_blocks(args):
98 if args.datadir:
99 return chain.read_blocks(args.datadir, args.max_blocks)
100 return chain.read_blocks_from_files(args.block_files, args.max_blocks)
101
102
103def build_explorer(raw_blocks, out_dir):
104 try:
105 blocks = chain.select_main_chain(raw_blocks)
106 except chain.ParseError as e:
107 raise SystemExit(str(e))
108 out = Path(out_dir)
109 (out / "block").mkdir(parents=True, exist_ok=True)
110
111 summaries = []
112 for height, block in enumerate(blocks):
113 txs = [explorer_tx(tx) for tx in block["transactions"]]
114 detail = {
115 "height": height,
116 "hash": block["hash"],
117 "prev": block["previous_hash"],
118 "merkleRoot": block["merkle_root"],
119 "version": block["version"],
120 "time": block["time"],
121 "bits": int(block["bits"], 16),
122 "nonce": block["nonce"],
123 "size": block["size"],
124 "txs": txs,
125 }
126 cb = txs[0]["vout"][0] if txs and txs[0]["vout"] else {}
127 summaries.append({
128 "height": height,
129 "hash": block["hash"],
130 "time": block["time"],
131 "nTx": len(txs),
132 "size": block["size"],
133 "cbValue": cb.get("value", 0),
134 "cbAddr": cb.get("address", ""),
135 })
136 (out / "block" / ("%d.json" % height)).write_text(
137 json.dumps(detail, sort_keys=True, separators=(",", ":")) + "\n",
138 encoding="ascii",
139 )
140
141 summaries.sort(key=lambda x: -x["height"])
142 (out / "blocks.json").write_text(
143 json.dumps({
144 "network": "bitflash",
145 "tipHeight": len(blocks) - 1,
146 "count": len(blocks),
147 "blocks": summaries,
148 }, sort_keys=True, separators=(",", ":")) + "\n",
149 encoding="ascii",
150 )
151 (out / "index.html").write_text(INDEX_HTML, encoding="utf-8")
152 (out / "style.css").write_text(STYLE_CSS, encoding="utf-8")
153 (out / "explorer.js").write_text(EXPLORER_JS, encoding="utf-8")
154 logo = Path(__file__).resolve().parents[1] / "docs" / "logo.png"
155 if logo.exists():
156 shutil.copyfile(logo, out / "logo.png")
157 return len(blocks), len(blocks) - 1
158
159
160def main(argv):
161 args = parse_args(argv)
162 raw_blocks = load_blocks(args)
163 if not raw_blocks:
164 raise SystemExit("no Bitflash blocks parsed")
165 count, tip = build_explorer(raw_blocks, args.out_dir)
166 print("explorer built: %d main-chain blocks, tip %d, into %s" % (count, tip, args.out_dir))
167 return 0
168
169
170INDEX_HTML = r"""<!doctype html>
171<html lang="en">
172<head>
173<meta charset="utf-8">
174<meta name="viewport" content="width=device-width, initial-scale=1">
175<title>Bitflash Block Explorer</title>
176<meta name="description" content="Read-only Bitflash block explorer generated from local blk*.dat files.">
177<link rel="icon" href="logo.png" type="image/png">
178<link rel="stylesheet" href="style.css">
179<script src="explorer.js" defer></script>
180</head>
181<body>
182<header class="topbar">
183 <a class="brand" href="https://bitflash.network/"><img src="logo.png" width="22" height="22" alt="">Bitflash</a>
184 <nav aria-label="Bitflash sites">
185 <a href="https://bitflash.network/">home</a>
186 <a href="https://git.bitflash.network/bitflash/bitflash">code</a>
187 <a href="https://docs.bitflash.network/">docs</a>
188 <a href="https://releases.bitflash.network/">downloads</a>
189 <a href="https://explorer.bitflash.network/" aria-current="page">explorer</a>
190 <a href="https://status.bitflash.network/">status</a>
191 </nav>
192 <span class="meta" id="tip"></span>
193</header>
194<div class="wrap">
195 <h1>Block explorer <button class="link" id="home" hidden>back to blocks</button></h1>
196 <p class="note">Read-only view of the main chain: blocks, their transactions, and the coinbase payout. There is no mempool and no per-address history here.</p>
197 <div id="listview">
198 <input id="q" placeholder="Search by height or block hash">
199 <div class="scroll"><table>
200 <thead><tr><th>Height</th><th>Time (UTC)</th><th>Txs</th><th>Size</th><th>Coinbase</th><th>To</th></tr></thead>
201 <tbody id="rows"></tbody>
202 </table></div>
203 <p class="mut" id="more"></p>
204 </div>
205 <div id="detail" hidden></div>
206</div>
207</body>
208</html>
209"""
210
211
212STYLE_CSS = r""":root{
213 color-scheme: light dark;
214 --bg:#fff; --bg-2:#f6f6f6; --fg:#111; --dim:#6b6b6b; --line:#d7d7d7; --line-hard:#111;
215 --accent:#0b7a34; --accent-2:#12a047; --bar:3rem;
216 --mono: ui-monospace,"Cascadia Mono","Segoe UI Mono",Consolas,"DejaVu Sans Mono","Liberation Mono","Courier New",monospace;
217}
218@media (prefers-color-scheme: dark){:root{
219 --bg:#000; --bg-2:#0b0b0b; --fg:#dcdcdc; --dim:#8a8a8a; --line:#242424; --line-hard:#4a4a4a;
220 --accent:#2ee06a; --accent-2:#6bff9f;
221}}
222*{box-sizing:border-box;}
223html,body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.55 var(--mono);-webkit-text-size-adjust:100%;}
224a{color:var(--accent);}
225.topbar{display:flex;align-items:center;gap:1rem;flex-wrap:wrap;min-height:var(--bar);
226 padding:.5rem clamp(.9rem,2.5vw,3rem);border-bottom:1px solid var(--line);position:sticky;top:0;background:var(--bg);z-index:5;}
227.brand{display:flex;align-items:center;gap:.6rem;text-decoration:none;color:var(--fg);font-weight:bold;}
228.brand img{width:22px;height:22px;display:block;}
229.topbar nav{display:flex;flex-wrap:wrap;gap:.15rem 1.4rem;font-size:.86rem;}
230.topbar nav a{text-decoration:none;color:var(--dim);}
231.topbar nav a::before{content:"/";color:var(--line-hard);margin-right:.3rem;}
232.topbar nav a:hover{color:var(--fg);text-decoration:underline;}
233.topbar nav a[aria-current="page"]{color:var(--accent);font-weight:bold;}
234.topbar nav a[aria-current="page"]::before{color:var(--accent);}
235.topbar .meta{margin-left:auto;font-size:.76rem;color:var(--dim);letter-spacing:.06em;}
236.wrap{padding:1.4rem clamp(.9rem,2.5vw,3rem) 4rem;}
237h1{font-size:1rem;margin:0 0 .3rem;letter-spacing:.02em;}
238h2{font-size:.95rem;margin:1.2rem 0 .4rem;}
239.mut{color:var(--dim);}
240.note{color:var(--dim);font-size:.82rem;max-width:60rem;margin:.2rem 0 1rem;}
241input{width:100%;max-width:28rem;padding:.5rem .7rem;border:1px solid var(--line);background:var(--bg-2);color:var(--fg);font:inherit;font-size:.86rem;}
242input:focus{outline:none;border-color:var(--accent);}
243.scroll{overflow-x:auto;}
244table{width:100%;border-collapse:collapse;margin-top:.8rem;font-size:.86rem;}
245th,td{text-align:left;padding:.5rem .6rem;border-bottom:1px solid var(--line);font-variant-numeric:tabular-nums;}
246th{font-size:.72rem;text-transform:uppercase;letter-spacing:.1em;color:var(--dim);font-weight:normal;}
247tr.blk{cursor:pointer;}
248tr.blk:hover td{background:var(--bg-2);}
249code{font-size:.8rem;word-break:break-all;color:var(--fg);}
250.card{border:1px solid var(--line);background:var(--bg-2);padding:.9rem 1rem;margin-top:.8rem;}
251.kv{display:grid;grid-template-columns:11rem 1fr;gap:.25rem .8rem;font-size:.82rem;}
252.kv div:nth-child(odd){color:var(--dim);}
253.tx{border-top:1px solid var(--line);padding:.7rem 0;}
254.io{display:grid;grid-template-columns:1fr 1fr;gap:1rem;}
255@media (max-width:640px){.io{grid-template-columns:1fr;}.kv{grid-template-columns:7rem 1fr;}.topbar .meta{margin-left:0;width:100%;}}
256.pill{display:inline-block;font-size:.68rem;padding:.05rem .5rem;border:1px solid var(--line);color:var(--dim);text-transform:uppercase;letter-spacing:.05em;}
257button.link{background:none;border:none;color:var(--accent);cursor:pointer;font:inherit;padding:0;text-decoration:underline;}
258"""
259
260
261EXPLORER_JS = r"""
262var SUM = [], SHOWN = 200;
263function btf(sat){ return (sat/1e8).toFixed(8).replace(/0+$/,'').replace(/\.$/,'')+" BTF"; }
264function ts(t){ return new Date(t*1000).toISOString().replace('T',' ').replace('.000Z',' UTC'); }
265function short(h){ return h ? h.slice(0,10)+"..."+h.slice(-6) : "-"; }
266function E(tag, cls, text){
267 var e = document.createElement(tag);
268 if(cls) e.className = cls;
269 if(text !== undefined) e.textContent = text;
270 return e;
271}
272function code(text){ return E('code', '', text); }
273function clear(e){ while(e.firstChild) e.removeChild(e.firstChild); }
274function append(parent){
275 for(var i=1;i<arguments.length;i++) parent.appendChild(arguments[i]);
276 return parent;
277}
278function row(cells, cls){
279 var tr = E('tr', cls || '');
280 cells.forEach(function(c){ append(tr, E('td', '', c)); });
281 return tr;
282}
283function renderList(filter){
284 var q=(filter||"").trim().toLowerCase(), rows=document.getElementById('rows');
285 clear(rows);
286 var list = q ? SUM.filter(function(b){ return String(b.height)===q || b.hash.indexOf(q)===0; }) : SUM.slice(0,SHOWN);
287 if(!list.length){
288 var empty = E('tr');
289 var td = E('td', 'mut', 'no match');
290 td.colSpan = 6;
291 append(empty, td); append(rows, empty);
292 }
293 list.forEach(function(b){
294 var tr = row([String(b.height), ts(b.time), String(b.nTx), b.size+' B', btf(b.cbValue), ''], 'blk');
295 tr.dataset.h = b.height;
296 clear(tr.children[5]);
297 append(tr.children[5], code(b.cbAddr || '-'));
298 tr.onclick = function(){ openBlock(tr.dataset.h); };
299 append(rows, tr);
300 });
301 document.getElementById('more').textContent = (!q && SUM.length>SHOWN) ? ("showing latest "+SHOWN+" of "+SUM.length+" blocks; search to find any") : "";
302}
303function kv(parent, key, value, isCode){
304 append(parent, E('div', '', key));
305 append(parent, isCode ? append(E('div'), code(value)) : E('div', '', value));
306}
307function ioSide(title, items, render){
308 var side = E('div');
309 append(side, E('div', 'mut', title));
310 items.forEach(function(item){ append(side, render(item)); });
311 return side;
312}
313function openBlock(h){
314 fetch('block/'+h+'.json').then(function(r){return r.json();}).then(function(b){
315 var detail = document.getElementById('detail');
316 clear(detail);
317 append(detail, E('h2', '', 'Block '+b.height));
318 var card = E('div', 'card'), grid = E('div', 'kv');
319 kv(grid, 'Height', String(b.height));
320 kv(grid, 'Hash', b.hash, true);
321 var prev = E('div');
322 if(b.height > 0){
323 var btn = E('button', 'link');
324 append(btn, code(short(b.prev)));
325 btn.onclick = function(){ openBlock(b.height - 1); };
326 append(prev, btn);
327 } else {
328 append(prev, code(short(b.prev)));
329 }
330 append(grid, E('div', '', 'Previous'), prev);
331 kv(grid, 'Merkle root', b.merkleRoot, true);
332 kv(grid, 'Time (UTC)', ts(b.time));
333 kv(grid, 'Bits / Nonce', b.bits.toString(16)+' / '+b.nonce);
334 kv(grid, 'Size / Txs', b.size+' B / '+b.txs.length);
335 append(card, grid); append(detail, card);
336 b.txs.forEach(function(t, i){
337 var tx = E('div', 'tx');
338 var head = E('div');
339 append(head, code(t.txid));
340 if(i === 0) append(head, E('span', 'pill', 'coinbase'));
341 var io = E('div', 'io');
342 append(io, ioSide('Inputs', t.vin, function(v){
343 var d = E('div');
344 if(v.coinbase) append(d, E('span', 'pill', 'coinbase'));
345 else append(d, code(short(v.prev)+':'+v.vout));
346 return d;
347 }));
348 append(io, ioSide('Outputs', t.vout, function(o){
349 var d = E('div');
350 var to = o.address ? o.address : (o.pubkey ? 'pubkey '+o.pubkey.slice(0,16)+'...' : o.type);
351 append(d, document.createTextNode(btf(o.value)+' -> '), code(to));
352 return d;
353 }));
354 append(tx, head, io); append(detail, tx);
355 });
356 document.getElementById('listview').hidden = true;
357 detail.hidden = false;
358 document.getElementById('home').hidden = false;
359 window.scrollTo(0,0);
360 });
361}
362document.getElementById('home').onclick=function(){
363 document.getElementById('detail').hidden = true;
364 document.getElementById('listview').hidden = false;
365 this.hidden = true;
366};
367document.getElementById('q').oninput=function(){ renderList(this.value); };
368fetch('blocks.json').then(function(r){return r.json();}).then(function(d){
369 SUM=d.blocks; document.getElementById('tip').textContent='tip height '+d.tipHeight+' - '+d.count+' blocks';
370 renderList('');
371});
372"""
373
374
375if __name__ == "__main__":
376 raise SystemExit(main(sys.argv[1:]))
diff --git a/scripts/build-pool-index.py b/scripts/build-pool-index.py
new file mode 100644
index 0000000..d3eb386
--- /dev/null
+++ b/scripts/build-pool-index.py
@@ -0,0 +1,174 @@
1#!/usr/bin/env python3
2#
3# Build a static public pool directory from one or more Bitflash pool_status.json
4# files. Inputs may be local paths or http(s) URLs.
5
6import argparse
7import html
8import json
9import os
10import time
11import urllib.parse
12import urllib.request
13
14
15def load_json(source):
16 if source.startswith("http://") or source.startswith("https://"):
17 with urllib.request.urlopen(source, timeout=10) as r:
18 return json.loads(r.read().decode("utf-8"))
19 with open(source, "r", encoding="utf-8-sig") as f:
20 return json.load(f)
21
22
23def normalize_pool(source, obj, now):
24 operator = obj.get("operator", {})
25 pool = obj.get("pool", {})
26 node = obj.get("node", {})
27 rounds_file = operator.get("roundsFile") or "pool_rounds.json"
28 rounds_url = ""
29 if source.startswith("http://") or source.startswith("https://"):
30 rounds_url = urllib.parse.urljoin(source, rounds_file)
31 updated = int(obj.get("updatedAt") or 0)
32 age = max(0, now - updated) if updated else None
33 online = bool(node.get("poolRunning")) and age is not None and age <= 120
34 return {
35 "source": source,
36 "name": operator.get("name") or "Bitflash Pool",
37 "btfAddress": operator.get("btfAddress") or "",
38 "feePercent": float(operator.get("feePercent") or 0.0),
39 "dashboardUrl": operator.get("dashboardUrl") or "",
40 "height": int(node.get("height") or 0),
41 "online": online,
42 "ageSeconds": age,
43 "authorizedMiners": int(pool.get("authorizedMiners") or 0),
44 "blocksFoundSession": int(pool.get("blocksFoundSession") or 0),
45 "roundShares": int(pool.get("roundShares") or 0),
46 "hashRate": float(pool.get("hashRate") or 0.0),
47 "roundsUrl": rounds_url,
48 "recentRounds": obj.get("recentRounds", []),
49 }
50
51
52def rank_key(pool):
53 return (
54 1 if pool["online"] else 0,
55 pool["hashRate"],
56 pool["authorizedMiners"],
57 -pool["feePercent"],
58 pool["name"].lower(),
59 )
60
61
62def render_html(pools, generated_at):
63 rows = []
64 for p in pools:
65 dash = p["dashboardUrl"]
66 rounds = p.get("roundsUrl", "")
67 # Only linkify http(s). The dashboard URL comes from an operator's
68 # self-published status file, so a "javascript:" or "data:" scheme
69 # would become a clickable script-injection on this public page.
70 dash_lower = dash.lower()
71 if dash_lower.startswith("http://") or dash_lower.startswith("https://"):
72 dash_html = '<a href="{0}" rel="noopener noreferrer">dashboard</a>'.format(
73 html.escape(dash, quote=True))
74 else:
75 dash_html = "-"
76 rounds_lower = rounds.lower()
77 if rounds_lower.startswith("http://") or rounds_lower.startswith("https://"):
78 rounds_html = '<a href="{0}" rel="noopener noreferrer">proof</a>'.format(
79 html.escape(rounds, quote=True))
80 else:
81 rounds_html = "-"
82 rows.append(
83 "<tr>"
84 "<td>{status}</td>"
85 "<td><strong>{name}</strong><br><code>{addr}</code></td>"
86 "<td>{fee:.2f}%</td>"
87 "<td>{miners}</td>"
88 "<td>{hashrate:.2f}</td>"
89 "<td>{blocks}</td>"
90 "<td>{height}</td>"
91 "<td>{age}</td>"
92 "<td>{dash}</td>"
93 "<td>{rounds}</td>"
94 "</tr>".format(
95 status="online" if p["online"] else "stale",
96 name=html.escape(p["name"]),
97 addr=html.escape(p["btfAddress"]),
98 fee=p["feePercent"],
99 miners=p["authorizedMiners"],
100 hashrate=p["hashRate"],
101 blocks=p["blocksFoundSession"],
102 height=p["height"],
103 age="{}s".format(p["ageSeconds"]) if p["ageSeconds"] is not None else "-",
104 dash=dash_html,
105 rounds=rounds_html,
106 )
107 )
108
109 return """<!doctype html>
110<html lang="en">
111<head>
112 <meta charset="utf-8">
113 <meta name="viewport" content="width=device-width, initial-scale=1">
114 <title>Bitflash Pools</title>
115 <style>
116 body {{ font: 15px/1.45 system-ui, sans-serif; margin: 32px; color: #111; }}
117 table {{ border-collapse: collapse; width: 100%; }}
118 th, td {{ border-bottom: 1px solid #ddd; padding: 10px; text-align: left; vertical-align: top; }}
119 th {{ font-size: 12px; text-transform: uppercase; letter-spacing: .08em; }}
120 code {{ font-size: 12px; word-break: break-all; }}
121 .note {{ color: #555; max-width: 780px; }}
122 </style>
123</head>
124<body>
125 <h1>Bitflash Pools</h1>
126 <p class="note">This directory is informational. Bitflash nodes discover pools through Nostr and .btf; this page only helps humans compare public operators.</p>
127 <table>
128 <thead>
129 <tr><th>Status</th><th>Pool</th><th>Fee</th><th>Miners</th><th>H/s</th><th>Blocks</th><th>Height</th><th>Age</th><th>Link</th><th>Proof</th></tr>
130 </thead>
131 <tbody>
132 {rows}
133 </tbody>
134 </table>
135 <p class="note">Generated at {generated_at}. JSON: <a href="pools.json">pools.json</a>.</p>
136</body>
137</html>
138""".format(rows="\n ".join(rows), generated_at=generated_at)
139
140
141def main():
142 ap = argparse.ArgumentParser()
143 ap.add_argument("--out-dir", default="pool-site")
144 ap.add_argument("sources", nargs="+", help="pool_status.json paths or URLs")
145 args = ap.parse_args()
146
147 now = int(time.time())
148 pools = []
149 errors = []
150 for source in args.sources:
151 try:
152 pools.append(normalize_pool(source, load_json(source), now))
153 except Exception as e:
154 errors.append({"source": source, "error": str(e)})
155
156 pools.sort(key=rank_key, reverse=True)
157 os.makedirs(args.out_dir, exist_ok=True)
158
159 generated_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now))
160 with open(os.path.join(args.out_dir, "pools.json"), "w", encoding="utf-8") as f:
161 json.dump({
162 "schema": "bitflash-pool-directory-1",
163 "generatedAt": generated_at,
164 "pools": pools,
165 "errors": errors,
166 }, f, indent=2, sort_keys=True)
167 f.write("\n")
168
169 with open(os.path.join(args.out_dir, "index.html"), "w", encoding="utf-8") as f:
170 f.write(render_html(pools, generated_at))
171
172
173if __name__ == "__main__":
174 main()
diff --git a/scripts/check-money-log-lines.sh b/scripts/check-money-log-lines.sh
new file mode 100755
index 0000000..c9f348b
--- /dev/null
+++ b/scripts/check-money-log-lines.sh
@@ -0,0 +1,38 @@
1#!/usr/bin/env bash
2# Money-moving payout lines must print every time they fire: they are the
3# audit trail for obligations created, moved and destroyed, and LogPrint's
4# burst collapse can stand one line in for any number of events inside one
5# 3-second window (issue #3: 100 voids totalling 4,950 BTF read as one
6# 49.49 BTF line). Every line carrying one of the phrases below must use
7# LogPrintAlways, which keeps the category gate but skips the collapse.
8#
9# Adding a payout line that reports money moving? Use LogPrintAlways and
10# add its phrase here. A phrase found in a plain LogPrint call fails this
11# check; anything else passes, so a non-money line is never held up by it.
12set -euo pipefail
13cd "$(dirname "$0")/.."
14
15phrases='was orphaned -- voiding|orphaned while queued|\[payout\] paid %s to %s|FAILED to pay|writing off|SKIPPED bad address'
16
17# Call sites span several lines; flatten each call to a single line first so
18# the phrase and the macro it belongs to are matched together. Only a line
19# whose first token is the macro starts a call, so comments mentioning
20# LogPrint are never mistaken for one.
21bad=$(awk '
22 /^[ \t]*LogPrint(Always)?[(]/ {
23 if (collecting) print buf
24 buf = $0; collecting = 1; next
25 }
26 collecting {
27 buf = buf $0
28 if (/;[ \t]*$/) { print buf; collecting = 0 }
29 }
30 END { if (collecting) print buf }
31' src/rpc.cpp | grep -E '^[[:space:]]*LogPrint[(]' | grep -E "$phrases" || true)
32
33if [ -n "$bad" ]; then
34 echo "error: money-moving payout line(s) on collapsing LogPrint:" >&2
35 echo "$bad" >&2
36 exit 1
37fi
38echo "ok: every money-moving payout line in src/rpc.cpp uses LogPrintAlways"
diff --git a/scripts/make-release-checksums.sh b/scripts/make-release-checksums.sh
index 5b29e2d..9c4f45a 100644..100755
--- a/scripts/make-release-checksums.sh
+++ b/scripts/make-release-checksums.sh
@@ -10,6 +10,7 @@ Usage: scripts/make-release-checksums.sh [--sign] [--local-user KEYID]
10 10
11Creates SHA256SUMS for release assets in the current directory: 11Creates SHA256SUMS for release assets in the current directory:
12 Bitflash-*-windows.zip 12 Bitflash-*-windows.zip
13 Bitflash-*-windows-with-tor.zip
13 Bitflash-*-x86_64.AppImage 14 Bitflash-*-x86_64.AppImage
14 bitflash-node-*-x86_64 15 bitflash-node-*-x86_64
15 16
@@ -44,6 +45,7 @@ fi
44assets=() 45assets=()
45for pattern in \ 46for pattern in \
46 "Bitflash-*-windows.zip" \ 47 "Bitflash-*-windows.zip" \
48 "Bitflash-*-windows-with-tor.zip" \
47 "Bitflash-*-x86_64.AppImage" \ 49 "Bitflash-*-x86_64.AppImage" \
48 "bitflash-node-*-x86_64" 50 "bitflash-node-*-x86_64"
49do 51do
diff --git a/scripts/package-windows-tor.sh b/scripts/package-windows-tor.sh
new file mode 100755
index 0000000..c8cca3b
--- /dev/null
+++ b/scripts/package-windows-tor.sh
@@ -0,0 +1,187 @@
1#!/usr/bin/env bash
2set -euo pipefail
3
4ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5VERSION="${VERSION:-$(sed -n 's/^VERSION[[:space:]]*:= *//p' "$ROOT/Makefile" | head -1)}"
6
7TOR_VERSION="${TOR_VERSION:-15.0.19}"
8TOR_ARCHIVE_NAME="tor-expert-bundle-windows-x86_64-${TOR_VERSION}.tar.gz"
9TOR_URL="${TOR_URL:-https://archive.torproject.org/tor-package-archive/torbrowser/${TOR_VERSION}/${TOR_ARCHIVE_NAME}}"
10TOR_SHA256="${TOR_SHA256:-6AC067402C7B4A3DC37887ED3754B3914B67FDC220C966190683E9CCF91ABF0F}"
11TOR_ASC_URL="${TOR_ASC_URL:-${TOR_URL}.asc}"
12TOR_SIGNING_KEY_FINGERPRINT="${TOR_SIGNING_KEY_FINGERPRINT:-EF6E286DDA85EA2A4BA7DE684E2C6E8793298290}"
13TOR_VERIFY_GPG="${TOR_VERIFY_GPG:-auto}"
14
15BUNDLE_DIR="${BUNDLE_DIR:-$ROOT/Bitflash-${VERSION}-windows}"
16OUT_ZIP="${OUT_ZIP:-$ROOT/Bitflash-${VERSION}-windows-with-tor.zip}"
17CACHE_DIR="${CACHE_DIR:-$ROOT/.cache}"
18ARCHIVE="${TOR_ARCHIVE:-$CACHE_DIR/$TOR_ARCHIVE_NAME}"
19ASC_FILE="${TOR_ASC:-$ARCHIVE.asc}"
20
21usage() {
22 cat <<EOF
23Usage: scripts/package-windows-tor.sh
24
25Creates Bitflash-${VERSION}-windows-with-tor.zip from an existing
26Bitflash-${VERSION}-windows/ release directory.
27
28Environment overrides:
29 VERSION Bitflash release version, default read from Makefile
30 TOR_VERSION Tor Expert Bundle version, default ${TOR_VERSION}
31 TOR_URL Download URL, default official Tor archive URL
32 TOR_ASC_URL Signature URL, default TOR_URL.asc
33 TOR_SHA256 Expected archive SHA256
34 TOR_ARCHIVE Use an existing archive instead of downloading
35 TOR_ASC Use an existing detached signature
36 TOR_VERIFY_GPG auto, required, or off; default auto
37 BUNDLE_DIR Existing unpacked Windows release directory
38 OUT_ZIP Output zip path
39EOF
40}
41
42if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then
43 usage
44 exit 0
45fi
46
47need() {
48 command -v "$1" >/dev/null 2>&1 || {
49 echo "missing required command: $1" >&2
50 exit 1
51 }
52}
53
54need sha256sum
55need tar
56need zip
57
58download() {
59 local url="$1"
60 local out="$2"
61 if command -v curl >/dev/null 2>&1; then
62 curl -L --fail --output "$out" "$url"
63 elif command -v wget >/dev/null 2>&1; then
64 wget -O "$out" "$url"
65 else
66 echo "missing required command: curl or wget" >&2
67 exit 1
68 fi
69}
70
71if [ ! -d "$BUNDLE_DIR" ]; then
72 echo "missing Windows release directory: $BUNDLE_DIR" >&2
73 echo "run 'make windows' first, or set BUNDLE_DIR" >&2
74 exit 1
75fi
76if [ ! -f "$BUNDLE_DIR/Bitflash.exe" ]; then
77 echo "missing Bitflash.exe in $BUNDLE_DIR" >&2
78 exit 1
79fi
80BUNDLE_PARENT="$(cd "$(dirname "$BUNDLE_DIR")" && pwd)"
81BUNDLE_NAME="$(basename "$BUNDLE_DIR")"
82OUT_PARENT="$(mkdir -p "$(dirname "$OUT_ZIP")" && cd "$(dirname "$OUT_ZIP")" && pwd)"
83OUT_NAME="$(basename "$OUT_ZIP")"
84OUT_ZIP_ABS="$OUT_PARENT/$OUT_NAME"
85
86mkdir -p "$CACHE_DIR"
87if [ ! -f "$ARCHIVE" ]; then
88 download "$TOR_URL" "$ARCHIVE"
89fi
90
91got="$(sha256sum "$ARCHIVE" | awk '{print tolower($1)}')"
92want="$(printf '%s' "$TOR_SHA256" | tr 'A-F' 'a-f')"
93if [ "$got" != "$want" ]; then
94 echo "Tor archive SHA256 mismatch" >&2
95 echo " expected: $want" >&2
96 echo " got: $got" >&2
97 exit 1
98fi
99
100tmp="$(mktemp -d "${TMPDIR:-/tmp}/bitflash-tor-package.XXXXXX")"
101trap 'rm -rf "$tmp"' EXIT
102
103case "$TOR_VERIFY_GPG" in
104 auto|required|off) ;;
105 *) echo "TOR_VERIFY_GPG must be auto, required, or off" >&2; exit 2 ;;
106esac
107
108if [ "$TOR_VERIFY_GPG" != "off" ]; then
109 if command -v gpg >/dev/null 2>&1; then
110 if [ ! -f "$ASC_FILE" ]; then
111 download "$TOR_ASC_URL" "$ASC_FILE"
112 fi
113 gnupg_home="$tmp/gnupg"
114 mkdir -p "$gnupg_home"
115 chmod 0700 "$gnupg_home"
116 gpg --homedir "$gnupg_home" --batch --auto-key-locate nodefault,wkd,keyserver \
117 --locate-keys torbrowser@torproject.org >/dev/null 2>&1 || {
118 if [ "$TOR_VERIFY_GPG" = "required" ]; then
119 echo "could not import Tor Browser Developers signing key" >&2
120 exit 1
121 fi
122 echo "WARNING: skipped Tor GPG signature verification; signing key import failed" >&2
123 }
124 if gpg --homedir "$gnupg_home" --batch --with-colons --fingerprint "$TOR_SIGNING_KEY_FINGERPRINT" \
125 2>/dev/null | grep -q "^fpr:::::::::${TOR_SIGNING_KEY_FINGERPRINT}:"; then
126 gpg --homedir "$gnupg_home" --batch --verify "$ASC_FILE" "$ARCHIVE" >/dev/null 2>&1 || {
127 echo "Tor archive GPG signature verification failed" >&2
128 exit 1
129 }
130 echo "Tor archive GPG signature verified against ${TOR_SIGNING_KEY_FINGERPRINT}"
131 elif [ "$TOR_VERIFY_GPG" = "required" ]; then
132 echo "Tor signing key fingerprint mismatch" >&2
133 exit 1
134 else
135 echo "WARNING: skipped Tor GPG signature verification; signing key fingerprint was not available" >&2
136 fi
137 elif [ "$TOR_VERIFY_GPG" = "required" ]; then
138 echo "missing required command for TOR_VERIFY_GPG=required: gpg" >&2
139 exit 1
140 else
141 echo "WARNING: gpg not found; relying on pinned SHA256 only" >&2
142 fi
143fi
144
145mkdir -p "$tmp/extract"
146tar -xzf "$ARCHIVE" -C "$tmp/extract"
147
148if [ ! -f "$tmp/extract/tor/tor.exe" ]; then
149 echo "Tor archive did not contain tor/tor.exe" >&2
150 exit 1
151fi
152
153rm -rf "$BUNDLE_DIR/tor"
154mkdir -p "$BUNDLE_DIR/tor"
155cp -R "$tmp/extract/tor/." "$BUNDLE_DIR/tor/"
156if [ -d "$tmp/extract/docs" ]; then
157 mkdir -p "$BUNDLE_DIR/tor/docs"
158 cp -R "$tmp/extract/docs/." "$BUNDLE_DIR/tor/docs/"
159fi
160if [ -d "$tmp/extract/data" ]; then
161 mkdir -p "$BUNDLE_DIR/tor/data-defaults"
162 cp -R "$tmp/extract/data/." "$BUNDLE_DIR/tor/data-defaults/"
163fi
164
165cat > "$BUNDLE_DIR/TOR-EXPERT-BUNDLE.txt" <<EOF
166Bitflash managed Tor bundle
167
168This Windows package includes the Tor Expert Bundle so -managedtor can start
169Tor without requiring a separate Tor install.
170
171Tor Expert Bundle: ${TOR_VERSION}
172Archive URL: ${TOR_URL}
173Archive SHA256: ${TOR_SHA256}
174
175Bitflash uses tor/tor.exe from this package. Runtime state, hidden-service keys,
176and control authentication cookies are written under the user's Bitflash data
177directory, not inside this release directory.
178EOF
179
180rm -f "$OUT_ZIP_ABS"
181(
182 cd "$BUNDLE_PARENT"
183 zip -qr "$OUT_ZIP_ABS" "$BUNDLE_NAME"
184)
185
186echo "Bundled Tor Expert Bundle ${TOR_VERSION}"
187echo "Built: $OUT_ZIP_ABS"
diff --git a/scripts/prove-utxo.py b/scripts/prove-utxo.py
new file mode 100644
index 0000000..105e677
--- /dev/null
+++ b/scripts/prove-utxo.py
@@ -0,0 +1,186 @@
1#!/usr/bin/env python3
2"""Create a Bitflash UTXO inclusion proof from local blk*.dat files.
3
4The proof is deliberately outside consensus. It proves that one txid:vout is
5included in the deterministic UTXO root produced by verify-utxo-set.py.
6"""
7
8import argparse
9import hashlib
10import json
11import sys
12from pathlib import Path
13
14import bitflash_chain as chain
15
16SCHEMA = "bitflash-utxo-inclusion-proof-1"
17
18
19def canonical_bytes(obj):
20 return (json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + "\n").encode("ascii")
21
22
23def split_outpoint(outpoint):
24 parts = outpoint.split(":")
25 if len(parts) != 2 or len(parts[0]) != 64:
26 raise SystemExit("outpoint must be txid:vout")
27 try:
28 vout = int(parts[1], 10)
29 except ValueError:
30 raise SystemExit("vout must be a decimal integer")
31 if vout < 0:
32 raise SystemExit("vout must not be negative")
33 return parts[0].lower(), vout
34
35
36def build_merkle_proof(keys, leaf_hashes, index):
37 current_index = index
38 layer = list(leaf_hashes)
39 proof = []
40 level = 0
41 while len(layer) > 1:
42 if current_index % 2 == 0:
43 sibling_index = current_index + 1
44 duplicated = sibling_index >= len(layer)
45 sibling = layer[current_index] if duplicated else layer[sibling_index]
46 side = "right"
47 else:
48 sibling_index = current_index - 1
49 duplicated = False
50 sibling = layer[sibling_index]
51 side = "left"
52 proof.append({
53 "level": level,
54 "side": side,
55 "hash": sibling.hex(),
56 "duplicated": duplicated,
57 })
58
59 next_layer = []
60 for i in range(0, len(layer), 2):
61 left = layer[i]
62 right = layer[i + 1] if i + 1 < len(layer) else left
63 next_layer.append(hashlib.sha256(chain.UTXO_NODE_DOMAIN + left + right).digest())
64 layer = next_layer
65 current_index //= 2
66 level += 1
67 return proof, layer[0].hex()
68
69
70def build_proof(args):
71 txid, vout = split_outpoint(args.outpoint)
72 outpoint = "%s:%d" % (txid, vout)
73
74 raw_blocks = chain.read_blocks(args.datadir, args.max_blocks)
75 if not raw_blocks:
76 raise SystemExit("no Bitflash blocks found in %s" % args.datadir)
77 try:
78 blocks = chain.select_main_chain(raw_blocks)
79 state = chain.apply_blocks(blocks, strict_duplicates=args.strict_duplicates)
80 except chain.ParseError as e:
81 raise SystemExit(str(e))
82 utxos = state["utxos"]
83 stats = state["stats"]
84 if outpoint not in utxos:
85 raise SystemExit("outpoint is not unspent in the scanned chain: %s" % outpoint)
86
87 keys = sorted(utxos)
88 leaf_hashes = [chain.utxo_leaf_hash(k, utxos[k]) for k in keys]
89 index = keys.index(outpoint)
90 proof_path, root = build_merkle_proof(keys, leaf_hashes, index)
91 direct_root = chain.utxo_root(utxos)
92 if root != direct_root:
93 raise SystemExit("internal proof root mismatch")
94
95 tip = blocks[-1]
96 utxo = utxos[outpoint]
97 report = {
98 "schema": SCHEMA,
99 "deterministic": True,
100 "chain": {
101 "name": "Bitflash",
102 "ticker": "BTF",
103 "message_start_hex": chain.MAGIC.hex(),
104 "coin": chain.COIN,
105 },
106 "commitment": {
107 "algorithm": "sorted-outpoint-merkle-sha256",
108 "leaf_domain": chain.UTXO_LEAF_DOMAIN,
109 "node_domain": chain.UTXO_NODE_DOMAIN_TAG,
110 "root": root,
111 "leaf_count": len(keys),
112 },
113 "target": {
114 "outpoint": outpoint,
115 "txid": txid,
116 "vout": vout,
117 "leaf_index": index,
118 "leaf_hash": leaf_hashes[index].hex(),
119 "utxo": utxo,
120 },
121 "proof": proof_path,
122 "scanned": {
123 "raw_block_count": len(raw_blocks),
124 "side_blocks_ignored": len(raw_blocks) - len(blocks),
125 "main_chain_block_count": len(blocks),
126 "first_height": 0,
127 "last_height": len(blocks) - 1,
128 "tip_hash": tip["hash"],
129 "tip_time": tip["time"],
130 "tip_time_utc": tip["time_utc"],
131 },
132 "warnings": {
133 "legacy_duplicate_output_overwrites": len(stats["duplicate_outputs"]),
134 },
135 "verdict": "UTXO_INCLUSION_PROOF_CREATED",
136 }
137 return report
138
139
140def print_text(proof):
141 data_hash = hashlib.sha256(canonical_bytes(proof)).hexdigest()
142 target = proof["target"]
143 utxo = target["utxo"]
144 print("Bitflash UTXO inclusion proof")
145 print("schema: %s" % proof["schema"])
146 print("canonical proof sha256: %s" % data_hash)
147 print()
148 print("Target")
149 print(" outpoint: %s" % target["outpoint"])
150 print(" value: %s BTF" % utxo["value_btf"])
151 print(" height: %d" % utxo["created_height"])
152 print(" index: %d of %d" % (target["leaf_index"], proof["commitment"]["leaf_count"]))
153 print()
154 print("Commitment")
155 print(" root: %s" % proof["commitment"]["root"])
156 print(" path: %d sibling hashes" % len(proof["proof"]))
157 print(" tip: %s" % proof["scanned"]["tip_hash"])
158 print()
159 print("Verdict: %s" % proof["verdict"])
160
161
162def main(argv):
163 ap = argparse.ArgumentParser(description=__doc__)
164 ap.add_argument("outpoint", help="unspent output to prove, as txid:vout")
165 ap.add_argument("--datadir", default=chain.default_datadir(), help="Bitflash data directory")
166 ap.add_argument("--max-blocks", type=int, default=0,
167 help="number of blocks to scan; 0 means all block files")
168 ap.add_argument("--strict-duplicates", action="store_true",
169 help="fail instead of using legacy last-write-wins duplicate output handling")
170 ap.add_argument("--json", action="store_true", help="print canonical JSON")
171 ap.add_argument("--out", help="write canonical JSON proof to this path")
172 args = ap.parse_args(argv)
173
174 proof = build_proof(args)
175 data = canonical_bytes(proof)
176 if args.out:
177 Path(args.out).write_bytes(data)
178 if args.json:
179 sys.stdout.buffer.write(data)
180 else:
181 print_text(proof)
182 return 0
183
184
185if __name__ == "__main__":
186 raise SystemExit(main(sys.argv[1:]))
diff --git a/scripts/smoke-managed-tor-onion.ps1 b/scripts/smoke-managed-tor-onion.ps1
new file mode 100644
index 0000000..70658c7
--- /dev/null
+++ b/scripts/smoke-managed-tor-onion.ps1
@@ -0,0 +1,174 @@
1param(
2 [string]$Bitflash,
3 [string]$Tor,
4 [string]$WorkDir,
5 [int]$TimeoutSec = 600,
6 [int]$OnionWarmupSec = 120
7)
8
9$ErrorActionPreference = "Stop"
10
11function Resolve-RepoRoot {
12 $scriptDir = Split-Path -Parent $PSCommandPath
13 return (Resolve-Path (Join-Path $scriptDir "..")).Path
14}
15
16function Get-FreeTcpPort {
17 $listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0)
18 $listener.Start()
19 try {
20 return ([Net.IPEndPoint]$listener.LocalEndpoint).Port
21 }
22 finally {
23 $listener.Stop()
24 }
25}
26
27function Wait-RegexInFile {
28 param(
29 [string]$Path,
30 [string]$Pattern,
31 [datetime]$Deadline,
32 [string]$Label
33 )
34
35 while ((Get-Date) -lt $Deadline) {
36 if (Test-Path $Path) {
37 $text = Get-Content -Raw -ErrorAction SilentlyContinue $Path
38 if ($null -ne $text) {
39 $m = [regex]::Match($text, $Pattern)
40 if ($m.Success) {
41 return $m
42 }
43 }
44 }
45 Start-Sleep -Milliseconds 500
46 }
47 throw "timed out waiting for $Label"
48}
49
50function Stop-TestProcesses {
51 param(
52 [System.Diagnostics.Process[]]$Processes,
53 [string[]]$DataDirKeys
54 )
55
56 foreach ($p in $Processes) {
57 try {
58 $p.Refresh()
59 if (!$p.HasExited) {
60 Stop-Process -Id $p.Id -Force
61 }
62 }
63 catch {}
64 }
65
66 foreach ($key in $DataDirKeys) {
67 if ([string]::IsNullOrWhiteSpace($key)) {
68 continue
69 }
70 try {
71 Get-CimInstance Win32_Process -Filter "name = 'tor.exe'" |
72 Where-Object { $_.CommandLine -like "*$key*" } |
73 ForEach-Object { Stop-Process -Id $_.ProcessId -Force }
74 }
75 catch {}
76 }
77}
78
79$repo = Resolve-RepoRoot
80if ([string]::IsNullOrWhiteSpace($Bitflash)) {
81 $Bitflash = Join-Path $repo "src\bitflash.exe"
82}
83if ([string]::IsNullOrWhiteSpace($Tor)) {
84 $candidates = @(
85 (Join-Path $repo "src\tor\tor.exe"),
86 (Join-Path $repo "Bitflash-$(Select-String -Path (Join-Path $repo 'Makefile') -Pattern '^VERSION[ ]*:=' | ForEach-Object { ($_.Line -split ':=')[1].Trim() })-windows\tor\tor.exe")
87 )
88 foreach ($candidate in $candidates) {
89 if (Test-Path $candidate) {
90 $Tor = $candidate
91 break
92 }
93 }
94}
95if ([string]::IsNullOrWhiteSpace($WorkDir)) {
96 $WorkDir = Join-Path $env:TEMP ("bitflash-managed-tor-two-node-" + (Get-Date -Format "yyyyMMddHHmmss"))
97}
98
99if (!(Test-Path $Bitflash)) {
100 throw "Bitflash binary not found: $Bitflash"
101}
102if (!(Test-Path $Tor)) {
103 throw "Tor binary not found. Pass -Tor or build the Windows-with-Tor package."
104}
105
106New-Item -ItemType Directory -Force -Path $WorkDir | Out-Null
107$dataA = Join-Path $WorkDir "node-a"
108$dataB = Join-Path $WorkDir "node-b"
109New-Item -ItemType Directory -Force -Path $dataA | Out-Null
110New-Item -ItemType Directory -Force -Path $dataB | Out-Null
111
112$portA = Get-FreeTcpPort
113$portB = Get-FreeTcpPort
114$deadline = (Get-Date).AddSeconds($TimeoutSec)
115$oldPath = $env:PATH
116$env:PATH = "C:\msys64\ucrt64\bin;C:\msys64\usr\bin;" + $env:PATH
117
118$procA = $null
119$procB = $null
120$dataKeys = @((Split-Path $WorkDir -Leaf))
121
122try {
123 $argsA = @(
124 "-datadir=$dataA",
125 "-port=$portA",
126 "-managedtor=$Tor",
127 "-nogui",
128 "-debug",
129 "-checkblocks=0"
130 )
131 $procA = Start-Process -FilePath $Bitflash -ArgumentList $argsA -WorkingDirectory (Split-Path $Bitflash -Parent) -PassThru -WindowStyle Hidden
132 $logA = Join-Path $dataA "debug.log"
133
134 $btfMatch = Wait-RegexInFile -Path $logA -Pattern "Nostr: node \.btf address = ([a-z2-7]+\.btf)" -Deadline $deadline -Label "node A .btf address"
135 $btfA = $btfMatch.Groups[1].Value
136
137 $onionMatch = Wait-RegexInFile -Path $logA -Pattern "Managed Tor hidden service ready: ([a-z2-7]{56}\.onion:\d+)" -Deadline $deadline -Label "node A managed onion"
138 $onionA = $onionMatch.Groups[1].Value
139
140 Wait-RegexInFile -Path $logA -Pattern "Nostr: \.btf self-resolve OK via .*" -Deadline $deadline -Label "node A descriptor self-resolve" | Out-Null
141 if ($OnionWarmupSec -gt 0) {
142 Write-Host "Waiting $OnionWarmupSec second(s) for Tor hidden-service descriptor propagation"
143 Start-Sleep -Seconds $OnionWarmupSec
144 }
145
146 $argsB = @(
147 "-datadir=$dataB",
148 "-port=$portB",
149 "-managedtor=$Tor",
150 "-oniononly",
151 "-connectbtf=$btfA",
152 "-nogui",
153 "-debug",
154 "-checkblocks=0"
155 )
156 $procB = Start-Process -FilePath $Bitflash -ArgumentList $argsB -WorkingDirectory (Split-Path $Bitflash -Parent) -PassThru -WindowStyle Hidden
157 $logB = Join-Path $dataB "debug.log"
158
159 Wait-RegexInFile -Path $logB -Pattern ("connected " + [regex]::Escape($btfA) + " via direct onion ") -Deadline $deadline -Label "node B direct onion connection to node A" | Out-Null
160
161 Write-Host "Managed Tor two-node onion smoke test passed"
162 Write-Host " node A .btf: $btfA"
163 Write-Host " node A onion: $onionA"
164 Write-Host " node A port: $portA"
165 Write-Host " node B port: $portB"
166 Write-Host " work dir: $WorkDir"
167}
168finally {
169 $env:PATH = $oldPath
170 $procs = @()
171 if ($null -ne $procA) { $procs += $procA }
172 if ($null -ne $procB) { $procs += $procB }
173 Stop-TestProcesses -Processes $procs -DataDirKeys $dataKeys
174}
diff --git a/scripts/verify-fair-launch.py b/scripts/verify-fair-launch.py
new file mode 100644
index 0000000..c33c8be
--- /dev/null
+++ b/scripts/verify-fair-launch.py
@@ -0,0 +1,166 @@
1#!/usr/bin/env python3
2"""Produce a deterministic Bitflash fair-launch audit report."""
3
4import argparse
5import hashlib
6import sys
7from pathlib import Path
8
9import bitflash_chain as chain
10
11
12def build_report(blocks, source_mode):
13 genesis = blocks[0]
14 launch_outputs = genesis["transactions"][0]["vout"] if genesis["transactions"] else []
15 coinbase_outputs = []
16 total_mined = 0
17 non_coinbase_txs = 0
18
19 for height, block in enumerate(blocks):
20 if not block["transactions"]:
21 continue
22 cb = block["transactions"][0]
23 if chain.is_coinbase(cb):
24 amount = sum(out["value_satoshis"] for out in cb["vout"])
25 total_mined += amount
26 coinbase_outputs.append({
27 "height": height,
28 "block_hash": block["hash"],
29 "coinbase_txid": cb["txid"],
30 "value_satoshis": amount,
31 "value_btf": chain.format_money(amount),
32 "outputs": cb["vout"],
33 })
34 non_coinbase_txs += sum(1 for tx in block["transactions"] if not chain.is_coinbase(tx))
35
36 checks = {
37 "genesis_hash_matches_source": genesis["hash"] == chain.GENESIS_HASH,
38 "genesis_merkle_matches_source": genesis["merkle_root"] == chain.GENESIS_MERKLE,
39 "computed_merkle_matches_header": genesis["computed_merkle_root"] == genesis["merkle_root"],
40 "genesis_has_single_transaction": genesis["tx_count"] == 1,
41 "genesis_transaction_is_coinbase": bool(genesis["transactions"] and chain.is_coinbase(genesis["transactions"][0])),
42 "genesis_supply_is_50_btf": sum(out["value_satoshis"] for out in launch_outputs) == 50 * chain.COIN,
43 }
44
45 report = {
46 "schema": "bitflash-fair-launch-report-1",
47 "deterministic": True,
48 "source_mode": source_mode,
49 "chain": {
50 "name": "Bitflash",
51 "ticker": "BTF",
52 "message_start_hex": chain.MAGIC.hex(),
53 "coin": chain.COIN,
54 },
55 "source_constants": {
56 "genesis_hash": chain.GENESIS_HASH,
57 "genesis_merkle_root": chain.GENESIS_MERKLE,
58 "genesis_time": chain.GENESIS_TIME,
59 "genesis_time_utc": chain.utc(chain.GENESIS_TIME),
60 "genesis_bits": "0x%08x" % chain.GENESIS_BITS,
61 "genesis_nonce": chain.GENESIS_NONCE,
62 "genesis_message": chain.GENESIS_TEXT,
63 },
64 "scanned": {
65 "block_count": len(blocks),
66 "first_height": 0,
67 "last_height": len(blocks) - 1,
68 "non_coinbase_transactions": non_coinbase_txs,
69 "total_coinbase_satoshis": total_mined,
70 "total_coinbase_btf": chain.format_money(total_mined),
71 },
72 "genesis": {
73 "hash": genesis["hash"],
74 "merkle_root": genesis["merkle_root"],
75 "computed_merkle_root": genesis["computed_merkle_root"],
76 "time": genesis["time"],
77 "time_utc": genesis["time_utc"],
78 "bits": genesis["bits"],
79 "nonce": genesis["nonce"],
80 "tx_count": genesis["tx_count"],
81 "coinbase_txid": genesis["transactions"][0]["txid"] if genesis["transactions"] else "",
82 "outputs": launch_outputs,
83 },
84 "coinbase_outputs": coinbase_outputs,
85 "checks": checks,
86 "verdict": "FAIR_LAUNCH_BASELINE_VERIFIED" if all(checks.values()) else "CHECKS_FAILED",
87 "external_timestamping": {
88 "status": "not_embedded",
89 "recommended": "OpenTimestamps",
90 "instructions": [
91 "Save the canonical JSON report.",
92 "Run: ots stamp bitflash-fair-launch-report.json",
93 "Later verify with: ots verify bitflash-fair-launch-report.json.ots",
94 ],
95 },
96 }
97 report["attestation_payload_sha256"] = hashlib.sha256(chain.canonical_bytes(report)).hexdigest()
98 return report
99
100
101def print_text(report):
102 checks = report["checks"]
103 print("Bitflash fair-launch verifier")
104 print("schema: %s" % report["schema"])
105 print("source: %s" % report["source_mode"])
106 print("attestation payload sha256: %s" % report["attestation_payload_sha256"])
107 print()
108 print("Genesis")
109 print(" hash: %s" % report["genesis"]["hash"])
110 print(" merkle: %s" % report["genesis"]["merkle_root"])
111 print(" time: %s" % report["genesis"]["time_utc"])
112 print(" txs: %d" % report["genesis"]["tx_count"])
113 print(" supply: %s BTF" % chain.format_money(sum(o["value_satoshis"] for o in report["genesis"]["outputs"])))
114 print()
115 print("Scanned")
116 print(" blocks: %d" % report["scanned"]["block_count"])
117 print(" height: %d..%d" % (report["scanned"]["first_height"], report["scanned"]["last_height"]))
118 print(" minted: %s BTF" % report["scanned"]["total_coinbase_btf"])
119 print(" normal transactions in range: %d" % report["scanned"]["non_coinbase_transactions"])
120 print()
121 print("Checks")
122 for key in sorted(checks):
123 print(" %s %s" % ("OK " if checks[key] else "FAIL", key))
124 print()
125 print("Verdict: %s" % report["verdict"])
126 print()
127 print("OpenTimestamps")
128 print(" canonical JSON can be stamped later with: ots stamp <report.json>")
129
130
131def main(argv):
132 ap = argparse.ArgumentParser(description=__doc__)
133 ap.add_argument("--datadir", default=chain.default_datadir(), help="Bitflash data directory")
134 ap.add_argument("--max-blocks", type=int, default=100, help="number of blocks to scan from blk*.dat")
135 ap.add_argument("--json", action="store_true", help="print canonical JSON")
136 ap.add_argument("--out", help="write canonical JSON to this path")
137 ap.add_argument("--reconstruct-genesis", action="store_true",
138 help="do not read disk; reconstruct only the genesis block from source constants")
139 args = ap.parse_args(argv)
140
141 if args.reconstruct_genesis:
142 blocks = [chain.reconstructed_genesis()]
143 source_mode = "reconstructed-genesis"
144 else:
145 blocks = chain.read_blocks(args.datadir, args.max_blocks)
146 if not blocks:
147 raise SystemExit("no Bitflash blocks found in %s; use --reconstruct-genesis for a source-only check" % args.datadir)
148 source_mode = "local-block-files"
149
150 try:
151 report = build_report(blocks, source_mode)
152 except chain.ParseError as e:
153 raise SystemExit(str(e))
154 data = chain.canonical_bytes(report)
155
156 if args.out:
157 Path(args.out).write_bytes(data)
158 if args.json:
159 sys.stdout.buffer.write(data)
160 else:
161 print_text(report)
162 return 0 if report["verdict"] == "FAIR_LAUNCH_BASELINE_VERIFIED" else 1
163
164
165if __name__ == "__main__":
166 raise SystemExit(main(sys.argv[1:]))
diff --git a/scripts/verify-release.sh b/scripts/verify-release.sh
index c651e78..4fd4558 100644..100755
--- a/scripts/verify-release.sh
+++ b/scripts/verify-release.sh
@@ -1,23 +1,50 @@
1#!/usr/bin/env bash 1#!/usr/bin/env bash
2set -euo pipefail 2set -euo pipefail
3 3
4repo="bitflash-coin/bitflash" 4# Verifies a Bitflash release: the checksum file's signature against a pinned
5tag="${1:-latest}" 5# key, then the assets against the checksums.
6#
7# The signing key is embedded below, not fetched. That is the whole point of a
8# pinned key: the trust anchor ships with this script (in git), so it does not
9# depend on the same server that serves the release. A server that is
10# compromised can swap the binaries and the checksums, but it cannot forge a
11# signature by this key.
12
13host="https://releases.bitflash.network"
6require_signature=0 14require_signature=0
7 15
16# The Bitflash release signing key. Verification requires this exact key --
17# gpg's own "good signature" is not enough, because a good signature by *some*
18# key proves nothing. See issue #138.
19RELEASE_KEY_FP="910A2B4CCA879E81FB4B2AEA1D4A53D3B78AA4B8"
20read -r -d '' RELEASE_KEY <<'KEY' || true
21-----BEGIN PGP PUBLIC KEY BLOCK-----
22
23mDMEanSOmRYJKwYBBAHaRw8BAQdAXj24i2BYUcAmOpoXwjE4ZTynHsifCZ+niwQK
24HqO0nMa0UEJpdGZsYXNoIFJlbGVhc2VzIChiaXRmbGFzaC5uZXR3b3JrIHJlbGVh
25c2Ugc2lnbmluZykgPHJlbGVhc2VzQGJpdGZsYXNoLm5ldHdvcms+iJAEExYKADgW
26IQSRCitMyoeegftLKuodSlPTt4qkuAUCanSOmQIbAwULCQgHAgYVCgkICwIEFgID
27AQIeAQIXgAAKCRAdSlPTt4qkuIi9AP96X3OcstiCTTgKOr16Vhds3LyuZ19q3Apq
284KAcNuOUMAD+NqgJ1CqkLGPXfZLCtYBCRHXNYWxMMEk+ViLcRtdLMg4=
29=mxic
30-----END PGP PUBLIC KEY BLOCK-----
31KEY
32
8usage() { 33usage() {
9 cat <<'EOF' 34 cat <<'EOF'
10Usage: scripts/verify-release.sh [tag|latest] [--require-signature] 35Usage: scripts/verify-release.sh [tag|latest] [--require-signature]
11 36
12Downloads the release checksum file and every asset named in it, then verifies: 37Downloads the release checksum file and every asset named in it, then verifies:
13 1. SHA256SUMS.asc, if present, against SHA256SUMS with gpg. 38 1. SHA256SUMS.asc against SHA256SUMS, requiring the pinned Bitflash release
39 key (fingerprint 910A 2B4C CA87 9E81 FB4B 2AEA 1D4A 53D3 B78A A4B8).
14 2. SHA256SUMS against the downloaded assets. 40 2. SHA256SUMS against the downloaded assets.
15 41
16Use --require-signature for release audits. Without it, older releases that 42Releases from before signing was introduced ship only SHA256SUMS; without
17ship only SHA256SUMS are checked for integrity and reported as unsigned. 43--require-signature those are checked for integrity and reported as unsigned.
18EOF 44EOF
19} 45}
20 46
47tag="${1:-latest}"
21for arg in "${@:2}"; do 48for arg in "${@:2}"; do
22 case "$arg" in 49 case "$arg" in
23 --require-signature) require_signature=1 ;; 50 --require-signature) require_signature=1 ;;
@@ -25,19 +52,9 @@ for arg in "${@:2}"; do
25 *) echo "unknown option: $arg" >&2; usage >&2; exit 2 ;; 52 *) echo "unknown option: $arg" >&2; usage >&2; exit 2 ;;
26 esac 53 esac
27done 54done
55if [ "$tag" = "-h" ] || [ "$tag" = "--help" ]; then usage; exit 0; fi
28 56
29if [ "$tag" = "-h" ] || [ "$tag" = "--help" ]; then 57need() { command -v "$1" >/dev/null 2>&1 || { echo "missing required command: $1" >&2; exit 1; }; }
30 usage
31 exit 0
32fi
33
34need() {
35 if ! command -v "$1" >/dev/null 2>&1; then
36 echo "missing required command: $1" >&2
37 exit 1
38 fi
39}
40
41need sed 58need sed
42need awk 59need awk
43 60
@@ -46,39 +63,25 @@ if command -v sha256sum >/dev/null 2>&1; then
46elif command -v shasum >/dev/null 2>&1; then 63elif command -v shasum >/dev/null 2>&1; then
47 sha_check=(shasum -a 256 -c SHA256SUMS) 64 sha_check=(shasum -a 256 -c SHA256SUMS)
48else 65else
49 echo "missing required command: sha256sum or shasum" >&2 66 echo "missing required command: sha256sum or shasum" >&2; exit 1
50 exit 1
51fi 67fi
52 68
53download() { 69download() {
54 local url="$1" 70 if command -v curl >/dev/null 2>&1; then curl -fsSL "$1" -o "$2"
55 local out="$2" 71 elif command -v wget >/dev/null 2>&1; then wget -q "$1" -O "$2"
56 if command -v curl >/dev/null 2>&1; then 72 else echo "missing required command: curl or wget" >&2; exit 1; fi
57 curl -fsSL "$url" -o "$out"
58 elif command -v wget >/dev/null 2>&1; then
59 wget -q "$url" -O "$out"
60 else
61 echo "missing required command: curl or wget" >&2
62 exit 1
63 fi
64} 73}
65 74
66if [ "$tag" = "latest" ]; then 75if [ "$tag" = "latest" ]; then
67 tag="$(download "https://api.github.com/repos/$repo/releases/latest" - | 76 tag="$(download "$host/latest.txt" - 2>/dev/null | tr -d '[:space:]' || true)"
68 sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | 77 [ -n "$tag" ] || { echo "could not resolve the latest release tag from $host/latest.txt" >&2; exit 1; }
69 head -1)"
70 if [ -z "$tag" ]; then
71 echo "could not resolve latest release tag" >&2
72 exit 1
73 fi
74fi 78fi
75 79
76workdir="$(mktemp -d "${TMPDIR:-/tmp}/bitflash-release-verify.XXXXXX")" 80workdir="$(mktemp -d "${TMPDIR:-/tmp}/bitflash-release-verify.XXXXXX")"
77cleanup() { rm -rf "$workdir"; } 81cleanup() { rm -rf "$workdir"; }
78trap cleanup EXIT 82trap cleanup EXIT
79 83
80base_url="https://github.com/$repo/releases/download/$tag" 84base_url="$host/$tag"
81
82echo "Verifying Bitflash $tag" 85echo "Verifying Bitflash $tag"
83echo "Working directory: $workdir" 86echo "Working directory: $workdir"
84cd "$workdir" 87cd "$workdir"
@@ -86,27 +89,32 @@ cd "$workdir"
86download "$base_url/SHA256SUMS" SHA256SUMS 89download "$base_url/SHA256SUMS" SHA256SUMS
87 90
88if download "$base_url/SHA256SUMS.asc" SHA256SUMS.asc 2>/dev/null; then 91if download "$base_url/SHA256SUMS.asc" SHA256SUMS.asc 2>/dev/null; then
89 if ! command -v gpg >/dev/null 2>&1; then 92 need gpg
90 echo "SHA256SUMS.asc exists, but gpg is not installed" >&2 93 # Verify in a throwaway keyring holding ONLY the pinned key, and require that
94 # the signature validates against it with the pinned fingerprint. This is the
95 # difference between "signed" and "signed by Bitflash".
96 keyring="$workdir/keyring"
97 mkdir -p "$keyring"; chmod 700 "$keyring"
98 printf '%s\n' "$RELEASE_KEY" | GNUPGHOME="$keyring" gpg --batch --quiet --import 2>/dev/null
99 status="$(GNUPGHOME="$keyring" gpg --batch --status-fd=1 --verify SHA256SUMS.asc SHA256SUMS 2>/dev/null || true)"
100 if printf '%s' "$status" | grep -q "VALIDSIG.*$RELEASE_KEY_FP"; then
101 echo "Signature OK -- signed by the pinned Bitflash release key"
102 else
103 echo "SIGNATURE REJECTED: not a valid signature by the pinned release key" >&2
104 echo " expected key: $RELEASE_KEY_FP" >&2
91 exit 1 105 exit 1
92 fi 106 fi
93 gpg --verify SHA256SUMS.asc SHA256SUMS
94 echo "Signature OK"
95else 107else
96 if [ "$require_signature" -eq 1 ]; then 108 if [ "$require_signature" -eq 1 ]; then
97 echo "release does not publish SHA256SUMS.asc" >&2 109 echo "release does not publish SHA256SUMS.asc" >&2; exit 1
98 exit 1
99 fi 110 fi
100 echo "WARNING: no SHA256SUMS.asc found; checking hashes only" 111 echo "WARNING: no SHA256SUMS.asc found; checking hashes only (unsigned release)"
101fi 112fi
102 113
103awk '{print $2}' SHA256SUMS | while IFS= read -r file; do 114awk '{print $2}' SHA256SUMS | while IFS= read -r file; do
104 file="${file#\*}" 115 file="${file#\*}"; file="${file#./}"
105 file="${file#./}"
106 [ -z "$file" ] && continue 116 [ -z "$file" ] && continue
107 case "$file" in 117 case "$file" in */*|..*) echo "refusing unexpected checksum path: $file" >&2; exit 1 ;; esac
108 */*|..*) echo "refusing unexpected checksum path: $file" >&2; exit 1 ;;
109 esac
110 echo "Downloading $file" 118 echo "Downloading $file"
111 download "$base_url/$file" "$file" 119 download "$base_url/$file" "$file"
112done 120done
diff --git a/scripts/verify-utxo-proof.py b/scripts/verify-utxo-proof.py
new file mode 100644
index 0000000..50d9356
--- /dev/null
+++ b/scripts/verify-utxo-proof.py
@@ -0,0 +1,146 @@
1#!/usr/bin/env python3
2"""Verify a Bitflash UTXO inclusion proof against a UTXO root."""
3
4import argparse
5import hashlib
6import json
7import sys
8from pathlib import Path
9
10import bitflash_chain as chain
11
12SCHEMA = "bitflash-utxo-inclusion-proof-1"
13
14
15def canonical_bytes(obj):
16 return (json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + "\n").encode("ascii")
17
18
19def load_proof(path):
20 try:
21 return json.loads(Path(path).read_text(encoding="ascii"))
22 except Exception as e:
23 raise SystemExit("could not read proof: %s" % e)
24
25
26def verify(proof, expected_root=None):
27 failures = []
28 if proof.get("schema") != SCHEMA:
29 failures.append("unexpected schema")
30 commitment = proof.get("commitment", {})
31 if commitment.get("algorithm") != "sorted-outpoint-merkle-sha256":
32 failures.append("unexpected commitment algorithm")
33 if commitment.get("leaf_domain") != chain.UTXO_LEAF_DOMAIN:
34 failures.append("unexpected leaf domain")
35 if commitment.get("node_domain") != chain.UTXO_NODE_DOMAIN_TAG:
36 failures.append("unexpected node domain")
37
38 target = proof.get("target", {})
39 outpoint = target.get("outpoint", "")
40 utxo = target.get("utxo", {})
41 if outpoint != "%s:%s" % (target.get("txid"), target.get("vout")):
42 failures.append("target outpoint does not match txid:vout")
43
44 try:
45 current = chain.utxo_leaf_hash(outpoint, utxo)
46 leaf_ok = target.get("leaf_hash") == current.hex()
47 except Exception as e:
48 failures.append("could not hash target leaf: %s" % e)
49 current = b"\x00" * 32
50 leaf_ok = False
51
52 if not leaf_ok:
53 failures.append("target leaf hash mismatch")
54
55 for i, step in enumerate(proof.get("proof", [])):
56 try:
57 sibling = bytes.fromhex(step["hash"])
58 except Exception:
59 failures.append("bad sibling hash at proof level %d" % i)
60 sibling = b"\x00" * 32
61 if len(sibling) != 32:
62 failures.append("bad sibling hash length at proof level %d" % i)
63 side = step.get("side")
64 if side == "right":
65 current = chain.sha256(chain.UTXO_NODE_DOMAIN + current + sibling)
66 elif side == "left":
67 current = chain.sha256(chain.UTXO_NODE_DOMAIN + sibling + current)
68 else:
69 failures.append("bad sibling side at proof level %d" % i)
70
71 computed_root = current.hex()
72 root = expected_root or commitment.get("root")
73 if computed_root != root:
74 failures.append("computed root does not match expected root")
75
76 return {
77 "schema": "bitflash-utxo-proof-verification-1",
78 "deterministic": True,
79 "target": {
80 "outpoint": outpoint,
81 "value_satoshis": utxo.get("value_satoshis"),
82 "value_btf": utxo.get("value_btf"),
83 "created_height": utxo.get("created_height"),
84 },
85 "commitment": {
86 "expected_root": root,
87 "computed_root": computed_root,
88 "leaf_hash": target.get("leaf_hash"),
89 "path_length": len(proof.get("proof", [])),
90 },
91 "checks": {
92 "schema_is_supported": proof.get("schema") == SCHEMA,
93 "leaf_hash_matches_target": leaf_ok,
94 "root_matches": computed_root == root,
95 },
96 "failures": failures,
97 "verdict": "UTXO_PROOF_VERIFIED" if not failures else "UTXO_PROOF_FAILED",
98 }
99
100
101def print_text(result):
102 data_hash = hashlib.sha256(canonical_bytes(result)).hexdigest()
103 print("Bitflash UTXO proof verifier")
104 print("schema: %s" % result["schema"])
105 print("canonical verification sha256: %s" % data_hash)
106 print()
107 print("Target")
108 print(" outpoint: %s" % result["target"]["outpoint"])
109 print(" value: %s BTF" % result["target"]["value_btf"])
110 print(" height: %s" % result["target"]["created_height"])
111 print()
112 print("Commitment")
113 print(" expected: %s" % result["commitment"]["expected_root"])
114 print(" computed: %s" % result["commitment"]["computed_root"])
115 print(" path: %d sibling hashes" % result["commitment"]["path_length"])
116 print()
117 print("Checks")
118 for key in sorted(result["checks"]):
119 print(" %s %s" % ("OK " if result["checks"][key] else "FAIL", key))
120 if result["failures"]:
121 print()
122 print("Failures")
123 for failure in result["failures"]:
124 print(" - %s" % failure)
125 print()
126 print("Verdict: %s" % result["verdict"])
127
128
129def main(argv):
130 ap = argparse.ArgumentParser(description=__doc__)
131 ap.add_argument("proof", help="canonical JSON proof from prove-utxo.py")
132 ap.add_argument("--root", help="expected UTXO root; defaults to root embedded in proof")
133 ap.add_argument("--json", action="store_true", help="print canonical JSON")
134 args = ap.parse_args(argv)
135
136 result = verify(load_proof(args.proof), args.root)
137 data = canonical_bytes(result)
138 if args.json:
139 sys.stdout.buffer.write(data)
140 else:
141 print_text(result)
142 return 0 if result["verdict"] == "UTXO_PROOF_VERIFIED" else 1
143
144
145if __name__ == "__main__":
146 raise SystemExit(main(sys.argv[1:]))
diff --git a/scripts/verify-utxo-set.py b/scripts/verify-utxo-set.py
new file mode 100644
index 0000000..b5bf3d5
--- /dev/null
+++ b/scripts/verify-utxo-set.py
@@ -0,0 +1,131 @@
1#!/usr/bin/env python3
2"""Build a deterministic Bitflash UTXO-set commitment from local blk*.dat."""
3
4import argparse
5import hashlib
6import sys
7from pathlib import Path
8
9import bitflash_chain as chain
10
11
12def build_report(raw_blocks, include_utxos=False, strict_duplicates=False):
13 blocks = chain.select_main_chain(raw_blocks)
14 state = chain.apply_blocks(blocks, strict_duplicates=strict_duplicates)
15 utxos = state["utxos"]
16 stats = state["stats"]
17 tip = blocks[-1]
18 root = chain.utxo_root(utxos)
19 checks = {
20 "genesis_hash_matches_source": blocks[0]["hash"] == chain.GENESIS_HASH,
21 "genesis_merkle_matches_source": blocks[0]["merkle_root"] == chain.GENESIS_MERKLE,
22 "all_scanned_merkle_roots_match": all(b["computed_merkle_root"] == b["merkle_root"] for b in blocks),
23 "no_missing_spends": len(stats["missing_spends"]) == 0,
24 }
25 warnings = {
26 "legacy_duplicate_output_overwrites": len(stats["duplicate_outputs"]),
27 }
28 report = {
29 "schema": "bitflash-utxo-set-commitment-1",
30 "deterministic": True,
31 "chain": {
32 "name": "Bitflash",
33 "ticker": "BTF",
34 "message_start_hex": chain.MAGIC.hex(),
35 "coin": chain.COIN,
36 },
37 "commitment": {
38 "algorithm": "sorted-outpoint-merkle-sha256",
39 "leaf_domain": "BTFUTXO1",
40 "node_domain": "BTFNODE1",
41 "root": root,
42 },
43 "scanned": {
44 "raw_block_count": len(raw_blocks),
45 "side_blocks_ignored": len(raw_blocks) - len(blocks),
46 "main_chain_block_count": len(blocks),
47 "first_height": 0,
48 "last_height": len(blocks) - 1,
49 "tip_hash": tip["hash"],
50 "tip_time": tip["time"],
51 "tip_time_utc": tip["time_utc"],
52 },
53 "stats": stats,
54 "checks": checks,
55 "warnings": warnings,
56 "verdict": "UTXO_COMMITMENT_VERIFIED" if all(checks.values()) else "CHECKS_FAILED",
57 }
58 if include_utxos:
59 report["utxos"] = {k: utxos[k] for k in sorted(utxos)}
60 return report
61
62
63def print_text(report):
64 data_hash = hashlib.sha256(chain.canonical_bytes(report)).hexdigest()
65 print("Bitflash UTXO-set commitment verifier")
66 print("schema: %s" % report["schema"])
67 print("canonical report sha256: %s" % data_hash)
68 print()
69 print("Commitment")
70 print(" root: %s" % report["commitment"]["root"])
71 print(" algo: %s" % report["commitment"]["algorithm"])
72 print()
73 print("Scanned")
74 print(" main chain blocks: %d" % report["scanned"]["main_chain_block_count"])
75 print(" raw disk blocks: %d" % report["scanned"]["raw_block_count"])
76 print(" side ignored: %d" % report["scanned"]["side_blocks_ignored"])
77 print(" height: %d..%d" % (report["scanned"]["first_height"], report["scanned"]["last_height"]))
78 print(" tip: %s" % report["scanned"]["tip_hash"])
79 print()
80 print("Supply")
81 print(" utxos: %d" % report["stats"]["utxo_count"])
82 print(" supply: %s BTF" % report["stats"]["total_unspent_btf"])
83 print(" mined: %s BTF" % report["stats"]["total_coinbase_btf"])
84 print(" delta: %s BTF" % report["stats"]["coinbase_minus_unspent_btf"])
85 print(" fees: %s BTF" % report["stats"]["total_fees_btf"])
86 if report["warnings"]["legacy_duplicate_output_overwrites"]:
87 print(" legacy duplicate overwrites: %d" % report["warnings"]["legacy_duplicate_output_overwrites"])
88 print()
89 print("Checks")
90 for key in sorted(report["checks"]):
91 print(" %s %s" % ("OK " if report["checks"][key] else "FAIL", key))
92 print()
93 print("Verdict: %s" % report["verdict"])
94 print()
95 print("OpenTimestamps")
96 print(" canonical JSON can be stamped later with: ots stamp <report.json>")
97
98
99def main(argv):
100 ap = argparse.ArgumentParser(description=__doc__)
101 ap.add_argument("--datadir", default=chain.default_datadir(), help="Bitflash data directory")
102 ap.add_argument("--max-blocks", type=int, default=0,
103 help="number of blocks to scan; 0 means all block files")
104 ap.add_argument("--include-utxos", action="store_true",
105 help="include the full sorted UTXO map in JSON output")
106 ap.add_argument("--strict-duplicates", action="store_true",
107 help="fail instead of using legacy last-write-wins duplicate output handling")
108 ap.add_argument("--json", action="store_true", help="print canonical JSON")
109 ap.add_argument("--out", help="write canonical JSON to this path")
110 args = ap.parse_args(argv)
111
112 blocks = chain.read_blocks(args.datadir, args.max_blocks)
113 if not blocks:
114 raise SystemExit("no Bitflash blocks found in %s" % args.datadir)
115 try:
116 report = build_report(blocks, include_utxos=args.include_utxos,
117 strict_duplicates=args.strict_duplicates)
118 except chain.ParseError as e:
119 raise SystemExit(str(e))
120 data = chain.canonical_bytes(report)
121 if args.out:
122 Path(args.out).write_bytes(data)
123 if args.json:
124 sys.stdout.buffer.write(data)
125 else:
126 print_text(report)
127 return 0 if report["verdict"] == "UTXO_COMMITMENT_VERIFIED" else 1
128
129
130if __name__ == "__main__":
131 raise SystemExit(main(sys.argv[1:]))