diff options
| author | bitflash-maintainer <noreply@bitflash.invalid> | 2026-09-05 22:46:52 +0000 |
|---|---|---|
| committer | bitflash-maintainer <noreply@bitflash.invalid> | 2026-09-05 22:46:52 +0000 |
| commit | ceae3d076b5ad11ee84729ae43f14e9312896646 (patch) | |
| tree | 7e9132aa871fdc01cb95268d2f5ea3bb11853ddf /scripts | |
| parent | be63e83d79e7e00e6b8ca889a70e6a19886aa1d8 (diff) | |
| parent | 66e0b9158d050c46b1ff2509faeee3f4573cac1c (diff) | |
Bring main onto the line this project actually ships
main had drifted to a history with no ancestor in common with the tree we
build and deploy. It sat at be63e83 (2026-08-09) with 28 commits; the shipped
line, deploy/soak-2026-08-21, had 383. The two shared nothing -- git
merge-base returns no result at all -- so anyone cloning this repository
landed on a default branch that was neither current nor related to the
running node.
The 28 commits on main were audited before this merge: every one is already
present in the tree by content, including the two that are hardest to see
because they were rewritten afterwards -- the payout reorg-safety work
(PayoutBlockStillOnChain, since evolved to re-check on every retry rather
than once at maturity) and the nostr relay certificate verification
(SSL_set1_host, now with a Windows root-store path). Nothing on main is lost
by this merge.
Recorded as a merge rather than a force-push: main only ever moves forward,
its old history stays reachable as the first parent, and this is revertible.
The tree is taken wholesale from the merged branch, so main's content is now
exactly what was built and tested.
What this brings onto main:
- compactblock: BIP152-style compact block relay ported from upstream 1.2.23,
primitives and unit test only, with the RAND_bytes failure path closed and
the reconstruction allocation bounded.
- upstream/main 17741d9: isolated testnet parameters and genesis, optional
2 MB large pages for RandomX. Mainnet genesis verified byte-identical
through upstream's BuildGenesisBlock() refactor.
- deploy: every image built on bookworm so binaries run on Debian 12, and the
container build now honours the RandomX and libsecp256k1 pins it had been
silently ignoring since before 1.2.15.
Gates at this tree: 15/15 selftests, compactblock's 17 checks, consensus
constants unchanged against e4f898c apart from the pre-existing
MAX_BLOCK_SIZE_GEN, mainnet genesis intact, money-log check ok.
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:])) | ||