diff options
Diffstat (limited to 'scripts')
| -rw-r--r-- | scripts/bitflash-mining-calculator.py | 206 | ||||
| -rw-r--r-- | scripts/bitflash_chain.py | 450 | ||||
| -rwxr-xr-x | scripts/build-explorer.py | 336 | ||||
| -rw-r--r-- | scripts/build-pool-index.py | 174 | ||||
| -rw-r--r-- | scripts/prove-utxo.py | 186 | ||||
| -rw-r--r-- | scripts/verify-fair-launch.py | 166 | ||||
| -rwxr-xr-x | scripts/verify-release.sh | 108 | ||||
| -rw-r--r-- | scripts/verify-utxo-proof.py | 146 | ||||
| -rw-r--r-- | scripts/verify-utxo-set.py | 131 |
9 files changed, 1853 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 | |||
| 4 | This is a local WhatToMine-style calculator for Bitflash. It does not fetch | ||
| 5 | prices, does not talk to the node, and does not affect consensus. | ||
| 6 | """ | ||
| 7 | |||
| 8 | import argparse | ||
| 9 | import json | ||
| 10 | import math | ||
| 11 | import sys | ||
| 12 | |||
| 13 | |||
| 14 | HASHES_AT_DIFF_1 = 4096.0 | ||
| 15 | DEFAULT_BLOCK_TIME = 120.0 | ||
| 16 | DEFAULT_BLOCK_REWARD = 50.0 | ||
| 17 | |||
| 18 | |||
| 19 | UNITS = { | ||
| 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 | |||
| 35 | def 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 | |||
| 48 | def 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 | |||
| 56 | def fmt_money(x): | ||
| 57 | return "%.8f" % x | ||
| 58 | |||
| 59 | |||
| 60 | def 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 | |||
| 139 | def 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 | |||
| 181 | def 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 | |||
| 205 | if __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 | |||
| 4 | import datetime as _dt | ||
| 5 | import hashlib | ||
| 6 | import json | ||
| 7 | import mmap | ||
| 8 | import os | ||
| 9 | import struct | ||
| 10 | from pathlib import Path | ||
| 11 | |||
| 12 | |||
| 13 | MAGIC = bytes([0xBF, 0x20, 0x5C, 0xFD]) | ||
| 14 | MAX_BLOCK_PAYLOAD = 32 * 1024 * 1024 | ||
| 15 | UTXO_LEAF_DOMAIN = "BTFUTXO1" | ||
| 16 | UTXO_NODE_DOMAIN_TAG = "BTFNODE1" | ||
| 17 | UTXO_NODE_DOMAIN = (UTXO_NODE_DOMAIN_TAG + "|").encode("ascii") | ||
| 18 | COIN = 100_000_000 | ||
| 19 | GENESIS_HASH = "5bd7cb255d814e48cebcdfb72da4dc87b34bd774227f8ceb546c5640f4bdc169" | ||
| 20 | GENESIS_MERKLE = "1a45b4482532abb29b10e234d3f13132230525a339ecea91658ffa675a5b1325" | ||
| 21 | GENESIS_TIME = 1_753_315_200 | ||
| 22 | GENESIS_BITS = 0x1F0FFFFF | ||
| 23 | GENESIS_NONCE = 3141 | ||
| 24 | GENESIS_TEXT = "Bitflash 24/Jul/2026 Fair launch: one CPU one vote, no premine" | ||
| 25 | |||
| 26 | |||
| 27 | class ParseError(Exception): | ||
| 28 | pass | ||
| 29 | |||
| 30 | |||
| 31 | class 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 | |||
| 56 | def sha256(data): | ||
| 57 | return hashlib.sha256(data).digest() | ||
| 58 | |||
| 59 | |||
| 60 | def sha256d(data): | ||
| 61 | return hashlib.sha256(hashlib.sha256(data).digest()).digest() | ||
| 62 | |||
| 63 | |||
| 64 | def hash_hex(serialized): | ||
| 65 | return sha256d(serialized)[::-1].hex() | ||
| 66 | |||
| 67 | |||
| 68 | def 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 | |||
| 79 | def read_varbytes(r): | ||
| 80 | return r.take(compact_size(r)) | ||
| 81 | |||
| 82 | |||
| 83 | def 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 | |||
| 93 | def ser_varbytes(b): | ||
| 94 | return ser_compact_size(len(b)) + b | ||
| 95 | |||
| 96 | |||
| 97 | def 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 | |||
| 114 | def 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 | |||
| 137 | def 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 | |||
| 151 | def 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 | |||
| 180 | def iter_block_files(datadir): | ||
| 181 | for path in sorted(Path(datadir).glob("blk*.dat")): | ||
| 182 | yield path | ||
| 183 | |||
| 184 | |||
| 185 | def 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 | |||
| 217 | def 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 | |||
| 227 | def read_blocks(datadir, max_blocks=0): | ||
| 228 | return read_blocks_from_files(iter_block_files(datadir), max_blocks) | ||
| 229 | |||
| 230 | |||
| 231 | def 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 | |||
| 255 | def 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 | |||
| 263 | def 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 | |||
| 275 | def 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 | |||
| 290 | def 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 | |||
| 363 | def 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 | |||
| 394 | def 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 | |||
| 420 | def 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 | |||
| 431 | def 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 | |||
| 437 | def utc(ts): | ||
| 438 | return _dt.datetime.fromtimestamp(ts, _dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") | ||
| 439 | |||
| 440 | |||
| 441 | def 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 | |||
| 449 | def 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..97a7f4e --- /dev/null +++ b/scripts/build-explorer.py | |||
| @@ -0,0 +1,336 @@ | |||
| 1 | #!/usr/bin/env python3 | ||
| 2 | """Build a static Bitflash block explorer from local blk*.dat files.""" | ||
| 3 | |||
| 4 | import argparse | ||
| 5 | import hashlib | ||
| 6 | import json | ||
| 7 | import sys | ||
| 8 | from pathlib import Path | ||
| 9 | |||
| 10 | import bitflash_chain as chain | ||
| 11 | |||
| 12 | |||
| 13 | ADDRESS_VERSION = 25 | ||
| 14 | B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" | ||
| 15 | |||
| 16 | |||
| 17 | def b58check(payload): | ||
| 18 | v = bytes([ADDRESS_VERSION]) + payload | ||
| 19 | v += chain.sha256d(v)[:4] | ||
| 20 | n = int.from_bytes(v, "big") | ||
| 21 | s = "" | ||
| 22 | while n > 0: | ||
| 23 | n, r = divmod(n, 58) | ||
| 24 | s = B58[r] + s | ||
| 25 | for byte in v: | ||
| 26 | if byte == 0: | ||
| 27 | s = "1" + s | ||
| 28 | else: | ||
| 29 | break | ||
| 30 | return s | ||
| 31 | |||
| 32 | |||
| 33 | def hash160(b): | ||
| 34 | return hashlib.new("ripemd160", hashlib.sha256(b).digest()).digest() | ||
| 35 | |||
| 36 | |||
| 37 | def spk_info(script_hex): | ||
| 38 | try: | ||
| 39 | spk = bytes.fromhex(script_hex) | ||
| 40 | except ValueError: | ||
| 41 | return {"type": "malformed", "hex": script_hex[:80]} | ||
| 42 | if ( | ||
| 43 | len(spk) == 25 and spk[0] == 0x76 and spk[1] == 0xA9 | ||
| 44 | and spk[2] == 0x14 and spk[23] == 0x88 and spk[24] == 0xAC | ||
| 45 | ): | ||
| 46 | return {"type": "p2pkh", "address": b58check(spk[3:23])} | ||
| 47 | if len(spk) >= 35 and spk[-1] == 0xAC: | ||
| 48 | if spk[0] == 0x41 and len(spk) == 67: | ||
| 49 | pk = spk[1:66] | ||
| 50 | return {"type": "p2pk", "pubkey": pk.hex(), "address": b58check(hash160(pk))} | ||
| 51 | if spk[0] == 0x21 and len(spk) == 35: | ||
| 52 | pk = spk[1:34] | ||
| 53 | return {"type": "p2pk", "pubkey": pk.hex(), "address": b58check(hash160(pk))} | ||
| 54 | return {"type": "other", "hex": script_hex[:80]} | ||
| 55 | |||
| 56 | |||
| 57 | def explorer_tx(tx): | ||
| 58 | vin = [] | ||
| 59 | for txin in tx["vin"]: | ||
| 60 | coinbase = ( | ||
| 61 | txin["prevout_hash"] == "0" * 64 | ||
| 62 | and txin["prevout_n"] == 0xFFFFFFFF | ||
| 63 | ) | ||
| 64 | vin.append({ | ||
| 65 | "coinbase": coinbase, | ||
| 66 | "prev": None if coinbase else txin["prevout_hash"], | ||
| 67 | "vout": None if coinbase else txin["prevout_n"], | ||
| 68 | }) | ||
| 69 | vout = [] | ||
| 70 | for out in tx["vout"]: | ||
| 71 | vout.append(dict({"value": out["value_satoshis"]}, **spk_info(out["script_pub_key_hex"]))) | ||
| 72 | return {"txid": tx["txid"], "vin": vin, "vout": vout, "size": len(chain.ser_tx(tx))} | ||
| 73 | |||
| 74 | |||
| 75 | def parse_args(argv): | ||
| 76 | ap = argparse.ArgumentParser(description=__doc__) | ||
| 77 | ap.add_argument("--datadir", help="Bitflash data directory containing blk*.dat") | ||
| 78 | ap.add_argument("--max-blocks", type=int, default=0, | ||
| 79 | help="number of blocks to scan; 0 means all block files") | ||
| 80 | ap.add_argument("paths", nargs="+", | ||
| 81 | help="with --datadir: <out-dir>; otherwise: <blk*.dat>... <out-dir>") | ||
| 82 | args = ap.parse_args(argv) | ||
| 83 | if args.datadir: | ||
| 84 | if len(args.paths) != 1: | ||
| 85 | raise SystemExit("usage with --datadir: build-explorer.py --datadir DATADIR <out-dir>") | ||
| 86 | args.block_files = None | ||
| 87 | args.out_dir = args.paths[0] | ||
| 88 | else: | ||
| 89 | if len(args.paths) < 2: | ||
| 90 | raise SystemExit("usage: build-explorer.py <blk*.dat>... <out-dir>") | ||
| 91 | args.block_files = args.paths[:-1] | ||
| 92 | args.out_dir = args.paths[-1] | ||
| 93 | return args | ||
| 94 | |||
| 95 | |||
| 96 | def load_blocks(args): | ||
| 97 | if args.datadir: | ||
| 98 | return chain.read_blocks(args.datadir, args.max_blocks) | ||
| 99 | return chain.read_blocks_from_files(args.block_files, args.max_blocks) | ||
| 100 | |||
| 101 | |||
| 102 | def build_explorer(raw_blocks, out_dir): | ||
| 103 | try: | ||
| 104 | blocks = chain.select_main_chain(raw_blocks) | ||
| 105 | except chain.ParseError as e: | ||
| 106 | raise SystemExit(str(e)) | ||
| 107 | out = Path(out_dir) | ||
| 108 | (out / "block").mkdir(parents=True, exist_ok=True) | ||
| 109 | |||
| 110 | summaries = [] | ||
| 111 | for height, block in enumerate(blocks): | ||
| 112 | txs = [explorer_tx(tx) for tx in block["transactions"]] | ||
| 113 | detail = { | ||
| 114 | "height": height, | ||
| 115 | "hash": block["hash"], | ||
| 116 | "prev": block["previous_hash"], | ||
| 117 | "merkleRoot": block["merkle_root"], | ||
| 118 | "version": block["version"], | ||
| 119 | "time": block["time"], | ||
| 120 | "bits": int(block["bits"], 16), | ||
| 121 | "nonce": block["nonce"], | ||
| 122 | "size": block["size"], | ||
| 123 | "txs": txs, | ||
| 124 | } | ||
| 125 | cb = txs[0]["vout"][0] if txs and txs[0]["vout"] else {} | ||
| 126 | summaries.append({ | ||
| 127 | "height": height, | ||
| 128 | "hash": block["hash"], | ||
| 129 | "time": block["time"], | ||
| 130 | "nTx": len(txs), | ||
| 131 | "size": block["size"], | ||
| 132 | "cbValue": cb.get("value", 0), | ||
| 133 | "cbAddr": cb.get("address", ""), | ||
| 134 | }) | ||
| 135 | (out / "block" / ("%d.json" % height)).write_text( | ||
| 136 | json.dumps(detail, sort_keys=True, separators=(",", ":")) + "\n", | ||
| 137 | encoding="ascii", | ||
| 138 | ) | ||
| 139 | |||
| 140 | summaries.sort(key=lambda x: -x["height"]) | ||
| 141 | (out / "blocks.json").write_text( | ||
| 142 | json.dumps({ | ||
| 143 | "network": "bitflash", | ||
| 144 | "tipHeight": len(blocks) - 1, | ||
| 145 | "count": len(blocks), | ||
| 146 | "blocks": summaries, | ||
| 147 | }, sort_keys=True, separators=(",", ":")) + "\n", | ||
| 148 | encoding="ascii", | ||
| 149 | ) | ||
| 150 | (out / "index.html").write_text(INDEX_HTML, encoding="utf-8") | ||
| 151 | return len(blocks), len(blocks) - 1 | ||
| 152 | |||
| 153 | |||
| 154 | def main(argv): | ||
| 155 | args = parse_args(argv) | ||
| 156 | raw_blocks = load_blocks(args) | ||
| 157 | if not raw_blocks: | ||
| 158 | raise SystemExit("no Bitflash blocks parsed") | ||
| 159 | count, tip = build_explorer(raw_blocks, args.out_dir) | ||
| 160 | print("explorer built: %d main-chain blocks, tip %d, into %s" % (count, tip, args.out_dir)) | ||
| 161 | return 0 | ||
| 162 | |||
| 163 | |||
| 164 | INDEX_HTML = r"""<!doctype html> | ||
| 165 | <html lang="en"> | ||
| 166 | <head> | ||
| 167 | <meta charset="utf-8"> | ||
| 168 | <meta name="viewport" content="width=device-width, initial-scale=1"> | ||
| 169 | <title>Bitflash Block Explorer</title> | ||
| 170 | <style> | ||
| 171 | :root { color-scheme: light dark; --bg:#fff; --fg:#111; --mut:#666; --line:#e2e2e2; --card:#fafafa; --acc:#2b6cb0; } | ||
| 172 | @media (prefers-color-scheme: dark){ :root{ --bg:#0e0f11; --fg:#e6e6e6; --mut:#9aa0a6; --line:#26282c; --card:#16181b; --acc:#63b3ed; } } | ||
| 173 | * { box-sizing: border-box; } | ||
| 174 | body { font: 15px/1.5 system-ui, sans-serif; margin:0; background:var(--bg); color:var(--fg); } | ||
| 175 | header { padding:20px 24px; border-bottom:1px solid var(--line); display:flex; gap:16px; align-items:baseline; flex-wrap:wrap; } | ||
| 176 | h1 { font-size:18px; margin:0; } | ||
| 177 | h2 { font-size:16px; } | ||
| 178 | .mut { color:var(--mut); } | ||
| 179 | .wrap { padding:16px 24px; max-width:1100px; margin:0 auto; } | ||
| 180 | input { width:100%; max-width:420px; padding:9px 12px; border:1px solid var(--line); border-radius:8px; background:var(--card); color:var(--fg); font-size:14px; } | ||
| 181 | table { width:100%; border-collapse:collapse; margin-top:12px; } | ||
| 182 | th,td { text-align:left; padding:9px 10px; border-bottom:1px solid var(--line); font-variant-numeric:tabular-nums; } | ||
| 183 | th { font-size:12px; text-transform:uppercase; letter-spacing:.05em; color:var(--mut); } | ||
| 184 | tr.blk { cursor:pointer; } | ||
| 185 | tr.blk:hover td { background:var(--card); } | ||
| 186 | code { font-size:12px; word-break:break-all; } | ||
| 187 | .scroll { overflow-x:auto; } | ||
| 188 | .card { border:1px solid var(--line); border-radius:10px; background:var(--card); padding:14px 16px; margin-top:12px; } | ||
| 189 | .kv { display:grid; grid-template-columns:150px 1fr; gap:4px 12px; font-size:13px; } | ||
| 190 | .kv div:nth-child(odd){ color:var(--mut); } | ||
| 191 | .tx { border-top:1px solid var(--line); padding:10px 0; } | ||
| 192 | .io { display:grid; grid-template-columns:1fr 1fr; gap:16px; } | ||
| 193 | @media (max-width:640px){ .io{ grid-template-columns:1fr; } .kv{ grid-template-columns:110px 1fr; } } | ||
| 194 | .pill { display:inline-block; font-size:11px; padding:1px 7px; border-radius:99px; border:1px solid var(--line); color:var(--mut); } | ||
| 195 | button.link { background:none; border:none; color:var(--acc); cursor:pointer; font:inherit; padding:0; } | ||
| 196 | .note { color:var(--mut); font-size:13px; max-width:820px; } | ||
| 197 | </style> | ||
| 198 | </head> | ||
| 199 | <body> | ||
| 200 | <header> | ||
| 201 | <h1>Bitflash Block Explorer</h1> | ||
| 202 | <span class="mut" id="tip"></span> | ||
| 203 | <span style="flex:1"></span> | ||
| 204 | <button class="link" id="home" style="display:none">back to blocks</button> | ||
| 205 | </header> | ||
| 206 | <div class="wrap"> | ||
| 207 | <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> | ||
| 208 | <div id="listview"> | ||
| 209 | <input id="q" placeholder="Search by height or block hash"> | ||
| 210 | <div class="scroll"><table> | ||
| 211 | <thead><tr><th>Height</th><th>Time (UTC)</th><th>Txs</th><th>Size</th><th>Coinbase</th><th>To</th></tr></thead> | ||
| 212 | <tbody id="rows"></tbody> | ||
| 213 | </table></div> | ||
| 214 | <p class="mut" id="more"></p> | ||
| 215 | </div> | ||
| 216 | <div id="detail" style="display:none"></div> | ||
| 217 | </div> | ||
| 218 | <script> | ||
| 219 | var SUM = [], SHOWN = 200; | ||
| 220 | function btf(sat){ return (sat/1e8).toFixed(8).replace(/0+$/,'').replace(/\.$/,'')+" BTF"; } | ||
| 221 | function ts(t){ return new Date(t*1000).toISOString().replace('T',' ').replace('.000Z',' UTC'); } | ||
| 222 | function short(h){ return h ? h.slice(0,10)+"..."+h.slice(-6) : "-"; } | ||
| 223 | function E(tag, cls, text){ | ||
| 224 | var e = document.createElement(tag); | ||
| 225 | if(cls) e.className = cls; | ||
| 226 | if(text !== undefined) e.textContent = text; | ||
| 227 | return e; | ||
| 228 | } | ||
| 229 | function code(text){ return E('code', '', text); } | ||
| 230 | function clear(e){ while(e.firstChild) e.removeChild(e.firstChild); } | ||
| 231 | function append(parent){ | ||
| 232 | for(var i=1;i<arguments.length;i++) parent.appendChild(arguments[i]); | ||
| 233 | return parent; | ||
| 234 | } | ||
| 235 | function row(cells, cls){ | ||
| 236 | var tr = E('tr', cls || ''); | ||
| 237 | cells.forEach(function(c){ append(tr, E('td', '', c)); }); | ||
| 238 | return tr; | ||
| 239 | } | ||
| 240 | function renderList(filter){ | ||
| 241 | var q=(filter||"").trim().toLowerCase(), rows=document.getElementById('rows'); | ||
| 242 | clear(rows); | ||
| 243 | var list = q ? SUM.filter(function(b){ return String(b.height)===q || b.hash.indexOf(q)===0; }) : SUM.slice(0,SHOWN); | ||
| 244 | if(!list.length){ | ||
| 245 | var empty = E('tr'); | ||
| 246 | var td = E('td', 'mut', 'no match'); | ||
| 247 | td.colSpan = 6; | ||
| 248 | append(empty, td); append(rows, empty); | ||
| 249 | } | ||
| 250 | list.forEach(function(b){ | ||
| 251 | var tr = row([String(b.height), ts(b.time), String(b.nTx), b.size+' B', btf(b.cbValue), ''], 'blk'); | ||
| 252 | tr.dataset.h = b.height; | ||
| 253 | clear(tr.children[5]); | ||
| 254 | append(tr.children[5], code(b.cbAddr || '-')); | ||
| 255 | tr.onclick = function(){ openBlock(tr.dataset.h); }; | ||
| 256 | append(rows, tr); | ||
| 257 | }); | ||
| 258 | document.getElementById('more').textContent = (!q && SUM.length>SHOWN) ? ("showing latest "+SHOWN+" of "+SUM.length+" blocks; search to find any") : ""; | ||
| 259 | } | ||
| 260 | function kv(parent, key, value, isCode){ | ||
| 261 | append(parent, E('div', '', key)); | ||
| 262 | append(parent, isCode ? append(E('div'), code(value)) : E('div', '', value)); | ||
| 263 | } | ||
| 264 | function ioSide(title, items, render){ | ||
| 265 | var side = E('div'); | ||
| 266 | append(side, E('div', 'mut', title)); | ||
| 267 | items.forEach(function(item){ append(side, render(item)); }); | ||
| 268 | return side; | ||
| 269 | } | ||
| 270 | function openBlock(h){ | ||
| 271 | fetch('block/'+h+'.json').then(function(r){return r.json();}).then(function(b){ | ||
| 272 | var detail = document.getElementById('detail'); | ||
| 273 | clear(detail); | ||
| 274 | append(detail, E('h2', '', 'Block '+b.height)); | ||
| 275 | var card = E('div', 'card'), grid = E('div', 'kv'); | ||
| 276 | kv(grid, 'Height', String(b.height)); | ||
| 277 | kv(grid, 'Hash', b.hash, true); | ||
| 278 | var prev = E('div'); | ||
| 279 | if(b.height > 0){ | ||
| 280 | var btn = E('button', 'link'); | ||
| 281 | append(btn, code(short(b.prev))); | ||
| 282 | btn.onclick = function(){ openBlock(b.height - 1); }; | ||
| 283 | append(prev, btn); | ||
| 284 | } else { | ||
| 285 | append(prev, code(short(b.prev))); | ||
| 286 | } | ||
| 287 | append(grid, E('div', '', 'Previous'), prev); | ||
| 288 | kv(grid, 'Merkle root', b.merkleRoot, true); | ||
| 289 | kv(grid, 'Time (UTC)', ts(b.time)); | ||
| 290 | kv(grid, 'Bits / Nonce', b.bits.toString(16)+' / '+b.nonce); | ||
| 291 | kv(grid, 'Size / Txs', b.size+' B / '+b.txs.length); | ||
| 292 | append(card, grid); append(detail, card); | ||
| 293 | b.txs.forEach(function(t, i){ | ||
| 294 | var tx = E('div', 'tx'); | ||
| 295 | var head = E('div'); | ||
| 296 | append(head, code(t.txid)); | ||
| 297 | if(i === 0) append(head, E('span', 'pill', 'coinbase')); | ||
| 298 | var io = E('div', 'io'); | ||
| 299 | append(io, ioSide('Inputs', t.vin, function(v){ | ||
| 300 | var d = E('div'); | ||
| 301 | if(v.coinbase) append(d, E('span', 'pill', 'coinbase')); | ||
| 302 | else append(d, code(short(v.prev)+':'+v.vout)); | ||
| 303 | return d; | ||
| 304 | })); | ||
| 305 | append(io, ioSide('Outputs', t.vout, function(o){ | ||
| 306 | var d = E('div'); | ||
| 307 | var to = o.address ? o.address : (o.pubkey ? 'pubkey '+o.pubkey.slice(0,16)+'...' : o.type); | ||
| 308 | append(d, document.createTextNode(btf(o.value)+' -> '), code(to)); | ||
| 309 | return d; | ||
| 310 | })); | ||
| 311 | append(tx, head, io); append(detail, tx); | ||
| 312 | }); | ||
| 313 | document.getElementById('listview').style.display='none'; | ||
| 314 | detail.style.display='block'; | ||
| 315 | document.getElementById('home').style.display='inline'; | ||
| 316 | window.scrollTo(0,0); | ||
| 317 | }); | ||
| 318 | } | ||
| 319 | document.getElementById('home').onclick=function(){ | ||
| 320 | document.getElementById('detail').style.display='none'; | ||
| 321 | document.getElementById('listview').style.display='block'; | ||
| 322 | this.style.display='none'; | ||
| 323 | }; | ||
| 324 | document.getElementById('q').oninput=function(){ renderList(this.value); }; | ||
| 325 | fetch('blocks.json').then(function(r){return r.json();}).then(function(d){ | ||
| 326 | SUM=d.blocks; document.getElementById('tip').textContent='tip height '+d.tipHeight+' - '+d.count+' blocks'; | ||
| 327 | renderList(''); | ||
| 328 | }); | ||
| 329 | </script> | ||
| 330 | </body> | ||
| 331 | </html> | ||
| 332 | """ | ||
| 333 | |||
| 334 | |||
| 335 | if __name__ == "__main__": | ||
| 336 | 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 | |||
| 6 | import argparse | ||
| 7 | import html | ||
| 8 | import json | ||
| 9 | import os | ||
| 10 | import time | ||
| 11 | import urllib.parse | ||
| 12 | import urllib.request | ||
| 13 | |||
| 14 | |||
| 15 | def 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 | |||
| 23 | def 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 | |||
| 52 | def 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 | |||
| 62 | def 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 | |||
| 141 | def 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 | |||
| 173 | if __name__ == "__main__": | ||
| 174 | main() | ||
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 | |||
| 4 | The proof is deliberately outside consensus. It proves that one txid:vout is | ||
| 5 | included in the deterministic UTXO root produced by verify-utxo-set.py. | ||
| 6 | """ | ||
| 7 | |||
| 8 | import argparse | ||
| 9 | import hashlib | ||
| 10 | import json | ||
| 11 | import sys | ||
| 12 | from pathlib import Path | ||
| 13 | |||
| 14 | import bitflash_chain as chain | ||
| 15 | |||
| 16 | SCHEMA = "bitflash-utxo-inclusion-proof-1" | ||
| 17 | |||
| 18 | |||
| 19 | def canonical_bytes(obj): | ||
| 20 | return (json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + "\n").encode("ascii") | ||
| 21 | |||
| 22 | |||
| 23 | def 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 | |||
| 36 | def 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 | |||
| 70 | def 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 | |||
| 140 | def 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 | |||
| 162 | def 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 | |||
| 185 | if __name__ == "__main__": | ||
| 186 | raise SystemExit(main(sys.argv[1:])) | ||
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 | |||
| 4 | import argparse | ||
| 5 | import hashlib | ||
| 6 | import sys | ||
| 7 | from pathlib import Path | ||
| 8 | |||
| 9 | import bitflash_chain as chain | ||
| 10 | |||
| 11 | |||
| 12 | def 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 | |||
| 101 | def 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 | |||
| 131 | def 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 | |||
| 165 | if __name__ == "__main__": | ||
| 166 | raise SystemExit(main(sys.argv[1:])) | ||
diff --git a/scripts/verify-release.sh b/scripts/verify-release.sh index 23e71d1..4fd4558 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 |
| 2 | set -euo pipefail | 2 | set -euo pipefail |
| 3 | 3 | ||
| 4 | repo="Bitflash-sh/bitflash" | 4 | # Verifies a Bitflash release: the checksum file's signature against a pinned |
| 5 | tag="${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 | |||
| 13 | host="https://releases.bitflash.network" | ||
| 6 | require_signature=0 | 14 | require_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. | ||
| 19 | RELEASE_KEY_FP="910A2B4CCA879E81FB4B2AEA1D4A53D3B78AA4B8" | ||
| 20 | read -r -d '' RELEASE_KEY <<'KEY' || true | ||
| 21 | -----BEGIN PGP PUBLIC KEY BLOCK----- | ||
| 22 | |||
| 23 | mDMEanSOmRYJKwYBBAHaRw8BAQdAXj24i2BYUcAmOpoXwjE4ZTynHsifCZ+niwQK | ||
| 24 | HqO0nMa0UEJpdGZsYXNoIFJlbGVhc2VzIChiaXRmbGFzaC5uZXR3b3JrIHJlbGVh | ||
| 25 | c2Ugc2lnbmluZykgPHJlbGVhc2VzQGJpdGZsYXNoLm5ldHdvcms+iJAEExYKADgW | ||
| 26 | IQSRCitMyoeegftLKuodSlPTt4qkuAUCanSOmQIbAwULCQgHAgYVCgkICwIEFgID | ||
| 27 | AQIeAQIXgAAKCRAdSlPTt4qkuIi9AP96X3OcstiCTTgKOr16Vhds3LyuZ19q3Apq | ||
| 28 | 4KAcNuOUMAD+NqgJ1CqkLGPXfZLCtYBCRHXNYWxMMEk+ViLcRtdLMg4= | ||
| 29 | =mxic | ||
| 30 | -----END PGP PUBLIC KEY BLOCK----- | ||
| 31 | KEY | ||
| 32 | |||
| 8 | usage() { | 33 | usage() { |
| 9 | cat <<'EOF' | 34 | cat <<'EOF' |
| 10 | Usage: scripts/verify-release.sh [tag|latest] [--require-signature] | 35 | Usage: scripts/verify-release.sh [tag|latest] [--require-signature] |
| 11 | 36 | ||
| 12 | Downloads the release checksum file and every asset named in it, then verifies: | 37 | Downloads 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 | ||
| 16 | Use --require-signature for release audits. Without it, older releases that | 42 | Releases from before signing was introduced ship only SHA256SUMS; without |
| 17 | ship only SHA256SUMS are checked for integrity and reported as unsigned. | 43 | --require-signature those are checked for integrity and reported as unsigned. |
| 18 | EOF | 44 | EOF |
| 19 | } | 45 | } |
| 20 | 46 | ||
| 47 | tag="${1:-latest}" | ||
| 21 | for arg in "${@:2}"; do | 48 | for 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 |
| 27 | done | 54 | done |
| 55 | if [ "$tag" = "-h" ] || [ "$tag" = "--help" ]; then usage; exit 0; fi | ||
| 28 | 56 | ||
| 29 | if [ "$tag" = "-h" ] || [ "$tag" = "--help" ]; then | 57 | need() { command -v "$1" >/dev/null 2>&1 || { echo "missing required command: $1" >&2; exit 1; }; } |
| 30 | usage | ||
| 31 | exit 0 | ||
| 32 | fi | ||
| 33 | |||
| 34 | need() { | ||
| 35 | if ! command -v "$1" >/dev/null 2>&1; then | ||
| 36 | echo "missing required command: $1" >&2 | ||
| 37 | exit 1 | ||
| 38 | fi | ||
| 39 | } | ||
| 40 | |||
| 41 | need sed | 58 | need sed |
| 42 | need awk | 59 | need awk |
| 43 | 60 | ||
| @@ -46,39 +63,25 @@ if command -v sha256sum >/dev/null 2>&1; then | |||
| 46 | elif command -v shasum >/dev/null 2>&1; then | 63 | elif command -v shasum >/dev/null 2>&1; then |
| 47 | sha_check=(shasum -a 256 -c SHA256SUMS) | 64 | sha_check=(shasum -a 256 -c SHA256SUMS) |
| 48 | else | 65 | else |
| 49 | echo "missing required command: sha256sum or shasum" >&2 | 66 | echo "missing required command: sha256sum or shasum" >&2; exit 1 |
| 50 | exit 1 | ||
| 51 | fi | 67 | fi |
| 52 | 68 | ||
| 53 | download() { | 69 | download() { |
| 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 | ||
| 66 | if [ "$tag" = "latest" ]; then | 75 | if [ "$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 | ||
| 74 | fi | 78 | fi |
| 75 | 79 | ||
| 76 | workdir="$(mktemp -d "${TMPDIR:-/tmp}/bitflash-release-verify.XXXXXX")" | 80 | workdir="$(mktemp -d "${TMPDIR:-/tmp}/bitflash-release-verify.XXXXXX")" |
| 77 | cleanup() { rm -rf "$workdir"; } | 81 | cleanup() { rm -rf "$workdir"; } |
| 78 | trap cleanup EXIT | 82 | trap cleanup EXIT |
| 79 | 83 | ||
| 80 | base_url="https://github.com/$repo/releases/download/$tag" | 84 | base_url="$host/$tag" |
| 81 | |||
| 82 | echo "Verifying Bitflash $tag" | 85 | echo "Verifying Bitflash $tag" |
| 83 | echo "Working directory: $workdir" | 86 | echo "Working directory: $workdir" |
| 84 | cd "$workdir" | 87 | cd "$workdir" |
| @@ -86,27 +89,32 @@ cd "$workdir" | |||
| 86 | download "$base_url/SHA256SUMS" SHA256SUMS | 89 | download "$base_url/SHA256SUMS" SHA256SUMS |
| 87 | 90 | ||
| 88 | if download "$base_url/SHA256SUMS.asc" SHA256SUMS.asc 2>/dev/null; then | 91 | if 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" | ||
| 95 | else | 107 | else |
| 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)" |
| 101 | fi | 112 | fi |
| 102 | 113 | ||
| 103 | awk '{print $2}' SHA256SUMS | while IFS= read -r file; do | 114 | awk '{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" |
| 112 | done | 120 | done |
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 | |||
| 4 | import argparse | ||
| 5 | import hashlib | ||
| 6 | import json | ||
| 7 | import sys | ||
| 8 | from pathlib import Path | ||
| 9 | |||
| 10 | import bitflash_chain as chain | ||
| 11 | |||
| 12 | SCHEMA = "bitflash-utxo-inclusion-proof-1" | ||
| 13 | |||
| 14 | |||
| 15 | def canonical_bytes(obj): | ||
| 16 | return (json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + "\n").encode("ascii") | ||
| 17 | |||
| 18 | |||
| 19 | def 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 | |||
| 26 | def 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 | |||
| 101 | def 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 | |||
| 129 | def 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 | |||
| 145 | if __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 | |||
| 4 | import argparse | ||
| 5 | import hashlib | ||
| 6 | import sys | ||
| 7 | from pathlib import Path | ||
| 8 | |||
| 9 | import bitflash_chain as chain | ||
| 10 | |||
| 11 | |||
| 12 | def 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 | |||
| 63 | def 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 | |||
| 99 | def 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 | |||
| 130 | if __name__ == "__main__": | ||
| 131 | raise SystemExit(main(sys.argv[1:])) | ||