diff options
| author | bitflash-maintainer <noreply@bitflash.invalid> | 2026-09-06 01:53:03 +0000 |
|---|---|---|
| committer | bitflash-maintainer <noreply@bitflash.invalid> | 2026-09-06 01:53:03 +0000 |
| commit | ef9f06d43fc6577c15df2256abbb25b20d4227b8 (patch) | |
| tree | 7e9132aa871fdc01cb95268d2f5ea3bb11853ddf /scripts | |
| parent | be63e83d79e7e00e6b8ca889a70e6a19886aa1d8 (diff) | |
| parent | ceae3d076b5ad11ee84729ae43f14e9312896646 (diff) | |
Bring main onto the line this project actually ships (incl. the 1.2.23 compactblock port)
Closes #6, #7, #8, #11, #35, #34, #29, #28, #27, and #16
See merge request !16
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 | 376 | ||||
| -rw-r--r-- | scripts/build-pool-index.py | 174 | ||||
| -rwxr-xr-x | scripts/check-money-log-lines.sh | 38 | ||||
| -rwxr-xr-x[-rw-r--r--] | scripts/make-release-checksums.sh | 2 | ||||
| -rwxr-xr-x | scripts/package-windows-tor.sh | 187 | ||||
| -rw-r--r-- | scripts/prove-utxo.py | 186 | ||||
| -rw-r--r-- | scripts/smoke-managed-tor-onion.ps1 | 174 | ||||
| -rw-r--r-- | scripts/verify-fair-launch.py | 166 | ||||
| -rwxr-xr-x[-rw-r--r--] | scripts/verify-release.sh | 108 | ||||
| -rw-r--r-- | scripts/verify-utxo-proof.py | 146 | ||||
| -rw-r--r-- | scripts/verify-utxo-set.py | 131 |
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 | |||
| 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..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 | |||
| 4 | import argparse | ||
| 5 | import hashlib | ||
| 6 | import json | ||
| 7 | import shutil | ||
| 8 | import sys | ||
| 9 | from pathlib import Path | ||
| 10 | |||
| 11 | import bitflash_chain as chain | ||
| 12 | |||
| 13 | |||
| 14 | ADDRESS_VERSION = 25 | ||
| 15 | B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" | ||
| 16 | |||
| 17 | |||
| 18 | def 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 | |||
| 34 | def hash160(b): | ||
| 35 | return hashlib.new("ripemd160", hashlib.sha256(b).digest()).digest() | ||
| 36 | |||
| 37 | |||
| 38 | def 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 | |||
| 58 | def 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 | |||
| 76 | def 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 | |||
| 97 | def 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 | |||
| 103 | def 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 | |||
| 160 | def 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 | |||
| 170 | INDEX_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 | |||
| 212 | STYLE_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;} | ||
| 223 | html,body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.55 var(--mono);-webkit-text-size-adjust:100%;} | ||
| 224 | a{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;} | ||
| 237 | h1{font-size:1rem;margin:0 0 .3rem;letter-spacing:.02em;} | ||
| 238 | h2{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;} | ||
| 241 | input{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;} | ||
| 242 | input:focus{outline:none;border-color:var(--accent);} | ||
| 243 | .scroll{overflow-x:auto;} | ||
| 244 | table{width:100%;border-collapse:collapse;margin-top:.8rem;font-size:.86rem;} | ||
| 245 | th,td{text-align:left;padding:.5rem .6rem;border-bottom:1px solid var(--line);font-variant-numeric:tabular-nums;} | ||
| 246 | th{font-size:.72rem;text-transform:uppercase;letter-spacing:.1em;color:var(--dim);font-weight:normal;} | ||
| 247 | tr.blk{cursor:pointer;} | ||
| 248 | tr.blk:hover td{background:var(--bg-2);} | ||
| 249 | code{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;} | ||
| 257 | button.link{background:none;border:none;color:var(--accent);cursor:pointer;font:inherit;padding:0;text-decoration:underline;} | ||
| 258 | """ | ||
| 259 | |||
| 260 | |||
| 261 | EXPLORER_JS = r""" | ||
| 262 | var SUM = [], SHOWN = 200; | ||
| 263 | function btf(sat){ return (sat/1e8).toFixed(8).replace(/0+$/,'').replace(/\.$/,'')+" BTF"; } | ||
| 264 | function ts(t){ return new Date(t*1000).toISOString().replace('T',' ').replace('.000Z',' UTC'); } | ||
| 265 | function short(h){ return h ? h.slice(0,10)+"..."+h.slice(-6) : "-"; } | ||
| 266 | function 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 | } | ||
| 272 | function code(text){ return E('code', '', text); } | ||
| 273 | function clear(e){ while(e.firstChild) e.removeChild(e.firstChild); } | ||
| 274 | function append(parent){ | ||
| 275 | for(var i=1;i<arguments.length;i++) parent.appendChild(arguments[i]); | ||
| 276 | return parent; | ||
| 277 | } | ||
| 278 | function row(cells, cls){ | ||
| 279 | var tr = E('tr', cls || ''); | ||
| 280 | cells.forEach(function(c){ append(tr, E('td', '', c)); }); | ||
| 281 | return tr; | ||
| 282 | } | ||
| 283 | function 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 | } | ||
| 303 | function kv(parent, key, value, isCode){ | ||
| 304 | append(parent, E('div', '', key)); | ||
| 305 | append(parent, isCode ? append(E('div'), code(value)) : E('div', '', value)); | ||
| 306 | } | ||
| 307 | function 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 | } | ||
| 313 | function 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 | } | ||
| 362 | document.getElementById('home').onclick=function(){ | ||
| 363 | document.getElementById('detail').hidden = true; | ||
| 364 | document.getElementById('listview').hidden = false; | ||
| 365 | this.hidden = true; | ||
| 366 | }; | ||
| 367 | document.getElementById('q').oninput=function(){ renderList(this.value); }; | ||
| 368 | fetch('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 | |||
| 375 | if __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 | |||
| 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/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. | ||
| 12 | set -euo pipefail | ||
| 13 | cd "$(dirname "$0")/.." | ||
| 14 | |||
| 15 | phrases='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. | ||
| 21 | bad=$(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 | |||
| 33 | if [ -n "$bad" ]; then | ||
| 34 | echo "error: money-moving payout line(s) on collapsing LogPrint:" >&2 | ||
| 35 | echo "$bad" >&2 | ||
| 36 | exit 1 | ||
| 37 | fi | ||
| 38 | echo "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 | ||
| 11 | Creates SHA256SUMS for release assets in the current directory: | 11 | Creates 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 | |||
| 44 | assets=() | 45 | assets=() |
| 45 | for pattern in \ | 46 | for 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" |
| 49 | do | 51 | do |
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 | ||
| 2 | set -euo pipefail | ||
| 3 | |||
| 4 | ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" | ||
| 5 | VERSION="${VERSION:-$(sed -n 's/^VERSION[[:space:]]*:= *//p' "$ROOT/Makefile" | head -1)}" | ||
| 6 | |||
| 7 | TOR_VERSION="${TOR_VERSION:-15.0.19}" | ||
| 8 | TOR_ARCHIVE_NAME="tor-expert-bundle-windows-x86_64-${TOR_VERSION}.tar.gz" | ||
| 9 | TOR_URL="${TOR_URL:-https://archive.torproject.org/tor-package-archive/torbrowser/${TOR_VERSION}/${TOR_ARCHIVE_NAME}}" | ||
| 10 | TOR_SHA256="${TOR_SHA256:-6AC067402C7B4A3DC37887ED3754B3914B67FDC220C966190683E9CCF91ABF0F}" | ||
| 11 | TOR_ASC_URL="${TOR_ASC_URL:-${TOR_URL}.asc}" | ||
| 12 | TOR_SIGNING_KEY_FINGERPRINT="${TOR_SIGNING_KEY_FINGERPRINT:-EF6E286DDA85EA2A4BA7DE684E2C6E8793298290}" | ||
| 13 | TOR_VERIFY_GPG="${TOR_VERIFY_GPG:-auto}" | ||
| 14 | |||
| 15 | BUNDLE_DIR="${BUNDLE_DIR:-$ROOT/Bitflash-${VERSION}-windows}" | ||
| 16 | OUT_ZIP="${OUT_ZIP:-$ROOT/Bitflash-${VERSION}-windows-with-tor.zip}" | ||
| 17 | CACHE_DIR="${CACHE_DIR:-$ROOT/.cache}" | ||
| 18 | ARCHIVE="${TOR_ARCHIVE:-$CACHE_DIR/$TOR_ARCHIVE_NAME}" | ||
| 19 | ASC_FILE="${TOR_ASC:-$ARCHIVE.asc}" | ||
| 20 | |||
| 21 | usage() { | ||
| 22 | cat <<EOF | ||
| 23 | Usage: scripts/package-windows-tor.sh | ||
| 24 | |||
| 25 | Creates Bitflash-${VERSION}-windows-with-tor.zip from an existing | ||
| 26 | Bitflash-${VERSION}-windows/ release directory. | ||
| 27 | |||
| 28 | Environment 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 | ||
| 39 | EOF | ||
| 40 | } | ||
| 41 | |||
| 42 | if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then | ||
| 43 | usage | ||
| 44 | exit 0 | ||
| 45 | fi | ||
| 46 | |||
| 47 | need() { | ||
| 48 | command -v "$1" >/dev/null 2>&1 || { | ||
| 49 | echo "missing required command: $1" >&2 | ||
| 50 | exit 1 | ||
| 51 | } | ||
| 52 | } | ||
| 53 | |||
| 54 | need sha256sum | ||
| 55 | need tar | ||
| 56 | need zip | ||
| 57 | |||
| 58 | download() { | ||
| 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 | |||
| 71 | if [ ! -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 | ||
| 75 | fi | ||
| 76 | if [ ! -f "$BUNDLE_DIR/Bitflash.exe" ]; then | ||
| 77 | echo "missing Bitflash.exe in $BUNDLE_DIR" >&2 | ||
| 78 | exit 1 | ||
| 79 | fi | ||
| 80 | BUNDLE_PARENT="$(cd "$(dirname "$BUNDLE_DIR")" && pwd)" | ||
| 81 | BUNDLE_NAME="$(basename "$BUNDLE_DIR")" | ||
| 82 | OUT_PARENT="$(mkdir -p "$(dirname "$OUT_ZIP")" && cd "$(dirname "$OUT_ZIP")" && pwd)" | ||
| 83 | OUT_NAME="$(basename "$OUT_ZIP")" | ||
| 84 | OUT_ZIP_ABS="$OUT_PARENT/$OUT_NAME" | ||
| 85 | |||
| 86 | mkdir -p "$CACHE_DIR" | ||
| 87 | if [ ! -f "$ARCHIVE" ]; then | ||
| 88 | download "$TOR_URL" "$ARCHIVE" | ||
| 89 | fi | ||
| 90 | |||
| 91 | got="$(sha256sum "$ARCHIVE" | awk '{print tolower($1)}')" | ||
| 92 | want="$(printf '%s' "$TOR_SHA256" | tr 'A-F' 'a-f')" | ||
| 93 | if [ "$got" != "$want" ]; then | ||
| 94 | echo "Tor archive SHA256 mismatch" >&2 | ||
| 95 | echo " expected: $want" >&2 | ||
| 96 | echo " got: $got" >&2 | ||
| 97 | exit 1 | ||
| 98 | fi | ||
| 99 | |||
| 100 | tmp="$(mktemp -d "${TMPDIR:-/tmp}/bitflash-tor-package.XXXXXX")" | ||
| 101 | trap 'rm -rf "$tmp"' EXIT | ||
| 102 | |||
| 103 | case "$TOR_VERIFY_GPG" in | ||
| 104 | auto|required|off) ;; | ||
| 105 | *) echo "TOR_VERIFY_GPG must be auto, required, or off" >&2; exit 2 ;; | ||
| 106 | esac | ||
| 107 | |||
| 108 | if [ "$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 | ||
| 143 | fi | ||
| 144 | |||
| 145 | mkdir -p "$tmp/extract" | ||
| 146 | tar -xzf "$ARCHIVE" -C "$tmp/extract" | ||
| 147 | |||
| 148 | if [ ! -f "$tmp/extract/tor/tor.exe" ]; then | ||
| 149 | echo "Tor archive did not contain tor/tor.exe" >&2 | ||
| 150 | exit 1 | ||
| 151 | fi | ||
| 152 | |||
| 153 | rm -rf "$BUNDLE_DIR/tor" | ||
| 154 | mkdir -p "$BUNDLE_DIR/tor" | ||
| 155 | cp -R "$tmp/extract/tor/." "$BUNDLE_DIR/tor/" | ||
| 156 | if [ -d "$tmp/extract/docs" ]; then | ||
| 157 | mkdir -p "$BUNDLE_DIR/tor/docs" | ||
| 158 | cp -R "$tmp/extract/docs/." "$BUNDLE_DIR/tor/docs/" | ||
| 159 | fi | ||
| 160 | if [ -d "$tmp/extract/data" ]; then | ||
| 161 | mkdir -p "$BUNDLE_DIR/tor/data-defaults" | ||
| 162 | cp -R "$tmp/extract/data/." "$BUNDLE_DIR/tor/data-defaults/" | ||
| 163 | fi | ||
| 164 | |||
| 165 | cat > "$BUNDLE_DIR/TOR-EXPERT-BUNDLE.txt" <<EOF | ||
| 166 | Bitflash managed Tor bundle | ||
| 167 | |||
| 168 | This Windows package includes the Tor Expert Bundle so -managedtor can start | ||
| 169 | Tor without requiring a separate Tor install. | ||
| 170 | |||
| 171 | Tor Expert Bundle: ${TOR_VERSION} | ||
| 172 | Archive URL: ${TOR_URL} | ||
| 173 | Archive SHA256: ${TOR_SHA256} | ||
| 174 | |||
| 175 | Bitflash uses tor/tor.exe from this package. Runtime state, hidden-service keys, | ||
| 176 | and control authentication cookies are written under the user's Bitflash data | ||
| 177 | directory, not inside this release directory. | ||
| 178 | EOF | ||
| 179 | |||
| 180 | rm -f "$OUT_ZIP_ABS" | ||
| 181 | ( | ||
| 182 | cd "$BUNDLE_PARENT" | ||
| 183 | zip -qr "$OUT_ZIP_ABS" "$BUNDLE_NAME" | ||
| 184 | ) | ||
| 185 | |||
| 186 | echo "Bundled Tor Expert Bundle ${TOR_VERSION}" | ||
| 187 | echo "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 | |||
| 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/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 @@ | |||
| 1 | param( | ||
| 2 | [string]$Bitflash, | ||
| 3 | [string]$Tor, | ||
| 4 | [string]$WorkDir, | ||
| 5 | [int]$TimeoutSec = 600, | ||
| 6 | [int]$OnionWarmupSec = 120 | ||
| 7 | ) | ||
| 8 | |||
| 9 | $ErrorActionPreference = "Stop" | ||
| 10 | |||
| 11 | function Resolve-RepoRoot { | ||
| 12 | $scriptDir = Split-Path -Parent $PSCommandPath | ||
| 13 | return (Resolve-Path (Join-Path $scriptDir "..")).Path | ||
| 14 | } | ||
| 15 | |||
| 16 | function 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 | |||
| 27 | function 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 | |||
| 50 | function 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 | ||
| 80 | if ([string]::IsNullOrWhiteSpace($Bitflash)) { | ||
| 81 | $Bitflash = Join-Path $repo "src\bitflash.exe" | ||
| 82 | } | ||
| 83 | if ([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 | } | ||
| 95 | if ([string]::IsNullOrWhiteSpace($WorkDir)) { | ||
| 96 | $WorkDir = Join-Path $env:TEMP ("bitflash-managed-tor-two-node-" + (Get-Date -Format "yyyyMMddHHmmss")) | ||
| 97 | } | ||
| 98 | |||
| 99 | if (!(Test-Path $Bitflash)) { | ||
| 100 | throw "Bitflash binary not found: $Bitflash" | ||
| 101 | } | ||
| 102 | if (!(Test-Path $Tor)) { | ||
| 103 | throw "Tor binary not found. Pass -Tor or build the Windows-with-Tor package." | ||
| 104 | } | ||
| 105 | |||
| 106 | New-Item -ItemType Directory -Force -Path $WorkDir | Out-Null | ||
| 107 | $dataA = Join-Path $WorkDir "node-a" | ||
| 108 | $dataB = Join-Path $WorkDir "node-b" | ||
| 109 | New-Item -ItemType Directory -Force -Path $dataA | Out-Null | ||
| 110 | New-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 | |||
| 122 | try { | ||
| 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 | } | ||
| 168 | finally { | ||
| 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 | |||
| 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 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 |
| 2 | set -euo pipefail | 2 | set -euo pipefail |
| 3 | 3 | ||
| 4 | repo="bitflash-coin/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:])) | ||