diff options
29 files changed, 1941 insertions, 930 deletions
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba8074c..f4045f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml | |||
| @@ -25,6 +25,9 @@ jobs: | |||
| 25 | - name: Run Linux selftests | 25 | - name: Run Linux selftests |
| 26 | run: make tests | 26 | run: make tests |
| 27 | 27 | ||
| 28 | - name: Run Python tool regressions | ||
| 29 | run: python3 tests/scripts/test_bitflash_tools.py | ||
| 30 | |||
| 28 | - name: Run P2P message fuzz smoke | 31 | - name: Run P2P message fuzz smoke |
| 29 | run: make fuzz-net-message-smoke | 32 | run: make fuzz-net-message-smoke |
| 30 | 33 | ||
| @@ -10,7 +10,7 @@ | |||
| 10 | ROOT := $(shell pwd) | 10 | ROOT := $(shell pwd) |
| 11 | NPROC := $(shell nproc 2>/dev/null || echo 2) | 11 | NPROC := $(shell nproc 2>/dev/null || echo 2) |
| 12 | SUDO := $(shell [ "$$(id -u)" = "0" ] && echo "" || echo "sudo") | 12 | SUDO := $(shell [ "$$(id -u)" = "0" ] && echo "" || echo "sudo") |
| 13 | VERSION := 1.2.16 | 13 | VERSION := 1.2.17 |
| 14 | 14 | ||
| 15 | SECP256K1_REPO := https://github.com/bitcoin-core/secp256k1 | 15 | SECP256K1_REPO := https://github.com/bitcoin-core/secp256k1 |
| 16 | SECP256K1_COMMIT := 7fecac74aed8e1fd9078380d67dd04663705c989 | 16 | SECP256K1_COMMIT := 7fecac74aed8e1fd9078380d67dd04663705c989 |
| @@ -36,7 +36,8 @@ Worth being precise, because the difference matters if you are relying on it. | |||
| 36 | 36 | ||
| 37 | **The node still listens on 8433.** Nothing dials by IP any more, but the listener is still there, so anyone who already knows your address and can reach that port may connect directly. If that matters to you, firewall it. | 37 | **The node still listens on 8433.** Nothing dials by IP any more, but the listener is still there, so anyone who already knows your address and can reach that port may connect directly. If that matters to you, firewall it. |
| 38 | 38 | ||
| 39 | This is unlinkability between peers, not anonymity against a network observer. It is not Tor. | 39 | This is unlinkability between peers, not anonymity against a network observer. |
| 40 | For Tor routing, start the node with `-tor`; see [Tor mode](docs/tor.md). | ||
| 40 | 41 | ||
| 41 | --- | 42 | --- |
| 42 | 43 | ||
| @@ -104,6 +105,14 @@ and produces canonical JSON whose hash can be compared by independent auditors | |||
| 104 | or timestamped externally with OpenTimestamps. See | 105 | or timestamped externally with OpenTimestamps. See |
| 105 | [fair-launch verification](docs/fair-launch.md). | 106 | [fair-launch verification](docs/fair-launch.md). |
| 106 | 107 | ||
| 108 | For a static local block explorer, generate HTML and JSON from either a data | ||
| 109 | directory or explicit block files: | ||
| 110 | |||
| 111 | ```bash | ||
| 112 | python3 scripts/build-explorer.py --datadir ~/.bitflash ./explorer-out | ||
| 113 | python3 scripts/build-explorer.py ~/.bitflash/blk0001.dat ~/.bitflash/blk0002.dat ./explorer-out | ||
| 114 | ``` | ||
| 115 | |||
| 107 | **Keep your node current.** Consensus rules have changed since the first | 116 | **Keep your node current.** Consensus rules have changed since the first |
| 108 | releases — 1.2.1 fixed a bug that let anyone spend anyone's coins, and 1.2.2 | 117 | releases — 1.2.1 fixed a bug that let anyone spend anyone's coins, and 1.2.2 |
| 109 | added a per-block signature-operation cap. A node on an older build will accept | 118 | added a per-block signature-operation cap. A node on an older build will accept |
| @@ -310,6 +319,8 @@ Other options worth knowing: | |||
| 310 | ```bash | 319 | ```bash |
| 311 | -datadir=PATH # wallet and chain data elsewhere | 320 | -datadir=PATH # wallet and chain data elsewhere |
| 312 | -port=N # P2P listen port, default 8433 | 321 | -port=N # P2P listen port, default 8433 |
| 322 | -socks=HOST:PORT # SOCKS5 for outbound Nostr and .btf rendezvous dials | ||
| 323 | -tor[=HOST:PORT] # Tor mode; default local Tor SOCKS5 proxy is 127.0.0.1:9050 | ||
| 313 | -debug # verbose log; without it debug.log is nearly silent | 324 | -debug # verbose log; without it debug.log is nearly silent |
| 314 | -help # full list | 325 | -help # full list |
| 315 | ``` | 326 | ``` |
| @@ -318,6 +329,16 @@ Other options worth knowing: | |||
| 318 | needed — the data directory takes an exclusive lock, so a second node pointed at | 329 | needed — the data directory takes an exclusive lock, so a second node pointed at |
| 319 | the same one will refuse to start. | 330 | the same one will refuse to start. |
| 320 | 331 | ||
| 332 | `-socks=127.0.0.1:9050` routes outbound Nostr discovery and `.btf` rendezvous | ||
| 333 | dials through a local SOCKS5 proxy such as Tor. When it is set, the node skips | ||
| 334 | plain HTTP external-IP probes instead of leaking a direct request outside the | ||
| 335 | proxy. IPv6 proxy endpoints use brackets, for example `-socks=[::1]:9050`. | ||
| 336 | |||
| 337 | `-tor` is a privacy shorthand for the usual local Tor SOCKS5 listener at | ||
| 338 | `127.0.0.1:9050`. It routes outbound Nostr discovery and `.btf` rendezvous | ||
| 339 | dials through Tor, refuses direct `.onion` resolution, and keeps external-IP | ||
| 340 | probes disabled. Use `-tor=HOST:PORT` when Tor listens somewhere else. | ||
| 341 | |||
| 321 | ### When something looks wrong | 342 | ### When something looks wrong |
| 322 | 343 | ||
| 323 | **Menu > Diagnostics**, and the same report in `debug.log` every ten minutes: | 344 | **Menu > Diagnostics**, and the same report in `debug.log` every ten minutes: |
diff --git a/docs/fair-launch.md b/docs/fair-launch.md index 79eca8e..9603910 100644 --- a/docs/fair-launch.md +++ b/docs/fair-launch.md | |||
| @@ -8,7 +8,8 @@ python3 scripts/verify-fair-launch.py --max-blocks 1000 --out bitflash-fair-laun | |||
| 8 | 8 | ||
| 9 | The verifier reads `blk*.dat` directly from the local Bitflash data directory. | 9 | The verifier reads `blk*.dat` directly from the local Bitflash data directory. |
| 10 | It does not use Berkeley DB, does not call the node binary, and does not need | 10 | It does not use Berkeley DB, does not call the node binary, and does not need |
| 11 | network access. | 11 | network access. It uses the same shared Python chain parser as the UTXO |
| 12 | commitment and proof tools, so block serialization fixes land in one place. | ||
| 12 | 13 | ||
| 13 | The report proves the launch baseline that can be checked from the chain: | 14 | The report proves the launch baseline that can be checked from the chain: |
| 14 | 15 | ||
diff --git a/docs/social.png b/docs/social.png index 19e2a3b..9a08b02 100644 --- a/docs/social.png +++ b/docs/social.png | |||
| Binary files differ | |||
diff --git a/docs/tor.md b/docs/tor.md new file mode 100644 index 0000000..087c822 --- /dev/null +++ b/docs/tor.md | |||
| @@ -0,0 +1,84 @@ | |||
| 1 | # Tor mode | ||
| 2 | |||
| 3 | Bitflash can route outbound discovery traffic through a local Tor SOCKS5 proxy. | ||
| 4 | This is transport plumbing only: it does not change consensus, wallet keys, | ||
| 5 | mining, `.btf` addresses, or the encrypted rendezvous protocol. | ||
| 6 | |||
| 7 | ## Client mode | ||
| 8 | |||
| 9 | Start Tor locally, then start Bitflash with: | ||
| 10 | |||
| 11 | ```bash | ||
| 12 | bitflash -tor | ||
| 13 | ``` | ||
| 14 | |||
| 15 | `-tor` is shorthand for: | ||
| 16 | |||
| 17 | ```bash | ||
| 18 | bitflash -socks=127.0.0.1:9050 | ||
| 19 | ``` | ||
| 20 | |||
| 21 | If Tor listens somewhere else: | ||
| 22 | |||
| 23 | ```bash | ||
| 24 | bitflash -tor=127.0.0.1:9150 | ||
| 25 | bitflash -tor=[::1]:9050 | ||
| 26 | ``` | ||
| 27 | |||
| 28 | When Tor mode is enabled: | ||
| 29 | |||
| 30 | - outbound Nostr relay dials use Tor; | ||
| 31 | - outbound `.btf` rendezvous relay dials use Tor; | ||
| 32 | - destination names are sent to Tor as SOCKS5 domain-name CONNECT requests, so | ||
| 33 | the local DNS resolver does not see them; | ||
| 34 | - direct `.onion` dials are refused unless a SOCKS5/Tor proxy is enabled; | ||
| 35 | - plain HTTP external-IP probes are skipped. | ||
| 36 | |||
| 37 | ## Onion rendezvous relay | ||
| 38 | |||
| 39 | An operator can expose a Bitflash rendezvous relay as a Tor hidden service. The | ||
| 40 | node does not create hidden services itself; Tor owns that configuration. | ||
| 41 | |||
| 42 | Example `torrc`: | ||
| 43 | |||
| 44 | ```text | ||
| 45 | HiddenServiceDir /var/lib/tor/bitflash-rendezvous/ | ||
| 46 | HiddenServicePort 8434 127.0.0.1:8434 | ||
| 47 | ``` | ||
| 48 | |||
| 49 | After restarting Tor, read the generated onion name: | ||
| 50 | |||
| 51 | ```bash | ||
| 52 | sudo cat /var/lib/tor/bitflash-rendezvous/hostname | ||
| 53 | ``` | ||
| 54 | |||
| 55 | Then run the relay on localhost/VPS as usual and announce the onion endpoint from | ||
| 56 | nodes that use Tor: | ||
| 57 | |||
| 58 | ```bash | ||
| 59 | bitflash -tor -rvrelay=exampleexampleexampleexampleexampleexampleexampleexample.onion:8434 | ||
| 60 | bitflash -tor -announcerelay=exampleexampleexampleexampleexampleexampleexampleexample.onion:8434 | ||
| 61 | ``` | ||
| 62 | |||
| 63 | For a pool operator that wants the pool descriptor to point at an onion | ||
| 64 | rendezvous relay, use both: | ||
| 65 | |||
| 66 | ```bash | ||
| 67 | bitflash -operator -tor \ | ||
| 68 | -rvrelay=exampleexampleexampleexampleexampleexampleexampleexample.onion:8434 \ | ||
| 69 | -announcerelay=exampleexampleexampleexampleexampleexampleexampleexample.onion:8434 | ||
| 70 | ``` | ||
| 71 | |||
| 72 | ## Full-node hidden service | ||
| 73 | |||
| 74 | The legacy P2P listener still binds to port `8433`. You can expose it through | ||
| 75 | Tor manually: | ||
| 76 | |||
| 77 | ```text | ||
| 78 | HiddenServiceDir /var/lib/tor/bitflash-node/ | ||
| 79 | HiddenServicePort 8433 127.0.0.1:8433 | ||
| 80 | ``` | ||
| 81 | |||
| 82 | Current automatic peer discovery is `.btf`/rendezvous based, not onion-address | ||
| 83 | gossip. Treat direct full-node onion access as an operator/admin tool unless a | ||
| 84 | future release adds first-class onion peer advertisements. | ||
diff --git a/docs/utxo-commitment.md b/docs/utxo-commitment.md index 991e5d9..ab43deb 100644 --- a/docs/utxo-commitment.md +++ b/docs/utxo-commitment.md | |||
| @@ -21,7 +21,8 @@ python3 scripts/verify-utxo-set.py --out utxo-report.json --json | |||
| 21 | 21 | ||
| 22 | The verifier: | 22 | The verifier: |
| 23 | 23 | ||
| 24 | - parses Bitflash block files directly; | 24 | - parses Bitflash block files directly with the shared Python chain parser; |
| 25 | - streams `blk*.dat` records instead of loading full block files into memory; | ||
| 25 | - reconstructs the best chain rooted at the Bitflash genesis block; | 26 | - reconstructs the best chain rooted at the Bitflash genesis block; |
| 26 | - ignores side blocks that may also be present in `blk*.dat`; | 27 | - ignores side blocks that may also be present in `blk*.dat`; |
| 27 | - verifies every scanned block merkle root; | 28 | - verifies every scanned block merkle root; |
| @@ -98,6 +99,23 @@ last-write-wins semantics for the UTXO map, matching the legacy index model. | |||
| 98 | Those warnings are part of the report so auditors can see the historical debt | 99 | Those warnings are part of the report so auditors can see the historical debt |
| 99 | instead of relying on a polished summary. | 100 | instead of relying on a polished summary. |
| 100 | 101 | ||
| 102 | For synthetic chains or future clean consensus ranges, auditors can make | ||
| 103 | duplicates a hard failure: | ||
| 104 | |||
| 105 | ```bash | ||
| 106 | python3 scripts/verify-utxo-set.py --strict-duplicates | ||
| 107 | ``` | ||
| 108 | |||
| 109 | The default remains compatible with the current historical chain because it has | ||
| 110 | known legacy duplicate-output overwrites. | ||
| 111 | |||
| 112 | ## Shared Parser | ||
| 113 | |||
| 114 | The audit scripts share `scripts/bitflash_chain.py` for block parsing, | ||
| 115 | main-chain selection, UTXO application, canonical JSON, and genesis | ||
| 116 | reconstruction. That keeps `verify-utxo-set.py`, `verify-fair-launch.py`, | ||
| 117 | `prove-utxo.py`, and the explorer on the same serialization rules. | ||
| 118 | |||
| 101 | ## Future Consensus Path | 119 | ## Future Consensus Path |
| 102 | 120 | ||
| 103 | This tool is the low-risk first step toward consensus commitments. A future | 121 | This tool is the low-risk first step toward consensus commitments. A future |
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 index 7849e8a..97a7f4e 100755 --- a/scripts/build-explorer.py +++ b/scripts/build-explorer.py | |||
| @@ -1,134 +1,164 @@ | |||
| 1 | #!/usr/bin/env python3 | 1 | #!/usr/bin/env python3 |
| 2 | # | 2 | """Build a static Bitflash block explorer from local blk*.dat files.""" |
| 3 | # Build a static Bitflash block explorer (v0) from a node's blk0001.dat. | ||
| 4 | # Emits into <out-dir>: | ||
| 5 | # blocks.json summary of every main-chain block (newest first) | ||
| 6 | # block/<height>.json full detail per block (header + all txs) | ||
| 7 | # index.html self-contained viewer (vanilla JS, same-origin fetch) | ||
| 8 | # | ||
| 9 | # Scope (v0, deliberately): block list, block detail, and coinbase. No mempool, | ||
| 10 | # no per-address history. On this chain the coinbase payout key rotates every | ||
| 11 | # block, so an address index would be nearly useless; block-level transparency is | ||
| 12 | # what lets miners and users confirm the chain is honest. | ||
| 13 | # | ||
| 14 | # Usage: build-explorer.py <blk0001.dat> <out-dir> | ||
| 15 | |||
| 16 | import sys, os, json, hashlib, struct | ||
| 17 | |||
| 18 | MAGIC = bytes([0xbf, 0x20, 0x5c, 0xfd]) | ||
| 19 | ADDRESS_VERSION = 25 # Bitflash addresses start with "B" | ||
| 20 | |||
| 21 | def sha256d(b): return hashlib.sha256(hashlib.sha256(b).digest()).digest() | ||
| 22 | 3 | ||
| 4 | import argparse | ||
| 5 | import hashlib | ||
| 6 | import json | ||
| 7 | import sys | ||
| 8 | from pathlib import Path | ||
| 9 | |||
| 10 | import bitflash_chain as chain | ||
| 11 | |||
| 12 | |||
| 13 | ADDRESS_VERSION = 25 | ||
| 23 | B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" | 14 | B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" |
| 15 | |||
| 16 | |||
| 24 | def b58check(payload): | 17 | def b58check(payload): |
| 25 | v = bytes([ADDRESS_VERSION]) + payload | 18 | v = bytes([ADDRESS_VERSION]) + payload |
| 26 | v += sha256d(v)[:4] | 19 | v += chain.sha256d(v)[:4] |
| 27 | n = int.from_bytes(v, 'big') | 20 | n = int.from_bytes(v, "big") |
| 28 | s = "" | 21 | s = "" |
| 29 | while n > 0: | 22 | while n > 0: |
| 30 | n, r = divmod(n, 58); s = B58[r] + s | 23 | n, r = divmod(n, 58) |
| 24 | s = B58[r] + s | ||
| 31 | for byte in v: | 25 | for byte in v: |
| 32 | if byte == 0: s = "1" + s | 26 | if byte == 0: |
| 33 | else: break | 27 | s = "1" + s |
| 28 | else: | ||
| 29 | break | ||
| 34 | return s | 30 | return s |
| 35 | 31 | ||
| 32 | |||
| 36 | def hash160(b): | 33 | def hash160(b): |
| 37 | return hashlib.new('ripemd160', hashlib.sha256(b).digest()).digest() | 34 | return hashlib.new("ripemd160", hashlib.sha256(b).digest()).digest() |
| 38 | 35 | ||
| 39 | class R: | 36 | |
| 40 | def __init__(s, b, o=0): s.b = b; s.o = o | 37 | def spk_info(script_hex): |
| 41 | def u(s, n): v = s.b[s.o:s.o+n]; s.o += n; return v | 38 | try: |
| 42 | def i32(s): return struct.unpack('<i', s.u(4))[0] | 39 | spk = bytes.fromhex(script_hex) |
| 43 | def u32(s): return struct.unpack('<I', s.u(4))[0] | 40 | except ValueError: |
| 44 | def i64(s): return struct.unpack('<q', s.u(8))[0] | 41 | return {"type": "malformed", "hex": script_hex[:80]} |
| 45 | def cs(s): | 42 | if ( |
| 46 | c = s.b[s.o]; s.o += 1 | 43 | len(spk) == 25 and spk[0] == 0x76 and spk[1] == 0xA9 |
| 47 | if c < 253: return c | 44 | and spk[2] == 0x14 and spk[23] == 0x88 and spk[24] == 0xAC |
| 48 | if c == 253: return struct.unpack('<H', s.u(2))[0] | 45 | ): |
| 49 | if c == 254: return struct.unpack('<I', s.u(4))[0] | ||
| 50 | return struct.unpack('<Q', s.u(8))[0] | ||
| 51 | def script(s): n = s.cs(); return s.u(n) | ||
| 52 | |||
| 53 | def spk_info(spk): | ||
| 54 | # P2PKH: OP_DUP OP_HASH160 <20> OP_EQUALVERIFY OP_CHECKSIG | ||
| 55 | if len(spk) == 25 and spk[0] == 0x76 and spk[1] == 0xa9 and spk[2] == 0x14 and spk[23] == 0x88 and spk[24] == 0xac: | ||
| 56 | return {"type": "p2pkh", "address": b58check(spk[3:23])} | 46 | return {"type": "p2pkh", "address": b58check(spk[3:23])} |
| 57 | # P2PK: <pubkey> OP_CHECKSIG (65- or 33-byte key) | 47 | if len(spk) >= 35 and spk[-1] == 0xAC: |
| 58 | if len(spk) >= 35 and spk[-1] == 0xac: | ||
| 59 | if spk[0] == 0x41 and len(spk) == 67: | 48 | if spk[0] == 0x41 and len(spk) == 67: |
| 60 | pk = spk[1:66]; return {"type": "p2pk", "pubkey": pk.hex(), "address": b58check(hash160(pk))} | 49 | pk = spk[1:66] |
| 50 | return {"type": "p2pk", "pubkey": pk.hex(), "address": b58check(hash160(pk))} | ||
| 61 | if spk[0] == 0x21 and len(spk) == 35: | 51 | if spk[0] == 0x21 and len(spk) == 35: |
| 62 | pk = spk[1:34]; return {"type": "p2pk", "pubkey": pk.hex(), "address": b58check(hash160(pk))} | 52 | pk = spk[1:34] |
| 63 | return {"type": "other", "hex": spk.hex()[:80]} | 53 | return {"type": "p2pk", "pubkey": pk.hex(), "address": b58check(hash160(pk))} |
| 64 | 54 | return {"type": "other", "hex": script_hex[:80]} | |
| 65 | def parse_tx(r): | 55 | |
| 66 | start = r.o | 56 | |
| 67 | r.i32() | 57 | def explorer_tx(tx): |
| 68 | nin = r.cs(); vin = [] | 58 | vin = [] |
| 69 | for _ in range(nin): | 59 | for txin in tx["vin"]: |
| 70 | ph = r.u(32); idx = r.u32(); r.script(); r.u32() | 60 | coinbase = ( |
| 71 | coinbase = (ph == b'\x00' * 32 and idx == 0xffffffff) | 61 | txin["prevout_hash"] == "0" * 64 |
| 72 | vin.append({"coinbase": coinbase, "prev": (None if coinbase else ph[::-1].hex()), "vout": (None if coinbase else idx)}) | 62 | and txin["prevout_n"] == 0xFFFFFFFF |
| 73 | nout = r.cs(); vout = [] | 63 | ) |
| 74 | for _ in range(nout): | 64 | vin.append({ |
| 75 | val = r.i64(); spk = r.script(); vout.append(dict({"value": val}, **spk_info(spk))) | 65 | "coinbase": coinbase, |
| 76 | r.u32() | 66 | "prev": None if coinbase else txin["prevout_hash"], |
| 77 | raw = r.b[start:r.o] | 67 | "vout": None if coinbase else txin["prevout_n"], |
| 78 | return {"txid": sha256d(raw)[::-1].hex(), "vin": vin, "vout": vout, "size": len(raw)} | 68 | }) |
| 79 | 69 | vout = [] | |
| 80 | def main(): | 70 | for out in tx["vout"]: |
| 81 | if len(sys.argv) != 3: | 71 | vout.append(dict({"value": out["value_satoshis"]}, **spk_info(out["script_pub_key_hex"]))) |
| 82 | print("usage: build-explorer.py <blk0001.dat> <out-dir>", file=sys.stderr); sys.exit(2) | 72 | return {"txid": tx["txid"], "vin": vin, "vout": vout, "size": len(chain.ser_tx(tx))} |
| 83 | blkpath, out = sys.argv[1], sys.argv[2] | 73 | |
| 84 | data = open(blkpath, 'rb').read() | 74 | |
| 85 | blocks = {}; o = 0; n = len(data) | 75 | def parse_args(argv): |
| 86 | while o + 8 <= n: | 76 | ap = argparse.ArgumentParser(description=__doc__) |
| 87 | if data[o:o+4] != MAGIC: o += 1; continue | 77 | ap.add_argument("--datadir", help="Bitflash data directory containing blk*.dat") |
| 88 | o += 4; size = struct.unpack('<I', data[o:o+4])[0]; o += 4 | 78 | ap.add_argument("--max-blocks", type=int, default=0, |
| 89 | blk = data[o:o+size]; o += size | 79 | help="number of blocks to scan; 0 means all block files") |
| 90 | if len(blk) < 80: continue | 80 | ap.add_argument("paths", nargs="+", |
| 91 | h = sha256d(blk[:80])[::-1].hex() | 81 | help="with --datadir: <out-dir>; otherwise: <blk*.dat>... <out-dir>") |
| 92 | r = R(blk, 0) | 82 | args = ap.parse_args(argv) |
| 93 | ver = r.i32(); prev = r.u(32)[::-1].hex(); merk = r.u(32)[::-1].hex() | 83 | if args.datadir: |
| 94 | t = r.u32(); bits = r.u32(); nonce = r.u32() | 84 | if len(args.paths) != 1: |
| 95 | ntx = r.cs(); txs = [] | 85 | raise SystemExit("usage with --datadir: build-explorer.py --datadir DATADIR <out-dir>") |
| 96 | try: | 86 | args.block_files = None |
| 97 | for _ in range(ntx): txs.append(parse_tx(r)) | 87 | args.out_dir = args.paths[0] |
| 98 | except Exception: | 88 | else: |
| 99 | continue | 89 | if len(args.paths) < 2: |
| 100 | blocks[h] = {"hash": h, "prev": prev, "merkleRoot": merk, "version": ver, | 90 | raise SystemExit("usage: build-explorer.py <blk*.dat>... <out-dir>") |
| 101 | "time": t, "bits": bits, "nonce": nonce, "size": size, "txs": txs} | 91 | args.block_files = args.paths[:-1] |
| 102 | if not blocks: | 92 | args.out_dir = args.paths[-1] |
| 103 | print("no blocks parsed", file=sys.stderr); sys.exit(1) | 93 | return args |
| 104 | 94 | ||
| 105 | hc = {} | 95 | |
| 106 | def height(h): | 96 | def load_blocks(args): |
| 107 | chain = []; cur = h | 97 | if args.datadir: |
| 108 | while cur in blocks and cur not in hc: | 98 | return chain.read_blocks(args.datadir, args.max_blocks) |
| 109 | chain.append(cur); cur = blocks[cur]["prev"] | 99 | return chain.read_blocks_from_files(args.block_files, args.max_blocks) |
| 110 | base = hc.get(cur, 0) | 100 | |
| 111 | for hh in reversed(chain): base += 1; hc[hh] = base | 101 | |
| 112 | return hc[h] | 102 | def build_explorer(raw_blocks, out_dir): |
| 113 | for h in blocks: height(h) | 103 | try: |
| 114 | tip = max(blocks, key=lambda h: hc[h]) | 104 | blocks = chain.select_main_chain(raw_blocks) |
| 115 | chain = []; cur = tip | 105 | except chain.ParseError as e: |
| 116 | while cur in blocks: chain.append(cur); cur = blocks[cur]["prev"] | 106 | raise SystemExit(str(e)) |
| 117 | chain.reverse() | 107 | out = Path(out_dir) |
| 118 | 108 | (out / "block").mkdir(parents=True, exist_ok=True) | |
| 119 | os.makedirs(os.path.join(out, "block"), exist_ok=True) | 109 | |
| 120 | summaries = [] | 110 | summaries = [] |
| 121 | for h in chain: | 111 | for height, block in enumerate(blocks): |
| 122 | b = blocks[h]; H = hc[h] | 112 | txs = [explorer_tx(tx) for tx in block["transactions"]] |
| 123 | cb = b["txs"][0]["vout"][0] if b["txs"] and b["txs"][0]["vout"] else {} | 113 | detail = { |
| 124 | summaries.append({"height": H, "hash": h, "time": b["time"], "nTx": len(b["txs"]), | 114 | "height": height, |
| 125 | "size": b["size"], "cbValue": cb.get("value", 0), "cbAddr": cb.get("address", "")}) | 115 | "hash": block["hash"], |
| 126 | json.dump(dict({"height": H}, **b), open(os.path.join(out, "block", "%d.json" % H), "w"), separators=(',', ':')) | 116 | "prev": block["previous_hash"], |
| 117 | "merkleRoot": block["merkle_root"], | ||
| 118 | "version": block["version"], | ||
| 119 | "time": block["time"], | ||
| 120 | "bits": int(block["bits"], 16), | ||
| 121 | "nonce": block["nonce"], | ||
| 122 | "size": block["size"], | ||
| 123 | "txs": txs, | ||
| 124 | } | ||
| 125 | cb = txs[0]["vout"][0] if txs and txs[0]["vout"] else {} | ||
| 126 | summaries.append({ | ||
| 127 | "height": height, | ||
| 128 | "hash": block["hash"], | ||
| 129 | "time": block["time"], | ||
| 130 | "nTx": len(txs), | ||
| 131 | "size": block["size"], | ||
| 132 | "cbValue": cb.get("value", 0), | ||
| 133 | "cbAddr": cb.get("address", ""), | ||
| 134 | }) | ||
| 135 | (out / "block" / ("%d.json" % height)).write_text( | ||
| 136 | json.dumps(detail, sort_keys=True, separators=(",", ":")) + "\n", | ||
| 137 | encoding="ascii", | ||
| 138 | ) | ||
| 139 | |||
| 127 | summaries.sort(key=lambda x: -x["height"]) | 140 | summaries.sort(key=lambda x: -x["height"]) |
| 128 | json.dump({"network": "bitflash", "tipHeight": hc[tip], "count": len(chain), "blocks": summaries}, | 141 | (out / "blocks.json").write_text( |
| 129 | open(os.path.join(out, "blocks.json"), "w"), separators=(',', ':')) | 142 | json.dumps({ |
| 130 | open(os.path.join(out, "index.html"), "w").write(INDEX_HTML) | 143 | "network": "bitflash", |
| 131 | print("explorer built: %d main-chain blocks, tip %d, into %s" % (len(chain), hc[tip], out)) | 144 | "tipHeight": len(blocks) - 1, |
| 145 | "count": len(blocks), | ||
| 146 | "blocks": summaries, | ||
| 147 | }, sort_keys=True, separators=(",", ":")) + "\n", | ||
| 148 | encoding="ascii", | ||
| 149 | ) | ||
| 150 | (out / "index.html").write_text(INDEX_HTML, encoding="utf-8") | ||
| 151 | return len(blocks), len(blocks) - 1 | ||
| 152 | |||
| 153 | |||
| 154 | def main(argv): | ||
| 155 | args = parse_args(argv) | ||
| 156 | raw_blocks = load_blocks(args) | ||
| 157 | if not raw_blocks: | ||
| 158 | raise SystemExit("no Bitflash blocks parsed") | ||
| 159 | count, tip = build_explorer(raw_blocks, args.out_dir) | ||
| 160 | print("explorer built: %d main-chain blocks, tip %d, into %s" % (count, tip, args.out_dir)) | ||
| 161 | return 0 | ||
| 132 | 162 | ||
| 133 | 163 | ||
| 134 | INDEX_HTML = r"""<!doctype html> | 164 | INDEX_HTML = r"""<!doctype html> |
| @@ -144,6 +174,7 @@ INDEX_HTML = r"""<!doctype html> | |||
| 144 | body { font: 15px/1.5 system-ui, sans-serif; margin:0; background:var(--bg); color:var(--fg); } | 174 | body { font: 15px/1.5 system-ui, sans-serif; margin:0; background:var(--bg); color:var(--fg); } |
| 145 | header { padding:20px 24px; border-bottom:1px solid var(--line); display:flex; gap:16px; align-items:baseline; flex-wrap:wrap; } | 175 | header { padding:20px 24px; border-bottom:1px solid var(--line); display:flex; gap:16px; align-items:baseline; flex-wrap:wrap; } |
| 146 | h1 { font-size:18px; margin:0; } | 176 | h1 { font-size:18px; margin:0; } |
| 177 | h2 { font-size:16px; } | ||
| 147 | .mut { color:var(--mut); } | 178 | .mut { color:var(--mut); } |
| 148 | .wrap { padding:16px 24px; max-width:1100px; margin:0 auto; } | 179 | .wrap { padding:16px 24px; max-width:1100px; margin:0 auto; } |
| 149 | input { width:100%; max-width:420px; padding:9px 12px; border:1px solid var(--line); border-radius:8px; background:var(--card); color:var(--fg); font-size:14px; } | 180 | input { width:100%; max-width:420px; padding:9px 12px; border:1px solid var(--line); border-radius:8px; background:var(--card); color:var(--fg); font-size:14px; } |
| @@ -154,7 +185,6 @@ INDEX_HTML = r"""<!doctype html> | |||
| 154 | tr.blk:hover td { background:var(--card); } | 185 | tr.blk:hover td { background:var(--card); } |
| 155 | code { font-size:12px; word-break:break-all; } | 186 | code { font-size:12px; word-break:break-all; } |
| 156 | .scroll { overflow-x:auto; } | 187 | .scroll { overflow-x:auto; } |
| 157 | a { color:var(--acc); text-decoration:none; } | ||
| 158 | .card { border:1px solid var(--line); border-radius:10px; background:var(--card); padding:14px 16px; margin-top:12px; } | 188 | .card { border:1px solid var(--line); border-radius:10px; background:var(--card); padding:14px 16px; margin-top:12px; } |
| 159 | .kv { display:grid; grid-template-columns:150px 1fr; gap:4px 12px; font-size:13px; } | 189 | .kv { display:grid; grid-template-columns:150px 1fr; gap:4px 12px; font-size:13px; } |
| 160 | .kv div:nth-child(odd){ color:var(--mut); } | 190 | .kv div:nth-child(odd){ color:var(--mut); } |
| @@ -174,7 +204,7 @@ INDEX_HTML = r"""<!doctype html> | |||
| 174 | <button class="link" id="home" style="display:none">back to blocks</button> | 204 | <button class="link" id="home" style="display:none">back to blocks</button> |
| 175 | </header> | 205 | </header> |
| 176 | <div class="wrap"> | 206 | <div class="wrap"> |
| 177 | <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. On Bitflash the coinbase payout key rotates every block, so an address index would say almost nothing; what matters is that every block and every coinbase is here to check.</p> | 207 | <p class="note">Read-only view of the main chain: blocks, their transactions, and the coinbase payout. There is no mempool and no per-address history here.</p> |
| 178 | <div id="listview"> | 208 | <div id="listview"> |
| 179 | <input id="q" placeholder="Search by height or block hash"> | 209 | <input id="q" placeholder="Search by height or block hash"> |
| 180 | <div class="scroll"><table> | 210 | <div class="scroll"><table> |
| @@ -186,52 +216,114 @@ INDEX_HTML = r"""<!doctype html> | |||
| 186 | <div id="detail" style="display:none"></div> | 216 | <div id="detail" style="display:none"></div> |
| 187 | </div> | 217 | </div> |
| 188 | <script> | 218 | <script> |
| 189 | var SUM = [], BYH = {}, SHOWN = 200; | 219 | var SUM = [], SHOWN = 200; |
| 190 | function btf(sat){ return (sat/1e8).toFixed(8).replace(/0+$/,'').replace(/\.$/,'')+" BTF"; } | 220 | function btf(sat){ return (sat/1e8).toFixed(8).replace(/0+$/,'').replace(/\.$/,'')+" BTF"; } |
| 191 | function ts(t){ return new Date(t*1000).toISOString().replace('T',' ').replace('.000Z',' UTC'); } | 221 | function ts(t){ return new Date(t*1000).toISOString().replace('T',' ').replace('.000Z',' UTC'); } |
| 192 | function esc(s){ return String(s).replace(/[&<>]/g,function(c){return {'&':'&','<':'<','>':'>'}[c];}); } | 222 | function short(h){ return h ? h.slice(0,10)+"..."+h.slice(-6) : "-"; } |
| 193 | function short(h){ return h ? h.slice(0,10)+"…"+h.slice(-6) : "-"; } | 223 | function E(tag, cls, text){ |
| 194 | 224 | var e = document.createElement(tag); | |
| 225 | if(cls) e.className = cls; | ||
| 226 | if(text !== undefined) e.textContent = text; | ||
| 227 | return e; | ||
| 228 | } | ||
| 229 | function code(text){ return E('code', '', text); } | ||
| 230 | function clear(e){ while(e.firstChild) e.removeChild(e.firstChild); } | ||
| 231 | function append(parent){ | ||
| 232 | for(var i=1;i<arguments.length;i++) parent.appendChild(arguments[i]); | ||
| 233 | return parent; | ||
| 234 | } | ||
| 235 | function row(cells, cls){ | ||
| 236 | var tr = E('tr', cls || ''); | ||
| 237 | cells.forEach(function(c){ append(tr, E('td', '', c)); }); | ||
| 238 | return tr; | ||
| 239 | } | ||
| 195 | function renderList(filter){ | 240 | function renderList(filter){ |
| 196 | var q=(filter||"").trim().toLowerCase(), rows=document.getElementById('rows'), out=""; | 241 | var q=(filter||"").trim().toLowerCase(), rows=document.getElementById('rows'); |
| 242 | clear(rows); | ||
| 197 | var list = q ? SUM.filter(function(b){ return String(b.height)===q || b.hash.indexOf(q)===0; }) : SUM.slice(0,SHOWN); | 243 | var list = q ? SUM.filter(function(b){ return String(b.height)===q || b.hash.indexOf(q)===0; }) : SUM.slice(0,SHOWN); |
| 198 | for(var i=0;i<list.length;i++){ var b=list[i]; | 244 | if(!list.length){ |
| 199 | out += '<tr class="blk" data-h="'+b.height+'"><td>'+b.height+'</td><td>'+ts(b.time)+'</td><td>'+b.nTx+'</td><td>'+b.size+' B</td><td>'+btf(b.cbValue)+'</td><td><code>'+(b.cbAddr?esc(b.cbAddr):'-')+'</code></td></tr>'; | 245 | var empty = E('tr'); |
| 246 | var td = E('td', 'mut', 'no match'); | ||
| 247 | td.colSpan = 6; | ||
| 248 | append(empty, td); append(rows, empty); | ||
| 200 | } | 249 | } |
| 201 | rows.innerHTML = out || '<tr><td colspan="6" class="mut">no match</td></tr>'; | 250 | list.forEach(function(b){ |
| 251 | var tr = row([String(b.height), ts(b.time), String(b.nTx), b.size+' B', btf(b.cbValue), ''], 'blk'); | ||
| 252 | tr.dataset.h = b.height; | ||
| 253 | clear(tr.children[5]); | ||
| 254 | append(tr.children[5], code(b.cbAddr || '-')); | ||
| 255 | tr.onclick = function(){ openBlock(tr.dataset.h); }; | ||
| 256 | append(rows, tr); | ||
| 257 | }); | ||
| 202 | document.getElementById('more').textContent = (!q && SUM.length>SHOWN) ? ("showing latest "+SHOWN+" of "+SUM.length+" blocks; search to find any") : ""; | 258 | document.getElementById('more').textContent = (!q && SUM.length>SHOWN) ? ("showing latest "+SHOWN+" of "+SUM.length+" blocks; search to find any") : ""; |
| 203 | Array.prototype.forEach.call(rows.querySelectorAll('tr.blk'), function(tr){ tr.onclick=function(){ openBlock(tr.getAttribute('data-h')); }; }); | ||
| 204 | } | 259 | } |
| 205 | function ioSide(title, items, html){ | 260 | function kv(parent, key, value, isCode){ |
| 206 | var s='<div><div class="mut">'+title+'</div>'; for(var i=0;i<items.length;i++){ s+=html(items[i]); } return s+'</div>'; | 261 | append(parent, E('div', '', key)); |
| 262 | append(parent, isCode ? append(E('div'), code(value)) : E('div', '', value)); | ||
| 263 | } | ||
| 264 | function ioSide(title, items, render){ | ||
| 265 | var side = E('div'); | ||
| 266 | append(side, E('div', 'mut', title)); | ||
| 267 | items.forEach(function(item){ append(side, render(item)); }); | ||
| 268 | return side; | ||
| 207 | } | 269 | } |
| 208 | function openBlock(h){ | 270 | function openBlock(h){ |
| 209 | fetch('block/'+h+'.json').then(function(r){return r.json();}).then(function(b){ | 271 | fetch('block/'+h+'.json').then(function(r){return r.json();}).then(function(b){ |
| 210 | var s='<div class="card"><div class="kv">'+ | 272 | var detail = document.getElementById('detail'); |
| 211 | '<div>Height</div><div>'+b.height+'</div>'+ | 273 | clear(detail); |
| 212 | '<div>Hash</div><div><code>'+esc(b.hash)+'</code></div>'+ | 274 | append(detail, E('h2', '', 'Block '+b.height)); |
| 213 | '<div>Previous</div><div><button class="link" onclick="openBlock('+(b.height-1)+')"><code>'+esc(short(b.prev))+'</code></button></div>'+ | 275 | var card = E('div', 'card'), grid = E('div', 'kv'); |
| 214 | '<div>Merkle root</div><div><code>'+esc(b.merkleRoot)+'</code></div>'+ | 276 | kv(grid, 'Height', String(b.height)); |
| 215 | '<div>Time (UTC)</div><div>'+ts(b.time)+'</div>'+ | 277 | kv(grid, 'Hash', b.hash, true); |
| 216 | '<div>Bits / Nonce</div><div>'+b.bits.toString(16)+' / '+b.nonce+'</div>'+ | 278 | var prev = E('div'); |
| 217 | '<div>Size / Txs</div><div>'+b.size+' B / '+b.txs.length+'</div>'+ | 279 | if(b.height > 0){ |
| 218 | '</div></div>'; | 280 | var btn = E('button', 'link'); |
| 219 | for(var i=0;i<b.txs.length;i++){ var t=b.txs[i]; | 281 | append(btn, code(short(b.prev))); |
| 220 | s+='<div class="tx"><div><code>'+esc(t.txid)+'</code> '+(i===0?'<span class="pill">coinbase</span>':'')+'</div><div class="io">'; | 282 | btn.onclick = function(){ openBlock(b.height - 1); }; |
| 221 | s+=ioSide('Inputs', t.vin, function(v){ return '<div>'+(v.coinbase?'<span class="pill">coinbase</span>':'<code>'+esc(short(v.prev))+':'+v.vout+'</code>')+'</div>'; }); | 283 | append(prev, btn); |
| 222 | s+=ioSide('Outputs', t.vout, function(o){ var to=o.address?esc(o.address):(o.pubkey?'pubkey '+esc(o.pubkey.slice(0,16))+'…':esc(o.type)); return '<div>'+btf(o.value)+' → <code>'+to+'</code></div>'; }); | 284 | } else { |
| 223 | s+='</div></div>'; | 285 | append(prev, code(short(b.prev))); |
| 224 | } | 286 | } |
| 287 | append(grid, E('div', '', 'Previous'), prev); | ||
| 288 | kv(grid, 'Merkle root', b.merkleRoot, true); | ||
| 289 | kv(grid, 'Time (UTC)', ts(b.time)); | ||
| 290 | kv(grid, 'Bits / Nonce', b.bits.toString(16)+' / '+b.nonce); | ||
| 291 | kv(grid, 'Size / Txs', b.size+' B / '+b.txs.length); | ||
| 292 | append(card, grid); append(detail, card); | ||
| 293 | b.txs.forEach(function(t, i){ | ||
| 294 | var tx = E('div', 'tx'); | ||
| 295 | var head = E('div'); | ||
| 296 | append(head, code(t.txid)); | ||
| 297 | if(i === 0) append(head, E('span', 'pill', 'coinbase')); | ||
| 298 | var io = E('div', 'io'); | ||
| 299 | append(io, ioSide('Inputs', t.vin, function(v){ | ||
| 300 | var d = E('div'); | ||
| 301 | if(v.coinbase) append(d, E('span', 'pill', 'coinbase')); | ||
| 302 | else append(d, code(short(v.prev)+':'+v.vout)); | ||
| 303 | return d; | ||
| 304 | })); | ||
| 305 | append(io, ioSide('Outputs', t.vout, function(o){ | ||
| 306 | var d = E('div'); | ||
| 307 | var to = o.address ? o.address : (o.pubkey ? 'pubkey '+o.pubkey.slice(0,16)+'...' : o.type); | ||
| 308 | append(d, document.createTextNode(btf(o.value)+' -> '), code(to)); | ||
| 309 | return d; | ||
| 310 | })); | ||
| 311 | append(tx, head, io); append(detail, tx); | ||
| 312 | }); | ||
| 225 | document.getElementById('listview').style.display='none'; | 313 | document.getElementById('listview').style.display='none'; |
| 226 | var d=document.getElementById('detail'); d.style.display='block'; d.innerHTML='<h2 style="font-size:16px">Block '+b.height+'</h2>'+s; | 314 | detail.style.display='block'; |
| 227 | document.getElementById('home').style.display='inline'; | 315 | document.getElementById('home').style.display='inline'; |
| 228 | window.scrollTo(0,0); | 316 | window.scrollTo(0,0); |
| 229 | }); | 317 | }); |
| 230 | } | 318 | } |
| 231 | document.getElementById('home').onclick=function(){ document.getElementById('detail').style.display='none'; document.getElementById('listview').style.display='block'; this.style.display='none'; }; | 319 | document.getElementById('home').onclick=function(){ |
| 320 | document.getElementById('detail').style.display='none'; | ||
| 321 | document.getElementById('listview').style.display='block'; | ||
| 322 | this.style.display='none'; | ||
| 323 | }; | ||
| 232 | document.getElementById('q').oninput=function(){ renderList(this.value); }; | 324 | document.getElementById('q').oninput=function(){ renderList(this.value); }; |
| 233 | fetch('blocks.json').then(function(r){return r.json();}).then(function(d){ | 325 | fetch('blocks.json').then(function(r){return r.json();}).then(function(d){ |
| 234 | SUM=d.blocks; document.getElementById('tip').textContent='tip height '+d.tipHeight+' · '+d.count+' blocks'; | 326 | SUM=d.blocks; document.getElementById('tip').textContent='tip height '+d.tipHeight+' - '+d.count+' blocks'; |
| 235 | renderList(''); | 327 | renderList(''); |
| 236 | }); | 328 | }); |
| 237 | </script> | 329 | </script> |
| @@ -239,5 +331,6 @@ fetch('blocks.json').then(function(r){return r.json();}).then(function(d){ | |||
| 239 | </html> | 331 | </html> |
| 240 | """ | 332 | """ |
| 241 | 333 | ||
| 334 | |||
| 242 | if __name__ == "__main__": | 335 | if __name__ == "__main__": |
| 243 | main() | 336 | raise SystemExit(main(sys.argv[1:])) |
diff --git a/scripts/prove-utxo.py b/scripts/prove-utxo.py index 7a10d2a..105e677 100644 --- a/scripts/prove-utxo.py +++ b/scripts/prove-utxo.py | |||
| @@ -7,25 +7,13 @@ included in the deterministic UTXO root produced by verify-utxo-set.py. | |||
| 7 | 7 | ||
| 8 | import argparse | 8 | import argparse |
| 9 | import hashlib | 9 | import hashlib |
| 10 | import importlib.util | ||
| 11 | import json | 10 | import json |
| 12 | import os | ||
| 13 | import sys | 11 | import sys |
| 14 | from pathlib import Path | 12 | from pathlib import Path |
| 15 | 13 | ||
| 14 | import bitflash_chain as chain | ||
| 16 | 15 | ||
| 17 | SCHEMA = "bitflash-utxo-inclusion-proof-1" | 16 | SCHEMA = "bitflash-utxo-inclusion-proof-1" |
| 18 | NODE_DOMAIN = b"BTFNODE1|" | ||
| 19 | |||
| 20 | |||
| 21 | def load_utxo_tool(): | ||
| 22 | path = Path(__file__).with_name("verify-utxo-set.py") | ||
| 23 | spec = importlib.util.spec_from_file_location("bitflash_verify_utxo_set", path) | ||
| 24 | if spec is None or spec.loader is None: | ||
| 25 | raise SystemExit("could not load %s" % path) | ||
| 26 | mod = importlib.util.module_from_spec(spec) | ||
| 27 | spec.loader.exec_module(mod) | ||
| 28 | return mod | ||
| 29 | 17 | ||
| 30 | 18 | ||
| 31 | def canonical_bytes(obj): | 19 | def canonical_bytes(obj): |
| @@ -72,7 +60,7 @@ def build_merkle_proof(keys, leaf_hashes, index): | |||
| 72 | for i in range(0, len(layer), 2): | 60 | for i in range(0, len(layer), 2): |
| 73 | left = layer[i] | 61 | left = layer[i] |
| 74 | right = layer[i + 1] if i + 1 < len(layer) else left | 62 | right = layer[i + 1] if i + 1 < len(layer) else left |
| 75 | next_layer.append(hashlib.sha256(NODE_DOMAIN + left + right).digest()) | 63 | next_layer.append(hashlib.sha256(chain.UTXO_NODE_DOMAIN + left + right).digest()) |
| 76 | layer = next_layer | 64 | layer = next_layer |
| 77 | current_index //= 2 | 65 | current_index //= 2 |
| 78 | level += 1 | 66 | level += 1 |
| @@ -80,25 +68,27 @@ def build_merkle_proof(keys, leaf_hashes, index): | |||
| 80 | 68 | ||
| 81 | 69 | ||
| 82 | def build_proof(args): | 70 | def build_proof(args): |
| 83 | tool = load_utxo_tool() | ||
| 84 | txid, vout = split_outpoint(args.outpoint) | 71 | txid, vout = split_outpoint(args.outpoint) |
| 85 | outpoint = "%s:%d" % (txid, vout) | 72 | outpoint = "%s:%d" % (txid, vout) |
| 86 | 73 | ||
| 87 | raw_blocks = tool.read_blocks(args.datadir, args.max_blocks) | 74 | raw_blocks = chain.read_blocks(args.datadir, args.max_blocks) |
| 88 | if not raw_blocks: | 75 | if not raw_blocks: |
| 89 | raise SystemExit("no Bitflash blocks found in %s" % args.datadir) | 76 | raise SystemExit("no Bitflash blocks found in %s" % args.datadir) |
| 90 | blocks = tool.select_main_chain(raw_blocks) | 77 | try: |
| 91 | state = tool.apply_blocks(blocks) | 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)) | ||
| 92 | utxos = state["utxos"] | 82 | utxos = state["utxos"] |
| 93 | stats = state["stats"] | 83 | stats = state["stats"] |
| 94 | if outpoint not in utxos: | 84 | if outpoint not in utxos: |
| 95 | raise SystemExit("outpoint is not unspent in the scanned chain: %s" % outpoint) | 85 | raise SystemExit("outpoint is not unspent in the scanned chain: %s" % outpoint) |
| 96 | 86 | ||
| 97 | keys = sorted(utxos) | 87 | keys = sorted(utxos) |
| 98 | leaf_hashes = [tool.utxo_leaf_hash(k, utxos[k]) for k in keys] | 88 | leaf_hashes = [chain.utxo_leaf_hash(k, utxos[k]) for k in keys] |
| 99 | index = keys.index(outpoint) | 89 | index = keys.index(outpoint) |
| 100 | proof_path, root = build_merkle_proof(keys, leaf_hashes, index) | 90 | proof_path, root = build_merkle_proof(keys, leaf_hashes, index) |
| 101 | direct_root = tool.utxo_root(utxos) | 91 | direct_root = chain.utxo_root(utxos) |
| 102 | if root != direct_root: | 92 | if root != direct_root: |
| 103 | raise SystemExit("internal proof root mismatch") | 93 | raise SystemExit("internal proof root mismatch") |
| 104 | 94 | ||
| @@ -110,13 +100,13 @@ def build_proof(args): | |||
| 110 | "chain": { | 100 | "chain": { |
| 111 | "name": "Bitflash", | 101 | "name": "Bitflash", |
| 112 | "ticker": "BTF", | 102 | "ticker": "BTF", |
| 113 | "message_start_hex": tool.MAGIC.hex(), | 103 | "message_start_hex": chain.MAGIC.hex(), |
| 114 | "coin": tool.COIN, | 104 | "coin": chain.COIN, |
| 115 | }, | 105 | }, |
| 116 | "commitment": { | 106 | "commitment": { |
| 117 | "algorithm": "sorted-outpoint-merkle-sha256", | 107 | "algorithm": "sorted-outpoint-merkle-sha256", |
| 118 | "leaf_domain": "BTFUTXO1", | 108 | "leaf_domain": chain.UTXO_LEAF_DOMAIN, |
| 119 | "node_domain": "BTFNODE1", | 109 | "node_domain": chain.UTXO_NODE_DOMAIN_TAG, |
| 120 | "root": root, | 110 | "root": root, |
| 121 | "leaf_count": len(keys), | 111 | "leaf_count": len(keys), |
| 122 | }, | 112 | }, |
| @@ -169,20 +159,14 @@ def print_text(proof): | |||
| 169 | print("Verdict: %s" % proof["verdict"]) | 159 | print("Verdict: %s" % proof["verdict"]) |
| 170 | 160 | ||
| 171 | 161 | ||
| 172 | def default_datadir(): | ||
| 173 | if os.name == "nt" and os.environ.get("APPDATA"): | ||
| 174 | return os.path.join(os.environ["APPDATA"], "Bitflash") | ||
| 175 | if os.environ.get("HOME"): | ||
| 176 | return os.path.join(os.environ["HOME"], ".bitflash") | ||
| 177 | return "." | ||
| 178 | |||
| 179 | |||
| 180 | def main(argv): | 162 | def main(argv): |
| 181 | ap = argparse.ArgumentParser(description=__doc__) | 163 | ap = argparse.ArgumentParser(description=__doc__) |
| 182 | ap.add_argument("outpoint", help="unspent output to prove, as txid:vout") | 164 | ap.add_argument("outpoint", help="unspent output to prove, as txid:vout") |
| 183 | ap.add_argument("--datadir", default=default_datadir(), help="Bitflash data directory") | 165 | ap.add_argument("--datadir", default=chain.default_datadir(), help="Bitflash data directory") |
| 184 | ap.add_argument("--max-blocks", type=int, default=0, | 166 | ap.add_argument("--max-blocks", type=int, default=0, |
| 185 | help="number of blocks to scan; 0 means all block files") | 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") | ||
| 186 | ap.add_argument("--json", action="store_true", help="print canonical JSON") | 170 | ap.add_argument("--json", action="store_true", help="print canonical JSON") |
| 187 | ap.add_argument("--out", help="write canonical JSON proof to this path") | 171 | ap.add_argument("--out", help="write canonical JSON proof to this path") |
| 188 | args = ap.parse_args(argv) | 172 | args = ap.parse_args(argv) |
diff --git a/scripts/verify-fair-launch.py b/scripts/verify-fair-launch.py index ce396fb..c33c8be 100644 --- a/scripts/verify-fair-launch.py +++ b/scripts/verify-fair-launch.py | |||
| @@ -1,299 +1,12 @@ | |||
| 1 | #!/usr/bin/env python3 | 1 | #!/usr/bin/env python3 |
| 2 | """Produce a deterministic Bitflash fair-launch audit report. | 2 | """Produce a deterministic Bitflash fair-launch audit report.""" |
| 3 | |||
| 4 | This verifier is intentionally self-contained. It parses Bitflash block files | ||
| 5 | directly, without Berkeley DB, without the node binary, and without network | ||
| 6 | access. The canonical JSON output is stable for the same inputs and can be | ||
| 7 | timestamped later with OpenTimestamps or any other external notary. | ||
| 8 | """ | ||
| 9 | 3 | ||
| 10 | import argparse | 4 | import argparse |
| 11 | import datetime as _dt | ||
| 12 | import hashlib | 5 | import hashlib |
| 13 | import json | ||
| 14 | import os | ||
| 15 | import struct | ||
| 16 | import sys | 6 | import sys |
| 17 | from pathlib import Path | 7 | from pathlib import Path |
| 18 | 8 | ||
| 19 | 9 | import bitflash_chain as chain | |
| 20 | MAGIC = bytes([0xBF, 0x20, 0x5C, 0xFD]) | ||
| 21 | COIN = 100_000_000 | ||
| 22 | GENESIS_HASH = "5bd7cb255d814e48cebcdfb72da4dc87b34bd774227f8ceb546c5640f4bdc169" | ||
| 23 | GENESIS_MERKLE = "1a45b4482532abb29b10e234d3f13132230525a339ecea91658ffa675a5b1325" | ||
| 24 | GENESIS_TIME = 1_753_315_200 | ||
| 25 | GENESIS_BITS = 0x1F0FFFFF | ||
| 26 | GENESIS_NONCE = 3141 | ||
| 27 | GENESIS_TEXT = "Bitflash 24/Jul/2026 Fair launch: one CPU one vote, no premine" | ||
| 28 | |||
| 29 | |||
| 30 | class ParseError(Exception): | ||
| 31 | pass | ||
| 32 | |||
| 33 | |||
| 34 | class Reader: | ||
| 35 | def __init__(self, data): | ||
| 36 | self.data = data | ||
| 37 | self.pos = 0 | ||
| 38 | |||
| 39 | def take(self, n): | ||
| 40 | if self.pos + n > len(self.data): | ||
| 41 | raise ParseError("unexpected end of data") | ||
| 42 | out = self.data[self.pos:self.pos + n] | ||
| 43 | self.pos += n | ||
| 44 | return out | ||
| 45 | |||
| 46 | def u8(self): | ||
| 47 | return self.take(1)[0] | ||
| 48 | |||
| 49 | def u32(self): | ||
| 50 | return struct.unpack("<I", self.take(4))[0] | ||
| 51 | |||
| 52 | def i32(self): | ||
| 53 | return struct.unpack("<i", self.take(4))[0] | ||
| 54 | |||
| 55 | def i64(self): | ||
| 56 | return struct.unpack("<q", self.take(8))[0] | ||
| 57 | |||
| 58 | |||
| 59 | def sha256d(data): | ||
| 60 | return hashlib.sha256(hashlib.sha256(data).digest()).digest() | ||
| 61 | |||
| 62 | |||
| 63 | def hash_hex(serialized): | ||
| 64 | return sha256d(serialized)[::-1].hex() | ||
| 65 | |||
| 66 | |||
| 67 | def compact_size(r): | ||
| 68 | first = r.u8() | ||
| 69 | if first < 253: | ||
| 70 | return first | ||
| 71 | if first == 253: | ||
| 72 | return struct.unpack("<H", r.take(2))[0] | ||
| 73 | if first == 254: | ||
| 74 | return r.u32() | ||
| 75 | return struct.unpack("<Q", r.take(8))[0] | ||
| 76 | |||
| 77 | |||
| 78 | def read_varbytes(r): | ||
| 79 | return r.take(compact_size(r)) | ||
| 80 | |||
| 81 | |||
| 82 | def ser_compact_size(n): | ||
| 83 | if n < 253: | ||
| 84 | return bytes([n]) | ||
| 85 | if n <= 0xFFFF: | ||
| 86 | return b"\xfd" + struct.pack("<H", n) | ||
| 87 | if n <= 0xFFFFFFFF: | ||
| 88 | return b"\xfe" + struct.pack("<I", n) | ||
| 89 | return b"\xff" + struct.pack("<Q", n) | ||
| 90 | |||
| 91 | |||
| 92 | def ser_varbytes(b): | ||
| 93 | return ser_compact_size(len(b)) + b | ||
| 94 | |||
| 95 | |||
| 96 | def ser_tx(tx): | ||
| 97 | out = bytearray() | ||
| 98 | out += struct.pack("<i", tx["version"]) | ||
| 99 | out += ser_compact_size(len(tx["vin"])) | ||
| 100 | for txin in tx["vin"]: | ||
| 101 | out += bytes.fromhex(txin["prevout_hash"])[::-1] | ||
| 102 | out += struct.pack("<I", txin["prevout_n"]) | ||
| 103 | out += ser_varbytes(bytes.fromhex(txin["script_sig_hex"])) | ||
| 104 | out += struct.pack("<I", txin["sequence"]) | ||
| 105 | out += ser_compact_size(len(tx["vout"])) | ||
| 106 | for txout in tx["vout"]: | ||
| 107 | out += struct.pack("<q", txout["value_satoshis"]) | ||
| 108 | out += ser_varbytes(bytes.fromhex(txout["script_pub_key_hex"])) | ||
| 109 | out += struct.pack("<I", tx["lock_time"]) | ||
| 110 | return bytes(out) | ||
| 111 | |||
| 112 | |||
| 113 | def parse_tx(r): | ||
| 114 | start = r.pos | ||
| 115 | tx = {"version": r.i32(), "vin": [], "vout": [], "lock_time": 0} | ||
| 116 | for _ in range(compact_size(r)): | ||
| 117 | prev_hash = r.take(32)[::-1].hex() | ||
| 118 | prev_n = r.u32() | ||
| 119 | script = read_varbytes(r).hex() | ||
| 120 | sequence = r.u32() | ||
| 121 | tx["vin"].append({ | ||
| 122 | "prevout_hash": prev_hash, | ||
| 123 | "prevout_n": prev_n, | ||
| 124 | "script_sig_hex": script, | ||
| 125 | "sequence": sequence, | ||
| 126 | }) | ||
| 127 | for _ in range(compact_size(r)): | ||
| 128 | value = r.i64() | ||
| 129 | script = read_varbytes(r).hex() | ||
| 130 | tx["vout"].append({ | ||
| 131 | "value_satoshis": value, | ||
| 132 | "value_btf": format_money(value), | ||
| 133 | "script_pub_key_hex": script, | ||
| 134 | }) | ||
| 135 | tx["lock_time"] = r.u32() | ||
| 136 | raw = r.data[start:r.pos] | ||
| 137 | tx["txid"] = hash_hex(raw) | ||
| 138 | return tx | ||
| 139 | |||
| 140 | |||
| 141 | def merkle_root(txids): | ||
| 142 | if not txids: | ||
| 143 | return "0" * 64 | ||
| 144 | layer = [bytes.fromhex(x)[::-1] for x in txids] | ||
| 145 | while len(layer) > 1: | ||
| 146 | nxt = [] | ||
| 147 | for i in range(0, len(layer), 2): | ||
| 148 | left = layer[i] | ||
| 149 | right = layer[i + 1] if i + 1 < len(layer) else left | ||
| 150 | nxt.append(sha256d(left + right)) | ||
| 151 | layer = nxt | ||
| 152 | return layer[0][::-1].hex() | ||
| 153 | |||
| 154 | |||
| 155 | def parse_block(payload, file_name="", offset=0): | ||
| 156 | r = Reader(payload) | ||
| 157 | header = r.take(80) | ||
| 158 | version, = struct.unpack("<i", header[0:4]) | ||
| 159 | prev = header[4:36][::-1].hex() | ||
| 160 | merkle = header[36:68][::-1].hex() | ||
| 161 | ntime, bits, nonce = struct.unpack("<III", header[68:80]) | ||
| 162 | txs = [parse_tx(r) for _ in range(compact_size(r))] | ||
| 163 | if r.pos != len(payload): | ||
| 164 | raise ParseError("trailing bytes inside block") | ||
| 165 | txids = [tx["txid"] for tx in txs] | ||
| 166 | return { | ||
| 167 | "hash": hash_hex(header), | ||
| 168 | "version": version, | ||
| 169 | "previous_hash": prev, | ||
| 170 | "merkle_root": merkle, | ||
| 171 | "computed_merkle_root": merkle_root(txids), | ||
| 172 | "time": ntime, | ||
| 173 | "time_utc": utc(ntime), | ||
| 174 | "bits": "0x%08x" % bits, | ||
| 175 | "nonce": nonce, | ||
| 176 | "tx_count": len(txs), | ||
| 177 | "transactions": txs, | ||
| 178 | "source_file": file_name, | ||
| 179 | "source_offset": offset, | ||
| 180 | } | ||
| 181 | |||
| 182 | |||
| 183 | def iter_block_files(datadir): | ||
| 184 | for path in sorted(Path(datadir).glob("blk*.dat")): | ||
| 185 | yield path | ||
| 186 | |||
| 187 | |||
| 188 | def read_blocks(datadir, limit): | ||
| 189 | blocks = [] | ||
| 190 | for path in iter_block_files(datadir): | ||
| 191 | data = path.read_bytes() | ||
| 192 | pos = 0 | ||
| 193 | while True: | ||
| 194 | idx = data.find(MAGIC, pos) | ||
| 195 | if idx < 0: | ||
| 196 | break | ||
| 197 | if idx + 8 > len(data): | ||
| 198 | break | ||
| 199 | size = struct.unpack("<I", data[idx + 4:idx + 8])[0] | ||
| 200 | start = idx + 8 | ||
| 201 | end = start + size | ||
| 202 | if end > len(data): | ||
| 203 | break | ||
| 204 | blocks.append(parse_block(data[start:end], path.name, start)) | ||
| 205 | if limit and len(blocks) >= limit: | ||
| 206 | return blocks | ||
| 207 | pos = end | ||
| 208 | return blocks | ||
| 209 | |||
| 210 | |||
| 211 | def genesis_transaction(): | ||
| 212 | script_sig = ( | ||
| 213 | bytes.fromhex("04ffff001d") | ||
| 214 | + b"\x01\x04" | ||
| 215 | + bytes([len(GENESIS_TEXT)]) | ||
| 216 | + GENESIS_TEXT.encode("ascii") | ||
| 217 | ) | ||
| 218 | pubkey_num = bytes.fromhex( | ||
| 219 | "5f1df16b2b704c8a578d0bbaf74d385cde12c11ee50455f3c438ef4c3fbcf649" | ||
| 220 | "b6de611feae06279a60939e028a8d65c10b73071a6f16719274855feb0fd8a6704" | ||
| 221 | ) | ||
| 222 | # The genesis source uses CScript << CBigNum("0x..."), and CBigNum::getvch() | ||
| 223 | # returns the minimal little-endian script number representation. | ||
| 224 | script_pub_key = bytes([65]) + pubkey_num[::-1] + b"\xac" | ||
| 225 | tx = { | ||
| 226 | "version": 1, | ||
| 227 | "vin": [{ | ||
| 228 | "prevout_hash": "0" * 64, | ||
| 229 | "prevout_n": 0xFFFFFFFF, | ||
| 230 | "script_sig_hex": script_sig.hex(), | ||
| 231 | "sequence": 0xFFFFFFFF, | ||
| 232 | }], | ||
| 233 | "vout": [{ | ||
| 234 | "value_satoshis": 50 * COIN, | ||
| 235 | "value_btf": format_money(50 * COIN), | ||
| 236 | "script_pub_key_hex": script_pub_key.hex(), | ||
| 237 | }], | ||
| 238 | "lock_time": 0, | ||
| 239 | } | ||
| 240 | tx["txid"] = hash_hex(ser_tx(tx)) | ||
| 241 | return tx | ||
| 242 | |||
| 243 | |||
| 244 | def reconstructed_genesis(): | ||
| 245 | tx = genesis_transaction() | ||
| 246 | header = ( | ||
| 247 | struct.pack("<i", 1) | ||
| 248 | + bytes(32) | ||
| 249 | + bytes.fromhex(tx["txid"])[::-1] | ||
| 250 | + struct.pack("<III", GENESIS_TIME, GENESIS_BITS, GENESIS_NONCE) | ||
| 251 | ) | ||
| 252 | return { | ||
| 253 | "hash": hash_hex(header), | ||
| 254 | "version": 1, | ||
| 255 | "previous_hash": "0" * 64, | ||
| 256 | "merkle_root": tx["txid"], | ||
| 257 | "computed_merkle_root": tx["txid"], | ||
| 258 | "time": GENESIS_TIME, | ||
| 259 | "time_utc": utc(GENESIS_TIME), | ||
| 260 | "bits": "0x%08x" % GENESIS_BITS, | ||
| 261 | "nonce": GENESIS_NONCE, | ||
| 262 | "tx_count": 1, | ||
| 263 | "transactions": [tx], | ||
| 264 | "source_file": "reconstructed-from-source-constants", | ||
| 265 | "source_offset": 0, | ||
| 266 | } | ||
| 267 | |||
| 268 | |||
| 269 | def format_money(value): | ||
| 270 | sign = "-" if value < 0 else "" | ||
| 271 | value = abs(value) | ||
| 272 | return "%s%d.%08d" % (sign, value // COIN, value % COIN) | ||
| 273 | |||
| 274 | |||
| 275 | def utc(ts): | ||
| 276 | return _dt.datetime.fromtimestamp(ts, _dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") | ||
| 277 | |||
| 278 | |||
| 279 | def default_datadir(): | ||
| 280 | if os.name == "nt" and os.environ.get("APPDATA"): | ||
| 281 | return os.path.join(os.environ["APPDATA"], "Bitflash") | ||
| 282 | if os.environ.get("HOME"): | ||
| 283 | return os.path.join(os.environ["HOME"], ".bitflash") | ||
| 284 | return "." | ||
| 285 | |||
| 286 | |||
| 287 | def is_coinbase(tx): | ||
| 288 | return ( | ||
| 289 | len(tx["vin"]) == 1 | ||
| 290 | and tx["vin"][0]["prevout_hash"] == "0" * 64 | ||
| 291 | and tx["vin"][0]["prevout_n"] == 0xFFFFFFFF | ||
| 292 | ) | ||
| 293 | |||
| 294 | |||
| 295 | def canonical_bytes(report): | ||
| 296 | return (json.dumps(report, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + "\n").encode("ascii") | ||
| 297 | 10 | ||
| 298 | 11 | ||
| 299 | def build_report(blocks, source_mode): | 12 | def build_report(blocks, source_mode): |
| @@ -307,7 +20,7 @@ def build_report(blocks, source_mode): | |||
| 307 | if not block["transactions"]: | 20 | if not block["transactions"]: |
| 308 | continue | 21 | continue |
| 309 | cb = block["transactions"][0] | 22 | cb = block["transactions"][0] |
| 310 | if is_coinbase(cb): | 23 | if chain.is_coinbase(cb): |
| 311 | amount = sum(out["value_satoshis"] for out in cb["vout"]) | 24 | amount = sum(out["value_satoshis"] for out in cb["vout"]) |
| 312 | total_mined += amount | 25 | total_mined += amount |
| 313 | coinbase_outputs.append({ | 26 | coinbase_outputs.append({ |
| @@ -315,18 +28,18 @@ def build_report(blocks, source_mode): | |||
| 315 | "block_hash": block["hash"], | 28 | "block_hash": block["hash"], |
| 316 | "coinbase_txid": cb["txid"], | 29 | "coinbase_txid": cb["txid"], |
| 317 | "value_satoshis": amount, | 30 | "value_satoshis": amount, |
| 318 | "value_btf": format_money(amount), | 31 | "value_btf": chain.format_money(amount), |
| 319 | "outputs": cb["vout"], | 32 | "outputs": cb["vout"], |
| 320 | }) | 33 | }) |
| 321 | non_coinbase_txs += sum(1 for tx in block["transactions"] if not is_coinbase(tx)) | 34 | non_coinbase_txs += sum(1 for tx in block["transactions"] if not chain.is_coinbase(tx)) |
| 322 | 35 | ||
| 323 | checks = { | 36 | checks = { |
| 324 | "genesis_hash_matches_source": genesis["hash"] == GENESIS_HASH, | 37 | "genesis_hash_matches_source": genesis["hash"] == chain.GENESIS_HASH, |
| 325 | "genesis_merkle_matches_source": genesis["merkle_root"] == GENESIS_MERKLE, | 38 | "genesis_merkle_matches_source": genesis["merkle_root"] == chain.GENESIS_MERKLE, |
| 326 | "computed_merkle_matches_header": genesis["computed_merkle_root"] == genesis["merkle_root"], | 39 | "computed_merkle_matches_header": genesis["computed_merkle_root"] == genesis["merkle_root"], |
| 327 | "genesis_has_single_transaction": genesis["tx_count"] == 1, | 40 | "genesis_has_single_transaction": genesis["tx_count"] == 1, |
| 328 | "genesis_transaction_is_coinbase": bool(genesis["transactions"] and is_coinbase(genesis["transactions"][0])), | 41 | "genesis_transaction_is_coinbase": bool(genesis["transactions"] and chain.is_coinbase(genesis["transactions"][0])), |
| 329 | "genesis_supply_is_50_btf": sum(out["value_satoshis"] for out in launch_outputs) == 50 * COIN, | 42 | "genesis_supply_is_50_btf": sum(out["value_satoshis"] for out in launch_outputs) == 50 * chain.COIN, |
| 330 | } | 43 | } |
| 331 | 44 | ||
| 332 | report = { | 45 | report = { |
| @@ -336,17 +49,17 @@ def build_report(blocks, source_mode): | |||
| 336 | "chain": { | 49 | "chain": { |
| 337 | "name": "Bitflash", | 50 | "name": "Bitflash", |
| 338 | "ticker": "BTF", | 51 | "ticker": "BTF", |
| 339 | "message_start_hex": MAGIC.hex(), | 52 | "message_start_hex": chain.MAGIC.hex(), |
| 340 | "coin": COIN, | 53 | "coin": chain.COIN, |
| 341 | }, | 54 | }, |
| 342 | "source_constants": { | 55 | "source_constants": { |
| 343 | "genesis_hash": GENESIS_HASH, | 56 | "genesis_hash": chain.GENESIS_HASH, |
| 344 | "genesis_merkle_root": GENESIS_MERKLE, | 57 | "genesis_merkle_root": chain.GENESIS_MERKLE, |
| 345 | "genesis_time": GENESIS_TIME, | 58 | "genesis_time": chain.GENESIS_TIME, |
| 346 | "genesis_time_utc": utc(GENESIS_TIME), | 59 | "genesis_time_utc": chain.utc(chain.GENESIS_TIME), |
| 347 | "genesis_bits": "0x%08x" % GENESIS_BITS, | 60 | "genesis_bits": "0x%08x" % chain.GENESIS_BITS, |
| 348 | "genesis_nonce": GENESIS_NONCE, | 61 | "genesis_nonce": chain.GENESIS_NONCE, |
| 349 | "genesis_message": GENESIS_TEXT, | 62 | "genesis_message": chain.GENESIS_TEXT, |
| 350 | }, | 63 | }, |
| 351 | "scanned": { | 64 | "scanned": { |
| 352 | "block_count": len(blocks), | 65 | "block_count": len(blocks), |
| @@ -354,7 +67,7 @@ def build_report(blocks, source_mode): | |||
| 354 | "last_height": len(blocks) - 1, | 67 | "last_height": len(blocks) - 1, |
| 355 | "non_coinbase_transactions": non_coinbase_txs, | 68 | "non_coinbase_transactions": non_coinbase_txs, |
| 356 | "total_coinbase_satoshis": total_mined, | 69 | "total_coinbase_satoshis": total_mined, |
| 357 | "total_coinbase_btf": format_money(total_mined), | 70 | "total_coinbase_btf": chain.format_money(total_mined), |
| 358 | }, | 71 | }, |
| 359 | "genesis": { | 72 | "genesis": { |
| 360 | "hash": genesis["hash"], | 73 | "hash": genesis["hash"], |
| @@ -381,7 +94,7 @@ def build_report(blocks, source_mode): | |||
| 381 | ], | 94 | ], |
| 382 | }, | 95 | }, |
| 383 | } | 96 | } |
| 384 | report["attestation_payload_sha256"] = hashlib.sha256(canonical_bytes(report)).hexdigest() | 97 | report["attestation_payload_sha256"] = hashlib.sha256(chain.canonical_bytes(report)).hexdigest() |
| 385 | return report | 98 | return report |
| 386 | 99 | ||
| 387 | 100 | ||
| @@ -397,7 +110,7 @@ def print_text(report): | |||
| 397 | print(" merkle: %s" % report["genesis"]["merkle_root"]) | 110 | print(" merkle: %s" % report["genesis"]["merkle_root"]) |
| 398 | print(" time: %s" % report["genesis"]["time_utc"]) | 111 | print(" time: %s" % report["genesis"]["time_utc"]) |
| 399 | print(" txs: %d" % report["genesis"]["tx_count"]) | 112 | print(" txs: %d" % report["genesis"]["tx_count"]) |
| 400 | print(" supply: %s BTF" % format_money(sum(o["value_satoshis"] for o in report["genesis"]["outputs"]))) | 113 | print(" supply: %s BTF" % chain.format_money(sum(o["value_satoshis"] for o in report["genesis"]["outputs"]))) |
| 401 | print() | 114 | print() |
| 402 | print("Scanned") | 115 | print("Scanned") |
| 403 | print(" blocks: %d" % report["scanned"]["block_count"]) | 116 | print(" blocks: %d" % report["scanned"]["block_count"]) |
| @@ -417,7 +130,7 @@ def print_text(report): | |||
| 417 | 130 | ||
| 418 | def main(argv): | 131 | def main(argv): |
| 419 | ap = argparse.ArgumentParser(description=__doc__) | 132 | ap = argparse.ArgumentParser(description=__doc__) |
| 420 | ap.add_argument("--datadir", default=default_datadir(), help="Bitflash data directory") | 133 | ap.add_argument("--datadir", default=chain.default_datadir(), help="Bitflash data directory") |
| 421 | ap.add_argument("--max-blocks", type=int, default=100, help="number of blocks to scan from blk*.dat") | 134 | ap.add_argument("--max-blocks", type=int, default=100, help="number of blocks to scan from blk*.dat") |
| 422 | ap.add_argument("--json", action="store_true", help="print canonical JSON") | 135 | ap.add_argument("--json", action="store_true", help="print canonical JSON") |
| 423 | ap.add_argument("--out", help="write canonical JSON to this path") | 136 | ap.add_argument("--out", help="write canonical JSON to this path") |
| @@ -426,18 +139,19 @@ def main(argv): | |||
| 426 | args = ap.parse_args(argv) | 139 | args = ap.parse_args(argv) |
| 427 | 140 | ||
| 428 | if args.reconstruct_genesis: | 141 | if args.reconstruct_genesis: |
| 429 | blocks = [reconstructed_genesis()] | 142 | blocks = [chain.reconstructed_genesis()] |
| 430 | source_mode = "reconstructed-genesis" | 143 | source_mode = "reconstructed-genesis" |
| 431 | datadir = "" | ||
| 432 | else: | 144 | else: |
| 433 | datadir = Path(args.datadir) | 145 | blocks = chain.read_blocks(args.datadir, args.max_blocks) |
| 434 | blocks = read_blocks(datadir, args.max_blocks) | ||
| 435 | if not blocks: | 146 | if not blocks: |
| 436 | raise SystemExit("no Bitflash blocks found in %s; use --reconstruct-genesis for a source-only check" % datadir) | 147 | raise SystemExit("no Bitflash blocks found in %s; use --reconstruct-genesis for a source-only check" % args.datadir) |
| 437 | source_mode = "local-block-files" | 148 | source_mode = "local-block-files" |
| 438 | 149 | ||
| 439 | report = build_report(blocks, source_mode) | 150 | try: |
| 440 | data = canonical_bytes(report) | 151 | report = build_report(blocks, source_mode) |
| 152 | except chain.ParseError as e: | ||
| 153 | raise SystemExit(str(e)) | ||
| 154 | data = chain.canonical_bytes(report) | ||
| 441 | 155 | ||
| 442 | if args.out: | 156 | if args.out: |
| 443 | Path(args.out).write_bytes(data) | 157 | Path(args.out).write_bytes(data) |
diff --git a/scripts/verify-utxo-proof.py b/scripts/verify-utxo-proof.py index 31099f7..50d9356 100644 --- a/scripts/verify-utxo-proof.py +++ b/scripts/verify-utxo-proof.py | |||
| @@ -7,32 +7,15 @@ import json | |||
| 7 | import sys | 7 | import sys |
| 8 | from pathlib import Path | 8 | from pathlib import Path |
| 9 | 9 | ||
| 10 | import bitflash_chain as chain | ||
| 10 | 11 | ||
| 11 | SCHEMA = "bitflash-utxo-inclusion-proof-1" | 12 | SCHEMA = "bitflash-utxo-inclusion-proof-1" |
| 12 | LEAF_DOMAIN = "BTFUTXO1" | ||
| 13 | NODE_DOMAIN = b"BTFNODE1|" | ||
| 14 | 13 | ||
| 15 | 14 | ||
| 16 | def canonical_bytes(obj): | 15 | def canonical_bytes(obj): |
| 17 | return (json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + "\n").encode("ascii") | 16 | return (json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + "\n").encode("ascii") |
| 18 | 17 | ||
| 19 | 18 | ||
| 20 | def sha256(data): | ||
| 21 | return hashlib.sha256(data).digest() | ||
| 22 | |||
| 23 | |||
| 24 | def utxo_leaf_hash(outpoint, utxo): | ||
| 25 | payload = "|".join([ | ||
| 26 | LEAF_DOMAIN, | ||
| 27 | outpoint, | ||
| 28 | str(utxo["value_satoshis"]), | ||
| 29 | utxo["script_pub_key_hex"], | ||
| 30 | str(utxo["created_height"]), | ||
| 31 | utxo["created_txid"], | ||
| 32 | ]).encode("ascii") | ||
| 33 | return sha256(payload) | ||
| 34 | |||
| 35 | |||
| 36 | def load_proof(path): | 19 | def load_proof(path): |
| 37 | try: | 20 | try: |
| 38 | return json.loads(Path(path).read_text(encoding="ascii")) | 21 | return json.loads(Path(path).read_text(encoding="ascii")) |
| @@ -47,9 +30,9 @@ def verify(proof, expected_root=None): | |||
| 47 | commitment = proof.get("commitment", {}) | 30 | commitment = proof.get("commitment", {}) |
| 48 | if commitment.get("algorithm") != "sorted-outpoint-merkle-sha256": | 31 | if commitment.get("algorithm") != "sorted-outpoint-merkle-sha256": |
| 49 | failures.append("unexpected commitment algorithm") | 32 | failures.append("unexpected commitment algorithm") |
| 50 | if commitment.get("leaf_domain") != LEAF_DOMAIN: | 33 | if commitment.get("leaf_domain") != chain.UTXO_LEAF_DOMAIN: |
| 51 | failures.append("unexpected leaf domain") | 34 | failures.append("unexpected leaf domain") |
| 52 | if commitment.get("node_domain") != "BTFNODE1": | 35 | if commitment.get("node_domain") != chain.UTXO_NODE_DOMAIN_TAG: |
| 53 | failures.append("unexpected node domain") | 36 | failures.append("unexpected node domain") |
| 54 | 37 | ||
| 55 | target = proof.get("target", {}) | 38 | target = proof.get("target", {}) |
| @@ -59,7 +42,7 @@ def verify(proof, expected_root=None): | |||
| 59 | failures.append("target outpoint does not match txid:vout") | 42 | failures.append("target outpoint does not match txid:vout") |
| 60 | 43 | ||
| 61 | try: | 44 | try: |
| 62 | current = utxo_leaf_hash(outpoint, utxo) | 45 | current = chain.utxo_leaf_hash(outpoint, utxo) |
| 63 | leaf_ok = target.get("leaf_hash") == current.hex() | 46 | leaf_ok = target.get("leaf_hash") == current.hex() |
| 64 | except Exception as e: | 47 | except Exception as e: |
| 65 | failures.append("could not hash target leaf: %s" % e) | 48 | failures.append("could not hash target leaf: %s" % e) |
| @@ -79,9 +62,9 @@ def verify(proof, expected_root=None): | |||
| 79 | failures.append("bad sibling hash length at proof level %d" % i) | 62 | failures.append("bad sibling hash length at proof level %d" % i) |
| 80 | side = step.get("side") | 63 | side = step.get("side") |
| 81 | if side == "right": | 64 | if side == "right": |
| 82 | current = sha256(NODE_DOMAIN + current + sibling) | 65 | current = chain.sha256(chain.UTXO_NODE_DOMAIN + current + sibling) |
| 83 | elif side == "left": | 66 | elif side == "left": |
| 84 | current = sha256(NODE_DOMAIN + sibling + current) | 67 | current = chain.sha256(chain.UTXO_NODE_DOMAIN + sibling + current) |
| 85 | else: | 68 | else: |
| 86 | failures.append("bad sibling side at proof level %d" % i) | 69 | failures.append("bad sibling side at proof level %d" % i) |
| 87 | 70 | ||
diff --git a/scripts/verify-utxo-set.py b/scripts/verify-utxo-set.py index 12b024f..b5bf3d5 100644 --- a/scripts/verify-utxo-set.py +++ b/scripts/verify-utxo-set.py | |||
| @@ -1,363 +1,24 @@ | |||
| 1 | #!/usr/bin/env python3 | 1 | #!/usr/bin/env python3 |
| 2 | """Build a deterministic Bitflash UTXO-set commitment from local blk*.dat. | 2 | """Build a deterministic Bitflash UTXO-set commitment from local blk*.dat.""" |
| 3 | |||
| 4 | This is deliberately outside consensus. It lets auditors reconstruct the chain | ||
| 5 | state from disk blocks and compare the same canonical UTXO root independently. | ||
| 6 | """ | ||
| 7 | 3 | ||
| 8 | import argparse | 4 | import argparse |
| 9 | import datetime as _dt | ||
| 10 | import hashlib | 5 | import hashlib |
| 11 | import json | ||
| 12 | import os | ||
| 13 | import struct | ||
| 14 | import sys | 6 | import sys |
| 15 | from pathlib import Path | 7 | from pathlib import Path |
| 16 | 8 | ||
| 17 | 9 | import bitflash_chain as chain | |
| 18 | MAGIC = bytes([0xBF, 0x20, 0x5C, 0xFD]) | ||
| 19 | COIN = 100_000_000 | ||
| 20 | GENESIS_HASH = "5bd7cb255d814e48cebcdfb72da4dc87b34bd774227f8ceb546c5640f4bdc169" | ||
| 21 | GENESIS_MERKLE = "1a45b4482532abb29b10e234d3f13132230525a339ecea91658ffa675a5b1325" | ||
| 22 | |||
| 23 | |||
| 24 | class ParseError(Exception): | ||
| 25 | pass | ||
| 26 | |||
| 27 | |||
| 28 | class Reader: | ||
| 29 | def __init__(self, data): | ||
| 30 | self.data = data | ||
| 31 | self.pos = 0 | ||
| 32 | |||
| 33 | def take(self, n): | ||
| 34 | if self.pos + n > len(self.data): | ||
| 35 | raise ParseError("unexpected end of data") | ||
| 36 | out = self.data[self.pos:self.pos + n] | ||
| 37 | self.pos += n | ||
| 38 | return out | ||
| 39 | |||
| 40 | def u8(self): | ||
| 41 | return self.take(1)[0] | ||
| 42 | |||
| 43 | def u32(self): | ||
| 44 | return struct.unpack("<I", self.take(4))[0] | ||
| 45 | |||
| 46 | def i32(self): | ||
| 47 | return struct.unpack("<i", self.take(4))[0] | ||
| 48 | |||
| 49 | def i64(self): | ||
| 50 | return struct.unpack("<q", self.take(8))[0] | ||
| 51 | |||
| 52 | |||
| 53 | def sha256(data): | ||
| 54 | return hashlib.sha256(data).digest() | ||
| 55 | |||
| 56 | |||
| 57 | def sha256d(data): | ||
| 58 | return hashlib.sha256(hashlib.sha256(data).digest()).digest() | ||
| 59 | |||
| 60 | |||
| 61 | def hash_hex(serialized): | ||
| 62 | return sha256d(serialized)[::-1].hex() | ||
| 63 | |||
| 64 | |||
| 65 | def compact_size(r): | ||
| 66 | first = r.u8() | ||
| 67 | if first < 253: | ||
| 68 | return first | ||
| 69 | if first == 253: | ||
| 70 | return struct.unpack("<H", r.take(2))[0] | ||
| 71 | if first == 254: | ||
| 72 | return r.u32() | ||
| 73 | return struct.unpack("<Q", r.take(8))[0] | ||
| 74 | |||
| 75 | |||
| 76 | def read_varbytes(r): | ||
| 77 | return r.take(compact_size(r)) | ||
| 78 | |||
| 79 | |||
| 80 | def ser_compact_size(n): | ||
| 81 | if n < 253: | ||
| 82 | return bytes([n]) | ||
| 83 | if n <= 0xFFFF: | ||
| 84 | return b"\xfd" + struct.pack("<H", n) | ||
| 85 | if n <= 0xFFFFFFFF: | ||
| 86 | return b"\xfe" + struct.pack("<I", n) | ||
| 87 | return b"\xff" + struct.pack("<Q", n) | ||
| 88 | |||
| 89 | |||
| 90 | def ser_varbytes(b): | ||
| 91 | return ser_compact_size(len(b)) + b | ||
| 92 | |||
| 93 | |||
| 94 | def ser_tx(tx): | ||
| 95 | out = bytearray() | ||
| 96 | out += struct.pack("<i", tx["version"]) | ||
| 97 | out += ser_compact_size(len(tx["vin"])) | ||
| 98 | for txin in tx["vin"]: | ||
| 99 | out += bytes.fromhex(txin["prevout_hash"])[::-1] | ||
| 100 | out += struct.pack("<I", txin["prevout_n"]) | ||
| 101 | out += ser_varbytes(bytes.fromhex(txin["script_sig_hex"])) | ||
| 102 | out += struct.pack("<I", txin["sequence"]) | ||
| 103 | out += ser_compact_size(len(tx["vout"])) | ||
| 104 | for txout in tx["vout"]: | ||
| 105 | out += struct.pack("<q", txout["value_satoshis"]) | ||
| 106 | out += ser_varbytes(bytes.fromhex(txout["script_pub_key_hex"])) | ||
| 107 | out += struct.pack("<I", tx["lock_time"]) | ||
| 108 | return bytes(out) | ||
| 109 | |||
| 110 | |||
| 111 | def parse_tx(r): | ||
| 112 | tx = {"version": r.i32(), "vin": [], "vout": [], "lock_time": 0} | ||
| 113 | for _ in range(compact_size(r)): | ||
| 114 | tx["vin"].append({ | ||
| 115 | "prevout_hash": r.take(32)[::-1].hex(), | ||
| 116 | "prevout_n": r.u32(), | ||
| 117 | "script_sig_hex": read_varbytes(r).hex(), | ||
| 118 | "sequence": r.u32(), | ||
| 119 | }) | ||
| 120 | for _ in range(compact_size(r)): | ||
| 121 | value = r.i64() | ||
| 122 | tx["vout"].append({ | ||
| 123 | "value_satoshis": value, | ||
| 124 | "value_btf": format_money(value), | ||
| 125 | "script_pub_key_hex": read_varbytes(r).hex(), | ||
| 126 | }) | ||
| 127 | tx["lock_time"] = r.u32() | ||
| 128 | tx["txid"] = hash_hex(ser_tx(tx)) | ||
| 129 | return tx | ||
| 130 | |||
| 131 | |||
| 132 | def merkle_root(txids): | ||
| 133 | if not txids: | ||
| 134 | return "0" * 64 | ||
| 135 | layer = [bytes.fromhex(x)[::-1] for x in txids] | ||
| 136 | while len(layer) > 1: | ||
| 137 | nxt = [] | ||
| 138 | for i in range(0, len(layer), 2): | ||
| 139 | left = layer[i] | ||
| 140 | right = layer[i + 1] if i + 1 < len(layer) else left | ||
| 141 | nxt.append(sha256d(left + right)) | ||
| 142 | layer = nxt | ||
| 143 | return layer[0][::-1].hex() | ||
| 144 | |||
| 145 | |||
| 146 | def parse_block(payload, file_name="", offset=0): | ||
| 147 | r = Reader(payload) | ||
| 148 | header = r.take(80) | ||
| 149 | version, = struct.unpack("<i", header[0:4]) | ||
| 150 | prev = header[4:36][::-1].hex() | ||
| 151 | merkle = header[36:68][::-1].hex() | ||
| 152 | ntime, bits, nonce = struct.unpack("<III", header[68:80]) | ||
| 153 | txs = [parse_tx(r) for _ in range(compact_size(r))] | ||
| 154 | if r.pos != len(payload): | ||
| 155 | raise ParseError("trailing bytes inside block") | ||
| 156 | txids = [tx["txid"] for tx in txs] | ||
| 157 | return { | ||
| 158 | "hash": hash_hex(header), | ||
| 159 | "version": version, | ||
| 160 | "previous_hash": prev, | ||
| 161 | "merkle_root": merkle, | ||
| 162 | "computed_merkle_root": merkle_root(txids), | ||
| 163 | "time": ntime, | ||
| 164 | "time_utc": utc(ntime), | ||
| 165 | "bits": "0x%08x" % bits, | ||
| 166 | "nonce": nonce, | ||
| 167 | "tx_count": len(txs), | ||
| 168 | "transactions": txs, | ||
| 169 | "source_file": file_name, | ||
| 170 | "source_offset": offset, | ||
| 171 | } | ||
| 172 | |||
| 173 | |||
| 174 | def read_blocks(datadir, max_blocks): | ||
| 175 | blocks = [] | ||
| 176 | for path in sorted(Path(datadir).glob("blk*.dat")): | ||
| 177 | data = path.read_bytes() | ||
| 178 | pos = 0 | ||
| 179 | while True: | ||
| 180 | idx = data.find(MAGIC, pos) | ||
| 181 | if idx < 0: | ||
| 182 | break | ||
| 183 | if idx + 8 > len(data): | ||
| 184 | break | ||
| 185 | size = struct.unpack("<I", data[idx + 4:idx + 8])[0] | ||
| 186 | start = idx + 8 | ||
| 187 | end = start + size | ||
| 188 | if end > len(data): | ||
| 189 | break | ||
| 190 | blocks.append(parse_block(data[start:end], path.name, start)) | ||
| 191 | if max_blocks and len(blocks) >= max_blocks: | ||
| 192 | return blocks | ||
| 193 | pos = end | ||
| 194 | return blocks | ||
| 195 | |||
| 196 | |||
| 197 | def select_main_chain(blocks): | ||
| 198 | by_hash = {} | ||
| 199 | for block in blocks: | ||
| 200 | by_hash.setdefault(block["hash"], block) | ||
| 201 | if GENESIS_HASH not in by_hash: | ||
| 202 | raise ParseError("genesis block not found in block files") | ||
| 203 | |||
| 204 | children = {} | ||
| 205 | for block in by_hash.values(): | ||
| 206 | children.setdefault(block["previous_hash"], []).append(block) | ||
| 207 | for v in children.values(): | ||
| 208 | v.sort(key=lambda b: (b["hash"], b["source_file"], b["source_offset"])) | ||
| 209 | |||
| 210 | best = [] | ||
| 211 | stack = [(by_hash[GENESIS_HASH], [by_hash[GENESIS_HASH]])] | ||
| 212 | while stack: | ||
| 213 | block, path = stack.pop() | ||
| 214 | if len(path) > len(best) or (len(path) == len(best) and block["hash"] < best[-1]["hash"]): | ||
| 215 | best = path | ||
| 216 | for child in children.get(block["hash"], []): | ||
| 217 | stack.append((child, path + [child])) | ||
| 218 | return best | ||
| 219 | |||
| 220 | |||
| 221 | def is_coinbase(tx): | ||
| 222 | return ( | ||
| 223 | len(tx["vin"]) == 1 | ||
| 224 | and tx["vin"][0]["prevout_hash"] == "0" * 64 | ||
| 225 | and tx["vin"][0]["prevout_n"] == 0xFFFFFFFF | ||
| 226 | ) | ||
| 227 | |||
| 228 | |||
| 229 | def utxo_leaf_hash(outpoint, utxo): | ||
| 230 | # Text fields are length-delimited by separators and all variable data is | ||
| 231 | # hex or decimal ASCII. That keeps the commitment language-neutral. | ||
| 232 | payload = "|".join([ | ||
| 233 | "BTFUTXO1", | ||
| 234 | outpoint, | ||
| 235 | str(utxo["value_satoshis"]), | ||
| 236 | utxo["script_pub_key_hex"], | ||
| 237 | str(utxo["created_height"]), | ||
| 238 | utxo["created_txid"], | ||
| 239 | ]).encode("ascii") | ||
| 240 | return sha256(payload) | ||
| 241 | |||
| 242 | |||
| 243 | def utxo_root(utxos): | ||
| 244 | leaves = [utxo_leaf_hash(k, utxos[k]) for k in sorted(utxos)] | ||
| 245 | if not leaves: | ||
| 246 | return sha256(b"BTFUTXO1|empty").hex() | ||
| 247 | layer = leaves | ||
| 248 | while len(layer) > 1: | ||
| 249 | nxt = [] | ||
| 250 | for i in range(0, len(layer), 2): | ||
| 251 | left = layer[i] | ||
| 252 | right = layer[i + 1] if i + 1 < len(layer) else left | ||
| 253 | nxt.append(sha256(b"BTFNODE1|" + left + right)) | ||
| 254 | layer = nxt | ||
| 255 | return layer[0].hex() | ||
| 256 | |||
| 257 | |||
| 258 | def apply_blocks(blocks): | ||
| 259 | utxos = {} | ||
| 260 | spent = 0 | ||
| 261 | missing_spends = [] | ||
| 262 | duplicate_outputs = [] | ||
| 263 | total_coinbase = 0 | ||
| 264 | total_fees = 0 | ||
| 265 | normal_txs = 0 | ||
| 266 | |||
| 267 | for height, block in enumerate(blocks): | ||
| 268 | if block["computed_merkle_root"] != block["merkle_root"]: | ||
| 269 | raise ParseError("bad merkle root at height %d" % height) | ||
| 270 | for tx_index, tx in enumerate(block["transactions"]): | ||
| 271 | coinbase = is_coinbase(tx) | ||
| 272 | input_value = 0 | ||
| 273 | output_value = sum(out["value_satoshis"] for out in tx["vout"]) | ||
| 274 | if coinbase: | ||
| 275 | total_coinbase += output_value | ||
| 276 | else: | ||
| 277 | normal_txs += 1 | ||
| 278 | for txin in tx["vin"]: | ||
| 279 | prev = "%s:%d" % (txin["prevout_hash"], txin["prevout_n"]) | ||
| 280 | prev_utxo = utxos.pop(prev, None) | ||
| 281 | if prev_utxo is None: | ||
| 282 | missing_spends.append({ | ||
| 283 | "height": height, | ||
| 284 | "txid": tx["txid"], | ||
| 285 | "missing_outpoint": prev, | ||
| 286 | }) | ||
| 287 | else: | ||
| 288 | spent += prev_utxo["value_satoshis"] | ||
| 289 | input_value += prev_utxo["value_satoshis"] | ||
| 290 | if input_value >= output_value: | ||
| 291 | total_fees += input_value - output_value | ||
| 292 | for n, out in enumerate(tx["vout"]): | ||
| 293 | key = "%s:%d" % (tx["txid"], n) | ||
| 294 | if key in utxos: | ||
| 295 | duplicate_outputs.append({"height": height, "outpoint": key}) | ||
| 296 | utxos[key] = { | ||
| 297 | "txid": tx["txid"], | ||
| 298 | "vout": n, | ||
| 299 | "value_satoshis": out["value_satoshis"], | ||
| 300 | "value_btf": out["value_btf"], | ||
| 301 | "script_pub_key_hex": out["script_pub_key_hex"], | ||
| 302 | "created_height": height, | ||
| 303 | "created_block_hash": block["hash"], | ||
| 304 | "created_txid": tx["txid"], | ||
| 305 | "coinbase": coinbase, | ||
| 306 | } | ||
| 307 | |||
| 308 | total_unspent = sum(u["value_satoshis"] for u in utxos.values()) | ||
| 309 | return { | ||
| 310 | "utxos": utxos, | ||
| 311 | "stats": { | ||
| 312 | "utxo_count": len(utxos), | ||
| 313 | "spent_outputs": spent, | ||
| 314 | "total_unspent_satoshis": total_unspent, | ||
| 315 | "total_unspent_btf": format_money(total_unspent), | ||
| 316 | "total_coinbase_satoshis": total_coinbase, | ||
| 317 | "total_coinbase_btf": format_money(total_coinbase), | ||
| 318 | "coinbase_minus_unspent_satoshis": total_coinbase - total_unspent, | ||
| 319 | "coinbase_minus_unspent_btf": format_money(total_coinbase - total_unspent), | ||
| 320 | "total_fees_satoshis": total_fees, | ||
| 321 | "total_fees_btf": format_money(total_fees), | ||
| 322 | "normal_transactions": normal_txs, | ||
| 323 | "missing_spends": missing_spends, | ||
| 324 | "duplicate_outputs": duplicate_outputs, | ||
| 325 | }, | ||
| 326 | } | ||
| 327 | |||
| 328 | |||
| 329 | def format_money(value): | ||
| 330 | sign = "-" if value < 0 else "" | ||
| 331 | value = abs(value) | ||
| 332 | return "%s%d.%08d" % (sign, value // COIN, value % COIN) | ||
| 333 | |||
| 334 | |||
| 335 | def utc(ts): | ||
| 336 | return _dt.datetime.fromtimestamp(ts, _dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") | ||
| 337 | |||
| 338 | |||
| 339 | def default_datadir(): | ||
| 340 | if os.name == "nt" and os.environ.get("APPDATA"): | ||
| 341 | return os.path.join(os.environ["APPDATA"], "Bitflash") | ||
| 342 | if os.environ.get("HOME"): | ||
| 343 | return os.path.join(os.environ["HOME"], ".bitflash") | ||
| 344 | return "." | ||
| 345 | |||
| 346 | |||
| 347 | def canonical_bytes(report): | ||
| 348 | return (json.dumps(report, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + "\n").encode("ascii") | ||
| 349 | 10 | ||
| 350 | 11 | ||
| 351 | def build_report(raw_blocks, include_utxos=False): | 12 | def build_report(raw_blocks, include_utxos=False, strict_duplicates=False): |
| 352 | blocks = select_main_chain(raw_blocks) | 13 | blocks = chain.select_main_chain(raw_blocks) |
| 353 | state = apply_blocks(blocks) | 14 | state = chain.apply_blocks(blocks, strict_duplicates=strict_duplicates) |
| 354 | utxos = state["utxos"] | 15 | utxos = state["utxos"] |
| 355 | stats = state["stats"] | 16 | stats = state["stats"] |
| 356 | tip = blocks[-1] | 17 | tip = blocks[-1] |
| 357 | root = utxo_root(utxos) | 18 | root = chain.utxo_root(utxos) |
| 358 | checks = { | 19 | checks = { |
| 359 | "genesis_hash_matches_source": blocks[0]["hash"] == GENESIS_HASH, | 20 | "genesis_hash_matches_source": blocks[0]["hash"] == chain.GENESIS_HASH, |
| 360 | "genesis_merkle_matches_source": blocks[0]["merkle_root"] == GENESIS_MERKLE, | 21 | "genesis_merkle_matches_source": blocks[0]["merkle_root"] == chain.GENESIS_MERKLE, |
| 361 | "all_scanned_merkle_roots_match": all(b["computed_merkle_root"] == b["merkle_root"] for b in blocks), | 22 | "all_scanned_merkle_roots_match": all(b["computed_merkle_root"] == b["merkle_root"] for b in blocks), |
| 362 | "no_missing_spends": len(stats["missing_spends"]) == 0, | 23 | "no_missing_spends": len(stats["missing_spends"]) == 0, |
| 363 | } | 24 | } |
| @@ -370,8 +31,8 @@ def build_report(raw_blocks, include_utxos=False): | |||
| 370 | "chain": { | 31 | "chain": { |
| 371 | "name": "Bitflash", | 32 | "name": "Bitflash", |
| 372 | "ticker": "BTF", | 33 | "ticker": "BTF", |
| 373 | "message_start_hex": MAGIC.hex(), | 34 | "message_start_hex": chain.MAGIC.hex(), |
| 374 | "coin": COIN, | 35 | "coin": chain.COIN, |
| 375 | }, | 36 | }, |
| 376 | "commitment": { | 37 | "commitment": { |
| 377 | "algorithm": "sorted-outpoint-merkle-sha256", | 38 | "algorithm": "sorted-outpoint-merkle-sha256", |
| @@ -400,7 +61,7 @@ def build_report(raw_blocks, include_utxos=False): | |||
| 400 | 61 | ||
| 401 | 62 | ||
| 402 | def print_text(report): | 63 | def print_text(report): |
| 403 | data_hash = hashlib.sha256(canonical_bytes(report)).hexdigest() | 64 | data_hash = hashlib.sha256(chain.canonical_bytes(report)).hexdigest() |
| 404 | print("Bitflash UTXO-set commitment verifier") | 65 | print("Bitflash UTXO-set commitment verifier") |
| 405 | print("schema: %s" % report["schema"]) | 66 | print("schema: %s" % report["schema"]) |
| 406 | print("canonical report sha256: %s" % data_hash) | 67 | print("canonical report sha256: %s" % data_hash) |
| @@ -437,20 +98,26 @@ def print_text(report): | |||
| 437 | 98 | ||
| 438 | def main(argv): | 99 | def main(argv): |
| 439 | ap = argparse.ArgumentParser(description=__doc__) | 100 | ap = argparse.ArgumentParser(description=__doc__) |
| 440 | ap.add_argument("--datadir", default=default_datadir(), help="Bitflash data directory") | 101 | ap.add_argument("--datadir", default=chain.default_datadir(), help="Bitflash data directory") |
| 441 | ap.add_argument("--max-blocks", type=int, default=0, | 102 | ap.add_argument("--max-blocks", type=int, default=0, |
| 442 | help="number of blocks to scan; 0 means all block files") | 103 | help="number of blocks to scan; 0 means all block files") |
| 443 | ap.add_argument("--include-utxos", action="store_true", | 104 | ap.add_argument("--include-utxos", action="store_true", |
| 444 | help="include the full sorted UTXO map in JSON output") | 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") | ||
| 445 | ap.add_argument("--json", action="store_true", help="print canonical JSON") | 108 | ap.add_argument("--json", action="store_true", help="print canonical JSON") |
| 446 | ap.add_argument("--out", help="write canonical JSON to this path") | 109 | ap.add_argument("--out", help="write canonical JSON to this path") |
| 447 | args = ap.parse_args(argv) | 110 | args = ap.parse_args(argv) |
| 448 | 111 | ||
| 449 | blocks = read_blocks(args.datadir, args.max_blocks) | 112 | blocks = chain.read_blocks(args.datadir, args.max_blocks) |
| 450 | if not blocks: | 113 | if not blocks: |
| 451 | raise SystemExit("no Bitflash blocks found in %s" % args.datadir) | 114 | raise SystemExit("no Bitflash blocks found in %s" % args.datadir) |
| 452 | report = build_report(blocks, include_utxos=args.include_utxos) | 115 | try: |
| 453 | data = canonical_bytes(report) | 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) | ||
| 454 | if args.out: | 121 | if args.out: |
| 455 | Path(args.out).write_bytes(data) | 122 | Path(args.out).write_bytes(data) |
| 456 | if args.json: | 123 | if args.json: |
diff --git a/src/Makefile b/src/Makefile index 00a8182..511fe79 100644 --- a/src/Makefile +++ b/src/Makefile | |||
| @@ -30,7 +30,7 @@ WARNFLAGS = -Wformat -Werror=format \ | |||
| 30 | -Wno-narrowing -Wno-unused-result | 30 | -Wno-narrowing -Wno-unused-result |
| 31 | 31 | ||
| 32 | CXXFLAGS = -std=gnu++14 -O2 $(WARNFLAGS) -fpermissive -pthread \ | 32 | CXXFLAGS = -std=gnu++14 -O2 $(WARNFLAGS) -fpermissive -pthread \ |
| 33 | -DSECP256K1_STATIC \ | 33 | -DSECP256K1_STATIC -DBITFLASH_NODE_BUILD \ |
| 34 | -I$(SECP_INC) -I$(RANDOMX_DIR)/src \ | 34 | -I$(SECP_INC) -I$(RANDOMX_DIR)/src \ |
| 35 | -I/usr/include/nlohmann \ | 35 | -I/usr/include/nlohmann \ |
| 36 | -I$(IMGUI_DIR) | 36 | -I$(IMGUI_DIR) |
| @@ -53,7 +53,7 @@ LIBS = -L$(SECP_LIB) -lsecp256k1 \ | |||
| 53 | HEADERS = headers_core.h headers.h util.h main.h serialize.h uint256.h \ | 53 | HEADERS = headers_core.h headers.h util.h main.h serialize.h uint256.h \ |
| 54 | key.h bignum.h script.h db.h base58.h net.h nostr.h \ | 54 | key.h bignum.h script.h db.h base58.h net.h nostr.h \ |
| 55 | randomx_pow.h btfaddr.h btfchan.h btfrv.h btftunnel.h market.h \ | 55 | randomx_pow.h btfaddr.h btfchan.h btfrv.h btftunnel.h market.h \ |
| 56 | selftest.h walletcmd.h compat.h sockcount.h font_roboto.h | 56 | selftest.h walletcmd.h compat.h sockcount.h proxy.h font_roboto.h |
| 57 | 57 | ||
| 58 | IMGUI_OBJS = obj/imgui.o obj/imgui_draw.o obj/imgui_tables.o \ | 58 | IMGUI_OBJS = obj/imgui.o obj/imgui_draw.o obj/imgui_tables.o \ |
| 59 | obj/imgui_widgets.o obj/imgui_impl_glfw.o obj/imgui_impl_opengl3.o | 59 | obj/imgui_widgets.o obj/imgui_impl_glfw.o obj/imgui_impl_opengl3.o |
| @@ -61,7 +61,7 @@ IMGUI_OBJS = obj/imgui.o obj/imgui_draw.o obj/imgui_tables.o \ | |||
| 61 | NODE_OBJS = obj/util.o obj/script.o obj/db.o obj/net.o obj/main.o \ | 61 | NODE_OBJS = obj/util.o obj/script.o obj/db.o obj/net.o obj/main.o \ |
| 62 | obj/market.o obj/sha.o obj/nostr.o obj/randomx_pow.o \ | 62 | obj/market.o obj/sha.o obj/nostr.o obj/randomx_pow.o \ |
| 63 | obj/btfaddr.o obj/btfchan.o obj/btfrv.o obj/btftunnel.o obj/rpc.o \ | 63 | obj/btfaddr.o obj/btfchan.o obj/btfrv.o obj/btftunnel.o obj/rpc.o \ |
| 64 | obj/selftest.o obj/bip32.o obj/walletcmd.o | 64 | obj/selftest.o obj/bip32.o obj/walletcmd.o obj/proxy.o |
| 65 | 65 | ||
| 66 | GUI_OBJS = obj/gui.o obj/main_gui.o | 66 | GUI_OBJS = obj/gui.o obj/main_gui.o |
| 67 | 67 | ||
| @@ -193,5 +193,6 @@ tests: test_bip32 bitflash-node | |||
| 193 | ./bitflash-node -selftest=consensus-limits | 193 | ./bitflash-node -selftest=consensus-limits |
| 194 | ./bitflash-node -selftest=pool-stratum | 194 | ./bitflash-node -selftest=pool-stratum |
| 195 | ./bitflash-node -selftest=parse-money | 195 | ./bitflash-node -selftest=parse-money |
| 196 | ./bitflash-node -selftest=socks5-proxy | ||
| 196 | 197 | ||
| 197 | .PHONY: tests fuzz-net-message-smoke fuzz-script-smoke | 198 | .PHONY: tests fuzz-net-message-smoke fuzz-script-smoke |
diff --git a/src/btfrv.cpp b/src/btfrv.cpp index 6ac3280..d4f079f 100644 --- a/src/btfrv.cpp +++ b/src/btfrv.cpp | |||
| @@ -5,6 +5,9 @@ | |||
| 5 | // Windows/Winsock implementation (Layer 6 will #ifdef this for Linux/BSD sockets). | 5 | // Windows/Winsock implementation (Layer 6 will #ifdef this for Linux/BSD sockets). |
| 6 | 6 | ||
| 7 | #include "btfrv.h" | 7 | #include "btfrv.h" |
| 8 | #ifdef BITFLASH_NODE_BUILD | ||
| 9 | #include "proxy.h" | ||
| 10 | #endif | ||
| 8 | 11 | ||
| 9 | // Portable sockets: Winsock on Windows, BSD sockets on Linux/POSIX (for the | 12 | // Portable sockets: Winsock on Windows, BSD sockets on Linux/POSIX (for the |
| 10 | // headless relay `bitflashd` that runs on the VPS). | 13 | // headless relay `bitflashd` that runs on the VPS). |
| @@ -92,6 +95,11 @@ static bool WriteN(SOCKET s, const void* buf, int n) | |||
| 92 | 95 | ||
| 93 | static SOCKET ConnectTo(const char* host, unsigned short port) | 96 | static SOCKET ConnectTo(const char* host, unsigned short port) |
| 94 | { | 97 | { |
| 98 | #ifdef BITFLASH_NODE_BUILD | ||
| 99 | SOCKET s = BtfConnectSocket(host, port, SOCK_RV_DIAL, 10); | ||
| 100 | if (s == INVALID_SOCKET) | ||
| 101 | return INVALID_SOCKET; | ||
| 102 | #else | ||
| 95 | struct addrinfo hints, *res = NULL; | 103 | struct addrinfo hints, *res = NULL; |
| 96 | memset(&hints, 0, sizeof(hints)); | 104 | memset(&hints, 0, sizeof(hints)); |
| 97 | hints.ai_family = AF_INET; | 105 | hints.ai_family = AF_INET; |
| @@ -119,6 +127,7 @@ static SOCKET ConnectTo(const char* host, unsigned short port) | |||
| 119 | CLOSESOCK(s); freeaddrinfo(res); return INVALID_SOCKET; | 127 | CLOSESOCK(s); freeaddrinfo(res); return INVALID_SOCKET; |
| 120 | } | 128 | } |
| 121 | freeaddrinfo(res); | 129 | freeaddrinfo(res); |
| 130 | #endif | ||
| 122 | 131 | ||
| 123 | // Clear the timeout -- the socket is now a live tunnel and must not | 132 | // Clear the timeout -- the socket is now a live tunnel and must not |
| 124 | // time out when idle. recv() will block until data arrives or peer closes. | 133 | // time out when idle. recv() will block until data arrives or peer closes. |
diff --git a/src/main_gui.cpp b/src/main_gui.cpp index b036ac9..bef8503 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp | |||
| @@ -1,6 +1,7 @@ | |||
| 1 | // Bitflash entry point -- starts node threads then runs GUI (or headless). | 1 | // Bitflash entry point -- starts node threads then runs GUI (or headless). |
| 2 | 2 | ||
| 3 | #include "headers_core.h" | 3 | #include "headers_core.h" |
| 4 | #include "proxy.h" | ||
| 4 | #include "selftest.h" | 5 | #include "selftest.h" |
| 5 | #include "walletcmd.h" | 6 | #include "walletcmd.h" |
| 6 | #include <thread> // hardware_concurrency, to sanity-check /genproclimit | 7 | #include <thread> // hardware_concurrency, to sanity-check /genproclimit |
| @@ -110,7 +111,8 @@ static void PrintUsage() | |||
| 110 | printf(" /debug\n"); | 111 | printf(" /debug\n"); |
| 111 | printf(" /gen\n"); | 112 | printf(" /gen\n"); |
| 112 | printf(" /nogui or /daemon\n"); | 113 | printf(" /nogui or /daemon\n"); |
| 113 | printf(" /selftest=wallet-keypool, wallet-hd, wallet-format, wallet-crypto, or wallet-encrypt\n"); | 114 | printf(" /selftest=wallet-keypool, wallet-hd, wallet-format, wallet-crypto, wallet-encrypt,\n"); |
| 115 | printf(" net-message, consensus-limits, pool-stratum, parse-money, or socks5-proxy\n"); | ||
| 114 | printf("\n"); | 116 | printf("\n"); |
| 115 | printf("Mining mode:\n"); | 117 | printf("Mining mode:\n"); |
| 116 | printf(" /operator\n"); | 118 | printf(" /operator\n"); |
| @@ -135,6 +137,8 @@ static void PrintUsage() | |||
| 135 | printf("\n"); | 137 | printf("\n"); |
| 136 | printf("Network:\n"); | 138 | printf("Network:\n"); |
| 137 | printf(" /port=N (P2P listen port, default 8433)\n"); | 139 | printf(" /port=N (P2P listen port, default 8433)\n"); |
| 140 | printf(" /socks=HOST:PORT (SOCKS5 proxy for outbound Nostr and .btf relay dials)\n"); | ||
| 141 | printf(" /tor[=HOST:PORT] (Tor mode; default SOCKS5 proxy is 127.0.0.1:9050)\n"); | ||
| 138 | printf(" /btfseed=ADDRESS:ENCHEX (extra bootstrap peer, repeatable)\n"); | 142 | printf(" /btfseed=ADDRESS:ENCHEX (extra bootstrap peer, repeatable)\n"); |
| 139 | printf("\n"); | 143 | printf("\n"); |
| 140 | printf("Wallet:\n"); | 144 | printf("Wallet:\n"); |
| @@ -282,6 +286,36 @@ static void ParseStartupArguments(int argc, char* argv[]) | |||
| 282 | if (!announceRelay.empty()) | 286 | if (!announceRelay.empty()) |
| 283 | strBtfAnnounceRelay = announceRelay; | 287 | strBtfAnnounceRelay = announceRelay; |
| 284 | 288 | ||
| 289 | bool fTorMode = arg(argc, argv, "/tor") || arg(argc, argv, "-tor"); | ||
| 290 | if (fTorMode) | ||
| 291 | { | ||
| 292 | string socksProxy = argval2(argc, argv, "/socks", "-socks"); | ||
| 293 | if (!socksProxy.empty()) | ||
| 294 | fprintf(stderr, "Warning: /tor takes precedence over /socks=%s\n", | ||
| 295 | socksProxy.c_str()); | ||
| 296 | |||
| 297 | string torProxy = argval2(argc, argv, "/tor", "-tor"); | ||
| 298 | string err; | ||
| 299 | if (!BtfEnableTorProxy(torProxy, err)) | ||
| 300 | fprintf(stderr, "Ignoring /tor=%s: %s\n", torProxy.c_str(), err.c_str()); | ||
| 301 | else | ||
| 302 | fprintf(stderr, "Tor mode enabled through SOCKS5 proxy %s\n", | ||
| 303 | BtfSocks5ProxyName().c_str()); | ||
| 304 | } | ||
| 305 | else | ||
| 306 | { | ||
| 307 | string socksProxy = argval2(argc, argv, "/socks", "-socks"); | ||
| 308 | if (!socksProxy.empty()) | ||
| 309 | { | ||
| 310 | string err; | ||
| 311 | if (!BtfSetSocks5Proxy(socksProxy, err)) | ||
| 312 | fprintf(stderr, "Ignoring /socks=%s: %s\n", socksProxy.c_str(), err.c_str()); | ||
| 313 | else | ||
| 314 | fprintf(stderr, "SOCKS5 proxy enabled for outbound discovery/relay dials: %s\n", | ||
| 315 | BtfSocks5ProxyName().c_str()); | ||
| 316 | } | ||
| 317 | } | ||
| 318 | |||
| 285 | // /btfseed=ADDRESS:ENCHEX -- extra bootstrap peers, repeatable. Useful for | 319 | // /btfseed=ADDRESS:ENCHEX -- extra bootstrap peers, repeatable. Useful for |
| 286 | // testing the seed path and for private networks that ship no compiled list. | 320 | // testing the seed path and for private networks that ship no compiled list. |
| 287 | for (int i = 1; i < argc; i++) | 321 | for (int i = 1; i < argc; i++) |
diff --git a/src/makefile.mingw b/src/makefile.mingw index 678d2e8..4e36a5c 100644 --- a/src/makefile.mingw +++ b/src/makefile.mingw | |||
| @@ -15,8 +15,13 @@ WARNFLAGS = -Wformat -Werror=format \ | |||
| 15 | -Wno-narrowing -Wno-unused-result | 15 | -Wno-narrowing -Wno-unused-result |
| 16 | 16 | ||
| 17 | CXXFLAGS = -std=gnu++14 -O2 $(WARNFLAGS) -fpermissive -pthread \ | 17 | CXXFLAGS = -std=gnu++14 -O2 $(WARNFLAGS) -fpermissive -pthread \ |
| 18 | <<<<<<< HEAD | ||
| 18 | -DWIN32 -DNOPCH \ | 19 | -DWIN32 -DNOPCH \ |
| 19 | -DSECP256K1_STATIC \ | 20 | -DSECP256K1_STATIC \ |
| 21 | ======= | ||
| 22 | -DWIN32 -DNOPCH -DFD_SETSIZE=1024 \ | ||
| 23 | -DSECP256K1_STATIC -DBITFLASH_NODE_BUILD \ | ||
| 24 | >>>>>>> origin/main | ||
| 20 | -I$(DEPS)/include -I$(RANDOMX)/src \ | 25 | -I$(DEPS)/include -I$(RANDOMX)/src \ |
| 21 | -I$(DEPS)/include/nlohmann \ | 26 | -I$(DEPS)/include/nlohmann \ |
| 22 | -IC:/msys64/ucrt64/include \ | 27 | -IC:/msys64/ucrt64/include \ |
| @@ -37,7 +42,7 @@ IMGUI_OBJS = obj/imgui.o obj/imgui_draw.o obj/imgui_tables.o \ | |||
| 37 | NODE_OBJS = obj/util.o obj/script.o obj/db.o obj/net.o obj/main.o \ | 42 | NODE_OBJS = obj/util.o obj/script.o obj/db.o obj/net.o obj/main.o \ |
| 38 | obj/market.o obj/sha.o obj/nostr.o obj/randomx_pow.o \ | 43 | obj/market.o obj/sha.o obj/nostr.o obj/randomx_pow.o \ |
| 39 | obj/btfaddr.o obj/btfchan.o obj/btfrv.o obj/btftunnel.o obj/rpc.o \ | 44 | obj/btfaddr.o obj/btfchan.o obj/btfrv.o obj/btftunnel.o obj/rpc.o \ |
| 40 | obj/selftest.o obj/bip32.o obj/walletcmd.o | 45 | obj/selftest.o obj/bip32.o obj/walletcmd.o obj/proxy.o |
| 41 | 46 | ||
| 42 | GUI_OBJS = obj/gui.o obj/main_gui.o obj/bitflash_res.o | 47 | GUI_OBJS = obj/gui.o obj/main_gui.o obj/bitflash_res.o |
| 43 | 48 | ||
| @@ -51,7 +56,7 @@ GUI_OBJS = obj/gui.o obj/main_gui.o obj/bitflash_res.o | |||
| 51 | HEADERS = headers_core.h headers.h util.h main.h serialize.h uint256.h \ | 56 | HEADERS = headers_core.h headers.h util.h main.h serialize.h uint256.h \ |
| 52 | key.h bignum.h script.h db.h base58.h net.h nostr.h \ | 57 | key.h bignum.h script.h db.h base58.h net.h nostr.h \ |
| 53 | randomx_pow.h btfaddr.h btfchan.h btfrv.h btftunnel.h market.h \ | 58 | randomx_pow.h btfaddr.h btfchan.h btfrv.h btftunnel.h market.h \ |
| 54 | selftest.h walletcmd.h sockcount.h compat.h font_roboto.h | 59 | selftest.h walletcmd.h sockcount.h proxy.h compat.h font_roboto.h |
| 55 | 60 | ||
| 56 | all: bitflash.exe | 61 | all: bitflash.exe |
| 57 | 62 | ||
| @@ -112,3 +117,4 @@ tests: test_bip32.exe bitflash.exe | |||
| 112 | ./bitflash.exe -selftest=net-message | 117 | ./bitflash.exe -selftest=net-message |
| 113 | ./bitflash.exe -selftest=consensus-limits | 118 | ./bitflash.exe -selftest=consensus-limits |
| 114 | ./bitflash.exe -selftest=pool-stratum | 119 | ./bitflash.exe -selftest=pool-stratum |
| 120 | ./bitflash.exe -selftest=socks5-proxy | ||
diff --git a/src/net.cpp b/src/net.cpp index b6b990a..c24547e 100644 --- a/src/net.cpp +++ b/src/net.cpp | |||
| @@ -19,6 +19,7 @@ | |||
| 19 | #include <openssl/rand.h> | 19 | #include <openssl/rand.h> |
| 20 | #include "btfaddr.h" | 20 | #include "btfaddr.h" |
| 21 | #include "btftunnel.h" | 21 | #include "btftunnel.h" |
| 22 | #include "proxy.h" | ||
| 22 | 23 | ||
| 23 | void ThreadReconnectCachedBtfPeers(void* parg); | 24 | void ThreadReconnectCachedBtfPeers(void* parg); |
| 24 | void ThreadMessageHandler2(void* parg); | 25 | void ThreadMessageHandler2(void* parg); |
| @@ -377,6 +378,9 @@ string GetDiagnosticsText() | |||
| 377 | 378 | ||
| 378 | bool GetMyExternalIP(unsigned int& ipRet) | 379 | bool GetMyExternalIP(unsigned int& ipRet) |
| 379 | { | 380 | { |
| 381 | if (BtfSocks5ProxyEnabled()) | ||
| 382 | return error("GetMyExternalIP() skipped while SOCKS5 proxy is enabled\n"); | ||
| 383 | |||
| 380 | // Try several plain-text IP echo services in order. | 384 | // Try several plain-text IP echo services in order. |
| 381 | // Each returns just the IPv4 address as the first line of the HTTP body. | 385 | // Each returns just the IPv4 address as the first line of the HTTP body. |
| 382 | struct { const char* host; const char* path; } services[] = { | 386 | struct { const char* host; const char* path; } services[] = { |
diff --git a/src/nostr.cpp b/src/nostr.cpp index 9bc538f..d286767 100644 --- a/src/nostr.cpp +++ b/src/nostr.cpp | |||
| @@ -29,6 +29,7 @@ | |||
| 29 | 29 | ||
| 30 | #include "btfaddr.h" | 30 | #include "btfaddr.h" |
| 31 | #include "btfchan.h" | 31 | #include "btfchan.h" |
| 32 | #include "proxy.h" | ||
| 32 | 33 | ||
| 33 | using json = nlohmann::json; | 34 | using json = nlohmann::json; |
| 34 | 35 | ||
| @@ -540,53 +541,8 @@ public: | |||
| 540 | size_t colon = host.find(':'); | 541 | size_t colon = host.find(':'); |
| 541 | if (colon != string::npos) { port = atoi(host.substr(colon + 1).c_str()); host = host.substr(0, colon); } | 542 | if (colon != string::npos) { port = atoi(host.substr(colon + 1).c_str()); host = host.substr(0, colon); } |
| 542 | 543 | ||
| 543 | // Resolve and connect (TCP). AF_UNSPEC so we get both A and AAAA | 544 | hSocket = BtfConnectSocket(host, (unsigned short)port, SOCK_NOSTR, timeoutSec); |
| 544 | // records back and try them in the order the resolver prefers, | 545 | if (hSocket == INVALID_SOCKET) |
| 545 | // rather than hard-failing whenever the IPv4 path to a relay is | ||
| 546 | // filtered/rate-limited (increasingly common for CDN-fronted | ||
| 547 | // relays) while a working IPv6 route exists right there in the | ||
| 548 | // same getaddrinfo() result. | ||
| 549 | struct addrinfo hints, *res = NULL, *rp = NULL; | ||
| 550 | memset(&hints, 0, sizeof(hints)); | ||
| 551 | hints.ai_family = AF_UNSPEC; | ||
| 552 | hints.ai_socktype = SOCK_STREAM; | ||
| 553 | char portstr[16]; sprintf(portstr, "%d", port); | ||
| 554 | if (getaddrinfo(host.c_str(), portstr, &hints, &res) != 0 || !res) | ||
| 555 | return error("Nostr: getaddrinfo %s failed", host.c_str()); | ||
| 556 | |||
| 557 | bool fConnected = false; | ||
| 558 | for (rp = res; rp != NULL; rp = rp->ai_next) | ||
| 559 | { | ||
| 560 | hSocket = BtfSocketTag(socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol), SOCK_NOSTR); | ||
| 561 | if (hSocket == INVALID_SOCKET) | ||
| 562 | continue; | ||
| 563 | |||
| 564 | // Socket receive/send timeout: Windows takes a DWORD of | ||
| 565 | // milliseconds; POSIX takes a struct timeval. | ||
| 566 | #ifdef _WIN32 | ||
| 567 | DWORD tv = timeoutSec * 1000; | ||
| 568 | setsockopt(hSocket, SOL_SOCKET, SO_RCVTIMEO, (char*)&tv, sizeof(tv)); | ||
| 569 | setsockopt(hSocket, SOL_SOCKET, SO_SNDTIMEO, (char*)&tv, sizeof(tv)); | ||
| 570 | #else | ||
| 571 | struct timeval tv; | ||
| 572 | tv.tv_sec = timeoutSec; | ||
| 573 | tv.tv_usec = 0; | ||
| 574 | setsockopt(hSocket, SOL_SOCKET, SO_RCVTIMEO, (char*)&tv, sizeof(tv)); | ||
| 575 | setsockopt(hSocket, SOL_SOCKET, SO_SNDTIMEO, (char*)&tv, sizeof(tv)); | ||
| 576 | #endif | ||
| 577 | |||
| 578 | if (connect(hSocket, rp->ai_addr, (int)rp->ai_addrlen) == 0) | ||
| 579 | { | ||
| 580 | fConnected = true; | ||
| 581 | break; | ||
| 582 | } | ||
| 583 | |||
| 584 | BtfCloseSocket(hSocket); | ||
| 585 | hSocket = INVALID_SOCKET; | ||
| 586 | } | ||
| 587 | freeaddrinfo(res); | ||
| 588 | |||
| 589 | if (!fConnected) | ||
| 590 | return error("Nostr: connect %s:%d failed", host.c_str(), port); | 546 | return error("Nostr: connect %s:%d failed", host.c_str(), port); |
| 591 | 547 | ||
| 592 | // TLS | 548 | // TLS |
diff --git a/src/proxy.cpp b/src/proxy.cpp new file mode 100644 index 0000000..7d30911 --- /dev/null +++ b/src/proxy.cpp | |||
| @@ -0,0 +1,377 @@ | |||
| 1 | // Copyright (c) 2026 Bitflash developers | ||
| 2 | // Distributed under the MIT/X11 software license. | ||
| 3 | |||
| 4 | #include "headers_core.h" | ||
| 5 | #include "proxy.h" | ||
| 6 | #include "sockcount.h" | ||
| 7 | |||
| 8 | #include <errno.h> | ||
| 9 | |||
| 10 | static CCriticalSection cs_socks5; | ||
| 11 | static bool g_fSocks5Proxy = false; | ||
| 12 | static bool g_fTorProxy = false; | ||
| 13 | static std::string g_socks5Host; | ||
| 14 | static unsigned short g_socks5Port = 0; | ||
| 15 | static const char* DEFAULT_TOR_SOCKS5_PROXY = "127.0.0.1:9050"; | ||
| 16 | |||
| 17 | static void SetSocketTimeout(SOCKET s, int nTimeoutSecs) | ||
| 18 | { | ||
| 19 | if (nTimeoutSecs <= 0) | ||
| 20 | return; | ||
| 21 | #ifdef _WIN32 | ||
| 22 | DWORD tv = (DWORD)nTimeoutSecs * 1000; | ||
| 23 | setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, (const char*)&tv, sizeof(tv)); | ||
| 24 | setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, (const char*)&tv, sizeof(tv)); | ||
| 25 | #else | ||
| 26 | struct timeval tv; | ||
| 27 | tv.tv_sec = nTimeoutSecs; | ||
| 28 | tv.tv_usec = 0; | ||
| 29 | setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); | ||
| 30 | setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); | ||
| 31 | #endif | ||
| 32 | } | ||
| 33 | |||
| 34 | static bool ReadN(SOCKET s, void* buf, int n) | ||
| 35 | { | ||
| 36 | char* p = (char*)buf; | ||
| 37 | int off = 0; | ||
| 38 | while (off < n) | ||
| 39 | { | ||
| 40 | int r = recv(s, p + off, n - off, 0); | ||
| 41 | if (r <= 0) | ||
| 42 | return false; | ||
| 43 | off += r; | ||
| 44 | } | ||
| 45 | return true; | ||
| 46 | } | ||
| 47 | |||
| 48 | static bool WriteN(SOCKET s, const void* buf, int n) | ||
| 49 | { | ||
| 50 | const char* p = (const char*)buf; | ||
| 51 | int off = 0; | ||
| 52 | while (off < n) | ||
| 53 | { | ||
| 54 | int r = send(s, p + off, n - off, 0); | ||
| 55 | if (r <= 0) | ||
| 56 | return false; | ||
| 57 | off += r; | ||
| 58 | } | ||
| 59 | return true; | ||
| 60 | } | ||
| 61 | |||
| 62 | static bool ValidProxyHostChar(char c) | ||
| 63 | { | ||
| 64 | return isalnum((unsigned char)c) || c == '.' || c == '-' || c == '_'; | ||
| 65 | } | ||
| 66 | |||
| 67 | static bool ValidProxyIpv6HostChar(char c) | ||
| 68 | { | ||
| 69 | return isxdigit((unsigned char)c) || c == ':' || c == '.'; | ||
| 70 | } | ||
| 71 | |||
| 72 | static std::string LowerProxyHost(std::string host) | ||
| 73 | { | ||
| 74 | if (!host.empty() && host[host.size() - 1] == '.') | ||
| 75 | host.erase(host.size() - 1); | ||
| 76 | for (size_t i = 0; i < host.size(); i++) | ||
| 77 | host[i] = (char)tolower((unsigned char)host[i]); | ||
| 78 | return host; | ||
| 79 | } | ||
| 80 | |||
| 81 | bool BtfIsTorOnionHost(const std::string& host) | ||
| 82 | { | ||
| 83 | std::string lower = LowerProxyHost(host); | ||
| 84 | static const char* suffix = ".onion"; | ||
| 85 | size_t suffixLen = strlen(suffix); | ||
| 86 | return lower.size() > suffixLen && | ||
| 87 | lower.compare(lower.size() - suffixLen, suffixLen, suffix) == 0; | ||
| 88 | } | ||
| 89 | |||
| 90 | static bool ParsePort(const std::string& port, unsigned short& portOut, | ||
| 91 | std::string& errOut) | ||
| 92 | { | ||
| 93 | if (port.empty()) | ||
| 94 | { | ||
| 95 | errOut = "port is empty"; | ||
| 96 | return false; | ||
| 97 | } | ||
| 98 | for (size_t i = 0; i < port.size(); i++) | ||
| 99 | { | ||
| 100 | if (!isdigit((unsigned char)port[i])) | ||
| 101 | { | ||
| 102 | errOut = "port is not numeric"; | ||
| 103 | return false; | ||
| 104 | } | ||
| 105 | } | ||
| 106 | |||
| 107 | errno = 0; | ||
| 108 | char* end = NULL; | ||
| 109 | long nPort = strtol(port.c_str(), &end, 10); | ||
| 110 | if (errno == ERANGE || end == NULL || *end != '\0' || nPort <= 0 || nPort > 65535) | ||
| 111 | { | ||
| 112 | errOut = "port is out of range"; | ||
| 113 | return false; | ||
| 114 | } | ||
| 115 | portOut = (unsigned short)nPort; | ||
| 116 | return true; | ||
| 117 | } | ||
| 118 | |||
| 119 | bool BtfParseSocks5Proxy(const std::string& spec, std::string& hostOut, | ||
| 120 | unsigned short& portOut, std::string& errOut) | ||
| 121 | { | ||
| 122 | hostOut.clear(); | ||
| 123 | portOut = 0; | ||
| 124 | errOut.clear(); | ||
| 125 | |||
| 126 | std::string host; | ||
| 127 | std::string port; | ||
| 128 | bool fBracketedIpv6 = false; | ||
| 129 | if (!spec.empty() && spec[0] == '[') | ||
| 130 | { | ||
| 131 | size_t close = spec.find(']'); | ||
| 132 | if (close == std::string::npos || close == 1 || close + 1 >= spec.size() || spec[close + 1] != ':') | ||
| 133 | { | ||
| 134 | errOut = "expected [IPv6]:PORT"; | ||
| 135 | return false; | ||
| 136 | } | ||
| 137 | host = spec.substr(1, close - 1); | ||
| 138 | port = spec.substr(close + 2); | ||
| 139 | fBracketedIpv6 = true; | ||
| 140 | } | ||
| 141 | else | ||
| 142 | { | ||
| 143 | size_t colon = spec.rfind(':'); | ||
| 144 | if (colon == std::string::npos || colon == 0 || colon + 1 >= spec.size()) | ||
| 145 | { | ||
| 146 | errOut = "expected HOST:PORT"; | ||
| 147 | return false; | ||
| 148 | } | ||
| 149 | host = spec.substr(0, colon); | ||
| 150 | port = spec.substr(colon + 1); | ||
| 151 | if (host.find(':') != std::string::npos) | ||
| 152 | { | ||
| 153 | errOut = "IPv6 proxy hosts must use [ADDR]:PORT"; | ||
| 154 | return false; | ||
| 155 | } | ||
| 156 | } | ||
| 157 | |||
| 158 | if (host.size() > 255) | ||
| 159 | { | ||
| 160 | errOut = "host is too long"; | ||
| 161 | return false; | ||
| 162 | } | ||
| 163 | if (fBracketedIpv6) | ||
| 164 | { | ||
| 165 | bool fHasColon = false; | ||
| 166 | for (size_t i = 0; i < host.size(); i++) | ||
| 167 | { | ||
| 168 | fHasColon = fHasColon || host[i] == ':'; | ||
| 169 | if (!ValidProxyIpv6HostChar(host[i])) | ||
| 170 | { | ||
| 171 | errOut = "IPv6 proxy host contains unsupported characters"; | ||
| 172 | return false; | ||
| 173 | } | ||
| 174 | } | ||
| 175 | if (!fHasColon) | ||
| 176 | { | ||
| 177 | errOut = "bracketed proxy host is not IPv6"; | ||
| 178 | return false; | ||
| 179 | } | ||
| 180 | } | ||
| 181 | else | ||
| 182 | { | ||
| 183 | for (size_t i = 0; i < host.size(); i++) | ||
| 184 | { | ||
| 185 | if (!ValidProxyHostChar(host[i])) | ||
| 186 | { | ||
| 187 | errOut = "host contains unsupported characters"; | ||
| 188 | return false; | ||
| 189 | } | ||
| 190 | } | ||
| 191 | } | ||
| 192 | if (!ParsePort(port, portOut, errOut)) | ||
| 193 | return false; | ||
| 194 | |||
| 195 | hostOut = host; | ||
| 196 | return true; | ||
| 197 | } | ||
| 198 | |||
| 199 | static bool SetSocks5Proxy(const std::string& spec, bool fTorMode, | ||
| 200 | std::string& errOut) | ||
| 201 | { | ||
| 202 | std::string host; | ||
| 203 | unsigned short port = 0; | ||
| 204 | if (!BtfParseSocks5Proxy(spec, host, port, errOut)) | ||
| 205 | return false; | ||
| 206 | CRITICAL_BLOCK(cs_socks5) | ||
| 207 | { | ||
| 208 | g_socks5Host = host; | ||
| 209 | g_socks5Port = port; | ||
| 210 | g_fSocks5Proxy = true; | ||
| 211 | g_fTorProxy = fTorMode; | ||
| 212 | } | ||
| 213 | return true; | ||
| 214 | } | ||
| 215 | |||
| 216 | bool BtfSetSocks5Proxy(const std::string& spec, std::string& errOut) | ||
| 217 | { | ||
| 218 | return SetSocks5Proxy(spec, false, errOut); | ||
| 219 | } | ||
| 220 | |||
| 221 | bool BtfEnableTorProxy(const std::string& spec, std::string& errOut) | ||
| 222 | { | ||
| 223 | return SetSocks5Proxy(spec.empty() ? DEFAULT_TOR_SOCKS5_PROXY : spec, true, errOut); | ||
| 224 | } | ||
| 225 | |||
| 226 | void BtfClearSocks5Proxy() | ||
| 227 | { | ||
| 228 | CRITICAL_BLOCK(cs_socks5) | ||
| 229 | { | ||
| 230 | g_fSocks5Proxy = false; | ||
| 231 | g_fTorProxy = false; | ||
| 232 | g_socks5Host.clear(); | ||
| 233 | g_socks5Port = 0; | ||
| 234 | } | ||
| 235 | } | ||
| 236 | |||
| 237 | bool BtfSocks5ProxyEnabled() | ||
| 238 | { | ||
| 239 | CRITICAL_BLOCK(cs_socks5) | ||
| 240 | return g_fSocks5Proxy; | ||
| 241 | return false; | ||
| 242 | } | ||
| 243 | |||
| 244 | bool BtfTorProxyEnabled() | ||
| 245 | { | ||
| 246 | CRITICAL_BLOCK(cs_socks5) | ||
| 247 | return g_fSocks5Proxy && g_fTorProxy; | ||
| 248 | return false; | ||
| 249 | } | ||
| 250 | |||
| 251 | std::string BtfSocks5ProxyName() | ||
| 252 | { | ||
| 253 | CRITICAL_BLOCK(cs_socks5) | ||
| 254 | { | ||
| 255 | if (!g_fSocks5Proxy) | ||
| 256 | return ""; | ||
| 257 | if (g_socks5Host.find(':') != std::string::npos) | ||
| 258 | return strprintf("[%s]:%u", g_socks5Host.c_str(), (unsigned)g_socks5Port); | ||
| 259 | return strprintf("%s:%u", g_socks5Host.c_str(), (unsigned)g_socks5Port); | ||
| 260 | } | ||
| 261 | return ""; | ||
| 262 | } | ||
| 263 | |||
| 264 | static SOCKET ConnectDirect(const std::string& host, unsigned short port, | ||
| 265 | int nSockSite, int nTimeoutSecs) | ||
| 266 | { | ||
| 267 | struct addrinfo hints, *res = NULL, *rp = NULL; | ||
| 268 | memset(&hints, 0, sizeof(hints)); | ||
| 269 | hints.ai_family = AF_UNSPEC; | ||
| 270 | hints.ai_socktype = SOCK_STREAM; | ||
| 271 | char portstr[16]; | ||
| 272 | sprintf(portstr, "%u", (unsigned)port); | ||
| 273 | if (getaddrinfo(host.c_str(), portstr, &hints, &res) != 0 || !res) | ||
| 274 | return INVALID_SOCKET; | ||
| 275 | |||
| 276 | SOCKET s = INVALID_SOCKET; | ||
| 277 | for (rp = res; rp != NULL; rp = rp->ai_next) | ||
| 278 | { | ||
| 279 | s = BtfSocketTag(socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol), nSockSite); | ||
| 280 | if (s == INVALID_SOCKET) | ||
| 281 | continue; | ||
| 282 | SetSocketTimeout(s, nTimeoutSecs); | ||
| 283 | if (connect(s, rp->ai_addr, (int)rp->ai_addrlen) == 0) | ||
| 284 | break; | ||
| 285 | BtfCloseSocket(s); | ||
| 286 | s = INVALID_SOCKET; | ||
| 287 | } | ||
| 288 | freeaddrinfo(res); | ||
| 289 | return s; | ||
| 290 | } | ||
| 291 | |||
| 292 | static bool Socks5Connect(SOCKET s, const std::string& destHost, | ||
| 293 | unsigned short destPort) | ||
| 294 | { | ||
| 295 | if (destHost.empty() || destHost.size() > 255) | ||
| 296 | return false; | ||
| 297 | |||
| 298 | unsigned char hello[3] = { 0x05, 0x01, 0x00 }; // SOCKS5, one method, no auth | ||
| 299 | if (!WriteN(s, hello, sizeof(hello))) | ||
| 300 | return false; | ||
| 301 | unsigned char choice[2] = { 0, 0 }; | ||
| 302 | if (!ReadN(s, choice, sizeof(choice)) || choice[0] != 0x05 || choice[1] != 0x00) | ||
| 303 | return false; | ||
| 304 | |||
| 305 | std::vector<unsigned char> req; | ||
| 306 | req.reserve(7 + destHost.size()); | ||
| 307 | req.push_back(0x05); // version | ||
| 308 | req.push_back(0x01); // CONNECT | ||
| 309 | req.push_back(0x00); // reserved | ||
| 310 | req.push_back(0x03); // domain name; proxy resolves, avoiding local DNS leak | ||
| 311 | req.push_back((unsigned char)destHost.size()); | ||
| 312 | req.insert(req.end(), destHost.begin(), destHost.end()); | ||
| 313 | req.push_back((unsigned char)(destPort >> 8)); | ||
| 314 | req.push_back((unsigned char)(destPort & 0xff)); | ||
| 315 | if (!WriteN(s, &req[0], (int)req.size())) | ||
| 316 | return false; | ||
| 317 | |||
| 318 | unsigned char rep[4] = {0,0,0,0}; | ||
| 319 | if (!ReadN(s, rep, sizeof(rep)) || rep[0] != 0x05) | ||
| 320 | return false; | ||
| 321 | if (rep[1] != 0x00) | ||
| 322 | { | ||
| 323 | error("SOCKS5 CONNECT to %s:%u failed with reply 0x%02x\n", | ||
| 324 | destHost.c_str(), (unsigned)destPort, (unsigned)rep[1]); | ||
| 325 | return false; | ||
| 326 | } | ||
| 327 | int nAddr = 0; | ||
| 328 | if (rep[3] == 0x01) nAddr = 4; | ||
| 329 | else if (rep[3] == 0x04) nAddr = 16; | ||
| 330 | else if (rep[3] == 0x03) | ||
| 331 | { | ||
| 332 | unsigned char len = 0; | ||
| 333 | if (!ReadN(s, &len, 1)) | ||
| 334 | return false; | ||
| 335 | nAddr = len; | ||
| 336 | } | ||
| 337 | else | ||
| 338 | return false; | ||
| 339 | |||
| 340 | std::vector<unsigned char> discard(nAddr + 2); | ||
| 341 | return ReadN(s, &discard[0], (int)discard.size()); | ||
| 342 | } | ||
| 343 | |||
| 344 | SOCKET BtfConnectSocket(const std::string& destHost, unsigned short destPort, | ||
| 345 | int nSockSite, int nTimeoutSecs) | ||
| 346 | { | ||
| 347 | std::string proxyHost; | ||
| 348 | unsigned short proxyPort = 0; | ||
| 349 | bool fProxy = false; | ||
| 350 | CRITICAL_BLOCK(cs_socks5) | ||
| 351 | { | ||
| 352 | fProxy = g_fSocks5Proxy; | ||
| 353 | proxyHost = g_socks5Host; | ||
| 354 | proxyPort = g_socks5Port; | ||
| 355 | } | ||
| 356 | |||
| 357 | if (!fProxy) | ||
| 358 | { | ||
| 359 | if (BtfIsTorOnionHost(destHost)) | ||
| 360 | { | ||
| 361 | error("Refusing direct .onion connection to %s:%u; use /tor or /socks\n", | ||
| 362 | destHost.c_str(), (unsigned)destPort); | ||
| 363 | return INVALID_SOCKET; | ||
| 364 | } | ||
| 365 | return ConnectDirect(destHost, destPort, nSockSite, nTimeoutSecs); | ||
| 366 | } | ||
| 367 | |||
| 368 | SOCKET s = ConnectDirect(proxyHost, proxyPort, nSockSite, nTimeoutSecs); | ||
| 369 | if (s == INVALID_SOCKET) | ||
| 370 | return INVALID_SOCKET; | ||
| 371 | if (!Socks5Connect(s, destHost, destPort)) | ||
| 372 | { | ||
| 373 | BtfCloseSocket(s); | ||
| 374 | return INVALID_SOCKET; | ||
| 375 | } | ||
| 376 | return s; | ||
| 377 | } | ||
diff --git a/src/proxy.h b/src/proxy.h new file mode 100644 index 0000000..6429cf2 --- /dev/null +++ b/src/proxy.h | |||
| @@ -0,0 +1,34 @@ | |||
| 1 | // Copyright (c) 2026 Bitflash developers | ||
| 2 | // Distributed under the MIT/X11 software license. | ||
| 3 | // | ||
| 4 | // Optional SOCKS5 outbound proxy support. This is transport plumbing only: | ||
| 5 | // no consensus, wallet, mining, or .btf identity data depends on it. | ||
| 6 | |||
| 7 | #ifndef BITFLASH_PROXY_H | ||
| 8 | #define BITFLASH_PROXY_H | ||
| 9 | |||
| 10 | #ifdef _WIN32 | ||
| 11 | #ifndef WIN32_LEAN_AND_MEAN | ||
| 12 | #define WIN32_LEAN_AND_MEAN 1 | ||
| 13 | #endif | ||
| 14 | #include <winsock2.h> | ||
| 15 | #else | ||
| 16 | #include "compat.h" | ||
| 17 | #endif | ||
| 18 | |||
| 19 | #include <string> | ||
| 20 | |||
| 21 | bool BtfParseSocks5Proxy(const std::string& spec, std::string& hostOut, | ||
| 22 | unsigned short& portOut, std::string& errOut); | ||
| 23 | bool BtfSetSocks5Proxy(const std::string& spec, std::string& errOut); | ||
| 24 | bool BtfEnableTorProxy(const std::string& spec, std::string& errOut); | ||
| 25 | void BtfClearSocks5Proxy(); | ||
| 26 | bool BtfSocks5ProxyEnabled(); | ||
| 27 | bool BtfTorProxyEnabled(); | ||
| 28 | bool BtfIsTorOnionHost(const std::string& host); | ||
| 29 | std::string BtfSocks5ProxyName(); | ||
| 30 | |||
| 31 | SOCKET BtfConnectSocket(const std::string& destHost, unsigned short destPort, | ||
| 32 | int nSockSite, int nTimeoutSecs); | ||
| 33 | |||
| 34 | #endif | ||
diff --git a/src/readme.txt b/src/readme.txt index b8b89c1..794fe20 100644 --- a/src/readme.txt +++ b/src/readme.txt | |||
| @@ -42,6 +42,13 @@ The resulting executable is src/bitflash.exe. | |||
| 42 | Command-line options (in addition to the original ones) | 42 | Command-line options (in addition to the original ones) |
| 43 | ------------------------------------------------------- | 43 | ------------------------------------------------------- |
| 44 | /port=N listen on P2P port N (default 8433) | 44 | /port=N listen on P2P port N (default 8433) |
| 45 | /socks=HOST:PORT | ||
| 46 | route outbound Nostr and .btf rendezvous dials through a | ||
| 47 | SOCKS5 proxy, e.g. /socks=127.0.0.1:9050 for local Tor | ||
| 48 | or /socks=[::1]:9050 for an IPv6 loopback proxy | ||
| 49 | /tor[=HOST:PORT] | ||
| 50 | Tor mode for outbound Nostr and .btf rendezvous dials; | ||
| 51 | defaults to the local Tor SOCKS5 proxy at 127.0.0.1:9050 | ||
| 45 | /solomine mine without requiring a connected peer (local testing) | 52 | /solomine mine without requiring a connected peer (local testing) |
| 46 | /operator operator mode (run pool server) | 53 | /operator operator mode (run pool server) |
| 47 | /participant=ADDR | 54 | /participant=ADDR |
diff --git a/src/selftest.cpp b/src/selftest.cpp index dcb9192..cca5335 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp | |||
| @@ -7,7 +7,9 @@ | |||
| 7 | 7 | ||
| 8 | #include "headers_core.h" | 8 | #include "headers_core.h" |
| 9 | #include "bip32.h" | 9 | #include "bip32.h" |
| 10 | #include "proxy.h" | ||
| 10 | #include "selftest.h" | 11 | #include "selftest.h" |
| 12 | #include "sockcount.h" | ||
| 11 | #include "walletcmd.h" | 13 | #include "walletcmd.h" |
| 12 | 14 | ||
| 13 | extern int RunPoolStratumSelfTest(); | 15 | extern int RunPoolStratumSelfTest(); |
| @@ -1253,6 +1255,279 @@ static int RunParseMoneySelfTest() | |||
| 1253 | return nFail == 0 ? 0 : 1; | 1255 | return nFail == 0 ? 0 : 1; |
| 1254 | } | 1256 | } |
| 1255 | 1257 | ||
| 1258 | static bool SelfTestReadN(SOCKET s, void* buf, int n) | ||
| 1259 | { | ||
| 1260 | char* p = (char*)buf; | ||
| 1261 | int off = 0; | ||
| 1262 | while (off < n) | ||
| 1263 | { | ||
| 1264 | int r = recv(s, p + off, n - off, 0); | ||
| 1265 | if (r <= 0) | ||
| 1266 | return false; | ||
| 1267 | off += r; | ||
| 1268 | } | ||
| 1269 | return true; | ||
| 1270 | } | ||
| 1271 | |||
| 1272 | static bool SelfTestWriteN(SOCKET s, const void* buf, int n) | ||
| 1273 | { | ||
| 1274 | const char* p = (const char*)buf; | ||
| 1275 | int off = 0; | ||
| 1276 | while (off < n) | ||
| 1277 | { | ||
| 1278 | int r = send(s, p + off, n - off, 0); | ||
| 1279 | if (r <= 0) | ||
| 1280 | return false; | ||
| 1281 | off += r; | ||
| 1282 | } | ||
| 1283 | return true; | ||
| 1284 | } | ||
| 1285 | |||
| 1286 | static void SelfTestSetSocketTimeout(SOCKET s, int nTimeoutSecs) | ||
| 1287 | { | ||
| 1288 | #ifdef _WIN32 | ||
| 1289 | DWORD tv = (DWORD)nTimeoutSecs * 1000; | ||
| 1290 | setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, (const char*)&tv, sizeof(tv)); | ||
| 1291 | setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, (const char*)&tv, sizeof(tv)); | ||
| 1292 | #else | ||
| 1293 | struct timeval tv; | ||
| 1294 | tv.tv_sec = nTimeoutSecs; | ||
| 1295 | tv.tv_usec = 0; | ||
| 1296 | setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); | ||
| 1297 | setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); | ||
| 1298 | #endif | ||
| 1299 | } | ||
| 1300 | |||
| 1301 | #ifdef _WIN32 | ||
| 1302 | struct SelfTestWinsock | ||
| 1303 | { | ||
| 1304 | bool fStarted; | ||
| 1305 | SelfTestWinsock() : fStarted(false) | ||
| 1306 | { | ||
| 1307 | WSADATA wsadata; | ||
| 1308 | fStarted = (WSAStartup(MAKEWORD(2,2), &wsadata) == 0); | ||
| 1309 | } | ||
| 1310 | ~SelfTestWinsock() | ||
| 1311 | { | ||
| 1312 | if (fStarted) | ||
| 1313 | WSACleanup(); | ||
| 1314 | } | ||
| 1315 | }; | ||
| 1316 | #endif | ||
| 1317 | |||
| 1318 | static bool RunSocks5HandshakeProbe(std::string& errOut) | ||
| 1319 | { | ||
| 1320 | errOut.clear(); | ||
| 1321 | #ifdef _WIN32 | ||
| 1322 | SelfTestWinsock winsock; | ||
| 1323 | if (!winsock.fStarted) | ||
| 1324 | { | ||
| 1325 | errOut = "could not start Winsock"; | ||
| 1326 | return false; | ||
| 1327 | } | ||
| 1328 | #endif | ||
| 1329 | |||
| 1330 | SOCKET listener = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); | ||
| 1331 | if (listener == INVALID_SOCKET) | ||
| 1332 | { | ||
| 1333 | errOut = "could not create listener"; | ||
| 1334 | return false; | ||
| 1335 | } | ||
| 1336 | SelfTestSetSocketTimeout(listener, 5); | ||
| 1337 | |||
| 1338 | struct sockaddr_in addr; | ||
| 1339 | memset(&addr, 0, sizeof(addr)); | ||
| 1340 | addr.sin_family = AF_INET; | ||
| 1341 | addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); | ||
| 1342 | addr.sin_port = 0; | ||
| 1343 | if (bind(listener, (struct sockaddr*)&addr, sizeof(addr)) != 0 || | ||
| 1344 | listen(listener, 1) != 0) | ||
| 1345 | { | ||
| 1346 | closesocket(listener); | ||
| 1347 | errOut = "could not bind/listen on loopback"; | ||
| 1348 | return false; | ||
| 1349 | } | ||
| 1350 | |||
| 1351 | #ifdef _WIN32 | ||
| 1352 | int addrLen = sizeof(addr); | ||
| 1353 | #else | ||
| 1354 | socklen_t addrLen = sizeof(addr); | ||
| 1355 | #endif | ||
| 1356 | if (getsockname(listener, (struct sockaddr*)&addr, &addrLen) != 0) | ||
| 1357 | { | ||
| 1358 | closesocket(listener); | ||
| 1359 | errOut = "could not read listener port"; | ||
| 1360 | return false; | ||
| 1361 | } | ||
| 1362 | unsigned short proxyPort = ntohs(addr.sin_port); | ||
| 1363 | |||
| 1364 | bool fServerOk = false; | ||
| 1365 | std::string serverErr; | ||
| 1366 | std::string requestedHost; | ||
| 1367 | unsigned short requestedPort = 0; | ||
| 1368 | std::thread server([&]() { | ||
| 1369 | SOCKET s = accept(listener, NULL, NULL); | ||
| 1370 | if (s == INVALID_SOCKET) | ||
| 1371 | { | ||
| 1372 | serverErr = "accept failed"; | ||
| 1373 | return; | ||
| 1374 | } | ||
| 1375 | SelfTestSetSocketTimeout(s, 5); | ||
| 1376 | |||
| 1377 | unsigned char hello[3] = {0,0,0}; | ||
| 1378 | if (!SelfTestReadN(s, hello, sizeof(hello)) || | ||
| 1379 | hello[0] != 0x05 || hello[1] != 0x01 || hello[2] != 0x00) | ||
| 1380 | { | ||
| 1381 | serverErr = "bad SOCKS5 greeting"; | ||
| 1382 | closesocket(s); | ||
| 1383 | return; | ||
| 1384 | } | ||
| 1385 | unsigned char choice[2] = {0x05, 0x00}; | ||
| 1386 | if (!SelfTestWriteN(s, choice, sizeof(choice))) | ||
| 1387 | { | ||
| 1388 | serverErr = "could not write method choice"; | ||
| 1389 | closesocket(s); | ||
| 1390 | return; | ||
| 1391 | } | ||
| 1392 | |||
| 1393 | unsigned char hdr[5] = {0,0,0,0,0}; | ||
| 1394 | if (!SelfTestReadN(s, hdr, sizeof(hdr)) || | ||
| 1395 | hdr[0] != 0x05 || hdr[1] != 0x01 || hdr[2] != 0x00 || hdr[3] != 0x03) | ||
| 1396 | { | ||
| 1397 | serverErr = "CONNECT did not use domain-name address type"; | ||
| 1398 | closesocket(s); | ||
| 1399 | return; | ||
| 1400 | } | ||
| 1401 | |||
| 1402 | unsigned char len = hdr[4]; | ||
| 1403 | std::vector<unsigned char> rest((size_t)len + 2); | ||
| 1404 | if (!SelfTestReadN(s, &rest[0], (int)rest.size())) | ||
| 1405 | { | ||
| 1406 | serverErr = "could not read CONNECT target"; | ||
| 1407 | closesocket(s); | ||
| 1408 | return; | ||
| 1409 | } | ||
| 1410 | requestedHost.assign((const char*)&rest[0], (size_t)len); | ||
| 1411 | requestedPort = ((unsigned short)rest[len] << 8) | rest[len + 1]; | ||
| 1412 | |||
| 1413 | unsigned char reply[10] = {0x05, 0x00, 0x00, 0x01, 0,0,0,0, 0,0}; | ||
| 1414 | if (!SelfTestWriteN(s, reply, sizeof(reply))) | ||
| 1415 | { | ||
| 1416 | serverErr = "could not write CONNECT reply"; | ||
| 1417 | closesocket(s); | ||
| 1418 | return; | ||
| 1419 | } | ||
| 1420 | fServerOk = true; | ||
| 1421 | closesocket(s); | ||
| 1422 | }); | ||
| 1423 | |||
| 1424 | std::string err; | ||
| 1425 | BtfClearSocks5Proxy(); | ||
| 1426 | bool fClientOk = BtfSetSocks5Proxy(strprintf("127.0.0.1:%u", (unsigned)proxyPort), err); | ||
| 1427 | SOCKET client = INVALID_SOCKET; | ||
| 1428 | if (fClientOk) | ||
| 1429 | client = BtfConnectSocket("relay.example", 443, SOCK_NOSTR, 5); | ||
| 1430 | if (client != INVALID_SOCKET) | ||
| 1431 | BtfCloseSocket(client); | ||
| 1432 | BtfClearSocks5Proxy(); | ||
| 1433 | closesocket(listener); | ||
| 1434 | server.join(); | ||
| 1435 | |||
| 1436 | if (!fClientOk) | ||
| 1437 | errOut = strprintf("could not enable proxy: %s", err.c_str()); | ||
| 1438 | else if (client == INVALID_SOCKET) | ||
| 1439 | errOut = "client SOCKS5 connect failed"; | ||
| 1440 | else if (!fServerOk) | ||
| 1441 | errOut = serverErr.empty() ? "mock server failed" : serverErr; | ||
| 1442 | else if (requestedHost != "relay.example" || requestedPort != 443) | ||
| 1443 | errOut = strprintf("unexpected CONNECT target %s:%u", requestedHost.c_str(), (unsigned)requestedPort); | ||
| 1444 | else | ||
| 1445 | return true; | ||
| 1446 | return false; | ||
| 1447 | } | ||
| 1448 | |||
| 1449 | static int RunSocks5ProxySelfTest() | ||
| 1450 | { | ||
| 1451 | printf("socks5-proxy self-test\n"); | ||
| 1452 | int nFail = 0; | ||
| 1453 | std::string host, err; | ||
| 1454 | unsigned short port = 0; | ||
| 1455 | |||
| 1456 | nFail += Check(BtfParseSocks5Proxy("127.0.0.1:9050", host, port, err) && | ||
| 1457 | host == "127.0.0.1" && port == 9050, | ||
| 1458 | "parses local Tor proxy endpoint") ? 0 : 1; | ||
| 1459 | nFail += Check(BtfParseSocks5Proxy("localhost:9050", host, port, err) && | ||
| 1460 | host == "localhost" && port == 9050, | ||
| 1461 | "parses hostname proxy endpoint") ? 0 : 1; | ||
| 1462 | nFail += Check(BtfParseSocks5Proxy("[::1]:9050", host, port, err) && | ||
| 1463 | host == "::1" && port == 9050, | ||
| 1464 | "parses bracketed IPv6 proxy endpoint") ? 0 : 1; | ||
| 1465 | nFail += Check(!BtfParseSocks5Proxy("::1:9050", host, port, err), | ||
| 1466 | "rejects unbracketed IPv6 proxy endpoint") ? 0 : 1; | ||
| 1467 | nFail += Check(!BtfParseSocks5Proxy("localhost", host, port, err), | ||
| 1468 | "rejects missing port") ? 0 : 1; | ||
| 1469 | nFail += Check(!BtfParseSocks5Proxy("localhost:0", host, port, err), | ||
| 1470 | "rejects zero port") ? 0 : 1; | ||
| 1471 | nFail += Check(!BtfParseSocks5Proxy("localhost:70000", host, port, err), | ||
| 1472 | "rejects out-of-range port") ? 0 : 1; | ||
| 1473 | nFail += Check(!BtfParseSocks5Proxy("localhost:999999999999999999999999999999", host, port, err), | ||
| 1474 | "rejects overflowing port") ? 0 : 1; | ||
| 1475 | nFail += Check(!BtfParseSocks5Proxy("user:pass@localhost:9050", host, port, err), | ||
| 1476 | "rejects unsupported authenticated proxy syntax") ? 0 : 1; | ||
| 1477 | nFail += Check(BtfIsTorOnionHost("abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcd.onion") && | ||
| 1478 | BtfIsTorOnionHost("Relay.Example.ONION.") && | ||
| 1479 | !BtfIsTorOnionHost("relay.example") && | ||
| 1480 | !BtfIsTorOnionHost(".onion"), | ||
| 1481 | "detects .onion hosts without accepting lookalikes") ? 0 : 1; | ||
| 1482 | |||
| 1483 | BtfClearSocks5Proxy(); | ||
| 1484 | nFail += Check(!BtfSocks5ProxyEnabled(), "starts disabled after clear") ? 0 : 1; | ||
| 1485 | nFail += Check(BtfSetSocks5Proxy("127.0.0.1:9050", err) && | ||
| 1486 | BtfSocks5ProxyEnabled() && | ||
| 1487 | BtfSocks5ProxyName() == "127.0.0.1:9050", | ||
| 1488 | "enables parsed SOCKS5 proxy") ? 0 : 1; | ||
| 1489 | BtfClearSocks5Proxy(); | ||
| 1490 | nFail += Check(!BtfSocks5ProxyEnabled(), "clear disables proxy") ? 0 : 1; | ||
| 1491 | |||
| 1492 | nFail += Check(BtfSetSocks5Proxy("[::1]:9050", err) && | ||
| 1493 | BtfSocks5ProxyEnabled() && | ||
| 1494 | BtfSocks5ProxyName() == "[::1]:9050", | ||
| 1495 | "enables bracketed IPv6 proxy endpoint") ? 0 : 1; | ||
| 1496 | BtfClearSocks5Proxy(); | ||
| 1497 | |||
| 1498 | nFail += Check(BtfEnableTorProxy("", err) && | ||
| 1499 | BtfSocks5ProxyEnabled() && | ||
| 1500 | BtfTorProxyEnabled() && | ||
| 1501 | BtfSocks5ProxyName() == "127.0.0.1:9050", | ||
| 1502 | "enables Tor mode on the default local SOCKS5 endpoint") ? 0 : 1; | ||
| 1503 | BtfClearSocks5Proxy(); | ||
| 1504 | |||
| 1505 | nFail += Check(BtfEnableTorProxy("[::1]:9050", err) && | ||
| 1506 | BtfSocks5ProxyEnabled() && | ||
| 1507 | BtfTorProxyEnabled() && | ||
| 1508 | BtfSocks5ProxyName() == "[::1]:9050", | ||
| 1509 | "enables Tor mode on a custom IPv6 endpoint") ? 0 : 1; | ||
| 1510 | BtfClearSocks5Proxy(); | ||
| 1511 | |||
| 1512 | nFail += Check(BtfSetSocks5Proxy("127.0.0.1:9051", err) && | ||
| 1513 | BtfSocks5ProxyEnabled() && | ||
| 1514 | !BtfTorProxyEnabled(), | ||
| 1515 | "plain SOCKS5 mode is distinct from Tor mode") ? 0 : 1; | ||
| 1516 | BtfClearSocks5Proxy(); | ||
| 1517 | |||
| 1518 | std::string handshakeErr; | ||
| 1519 | bool fHandshakeOk = RunSocks5HandshakeProbe(handshakeErr); | ||
| 1520 | nFail += Check(fHandshakeOk, handshakeErr.empty() ? | ||
| 1521 | "completes SOCKS5 domain-name CONNECT handshake" : | ||
| 1522 | strprintf("completes SOCKS5 domain-name CONNECT handshake (%s)", | ||
| 1523 | handshakeErr.c_str()).c_str()) ? 0 : 1; | ||
| 1524 | |||
| 1525 | printf("%s (%d failure%s)\n", nFail == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", | ||
| 1526 | nFail, nFail == 1 ? "" : "s"); | ||
| 1527 | fflush(stdout); | ||
| 1528 | return nFail == 0 ? 0 : 1; | ||
| 1529 | } | ||
| 1530 | |||
| 1256 | int RunSelfTest(const std::string& name) | 1531 | int RunSelfTest(const std::string& name) |
| 1257 | { | 1532 | { |
| 1258 | AttachTerminal(); | 1533 | AttachTerminal(); |
| @@ -1275,8 +1550,10 @@ int RunSelfTest(const std::string& name) | |||
| 1275 | return RunPoolStratumSelfTest(); | 1550 | return RunPoolStratumSelfTest(); |
| 1276 | if (name == "parse-money") | 1551 | if (name == "parse-money") |
| 1277 | return RunParseMoneySelfTest(); | 1552 | return RunParseMoneySelfTest(); |
| 1553 | if (name == "socks5-proxy") | ||
| 1554 | return RunSocks5ProxySelfTest(); | ||
| 1278 | 1555 | ||
| 1279 | printf("Unknown self-test '%s'\n", name.c_str()); | 1556 | printf("Unknown self-test '%s'\n", name.c_str()); |
| 1280 | printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-crypto, wallet-encrypt, net-message, consensus-limits, pool-stratum\n"); | 1557 | printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-crypto, wallet-encrypt, net-message, consensus-limits, pool-stratum, parse-money, socks5-proxy\n"); |
| 1281 | return 1; | 1558 | return 1; |
| 1282 | } | 1559 | } |
diff --git a/tests/fixtures/chain-tools/explorer-blocks.json b/tests/fixtures/chain-tools/explorer-blocks.json new file mode 100644 index 0000000..795d6e4 --- /dev/null +++ b/tests/fixtures/chain-tools/explorer-blocks.json | |||
| @@ -0,0 +1 @@ | |||
| {"blocks":[{"cbAddr":"BDnXDAAvEameyQk12FrtvBiPBp9MsfRmUA","cbValue":5000000000,"hash":"7c0e819cec25ebe29452049f1b35479526ba679bc2c1b00407417109384dea76","height":2,"nTx":2,"size":331,"time":1753315440},{"cbAddr":"B61KYnjpfXHN2w2e1nR869HneK4P1Gz4va","cbValue":5000000000,"hash":"17bdb455d41592adbff4aa97b7fca2091fef69600e67306baca829ab5a588b30","height":1,"nTx":2,"size":331,"time":1753315320},{"cbAddr":"BDU4zj6apurZ7YWNxsndaTt75PMgzvwbN4","cbValue":5000000000,"hash":"5bd7cb255d814e48cebcdfb72da4dc87b34bd774227f8ceb546c5640f4bdc169","height":0,"nTx":1,"size":278,"time":1753315200}],"count":3,"network":"bitflash","tipHeight":2} | |||
diff --git a/tests/fixtures/chain-tools/fair-launch-report.json b/tests/fixtures/chain-tools/fair-launch-report.json new file mode 100644 index 0000000..d35f30b --- /dev/null +++ b/tests/fixtures/chain-tools/fair-launch-report.json | |||
| @@ -0,0 +1 @@ | |||
| {"attestation_payload_sha256":"bf192bdbbb84c69d15bb68cf133abd474d0e1021600febc68e9f1a9de844b2af","chain":{"coin":100000000,"message_start_hex":"bf205cfd","name":"Bitflash","ticker":"BTF"},"checks":{"computed_merkle_matches_header":true,"genesis_has_single_transaction":true,"genesis_hash_matches_source":true,"genesis_merkle_matches_source":true,"genesis_supply_is_50_btf":true,"genesis_transaction_is_coinbase":true},"coinbase_outputs":[{"block_hash":"5bd7cb255d814e48cebcdfb72da4dc87b34bd774227f8ceb546c5640f4bdc169","coinbase_txid":"1a45b4482532abb29b10e234d3f13132230525a339ecea91658ffa675a5b1325","height":0,"outputs":[{"script_pub_key_hex":"4104678afdb0fe5548271967f1a67130b7105cd6a828e03909a67962e0ea1f61deb649f6bc3f4cef38c4f35504e51ec112de5c384df7ba0b8d578a4c702b6bf11d5fac","value_btf":"50.00000000","value_satoshis":5000000000}],"value_btf":"50.00000000","value_satoshis":5000000000},{"block_hash":"17bdb455d41592adbff4aa97b7fca2091fef69600e67306baca829ab5a588b30","coinbase_txid":"d6967c16013f1da136183f962aefd2f15e59b6836043f8ef49aadb92f38dca05","height":1,"outputs":[{"script_pub_key_hex":"76a914111111111111111111111111111111111111111188ac","value_btf":"50.00000000","value_satoshis":5000000000}],"value_btf":"50.00000000","value_satoshis":5000000000},{"block_hash":"7c0e819cec25ebe29452049f1b35479526ba679bc2c1b00407417109384dea76","coinbase_txid":"d55af2c80e8b70a9ae8f362a8aac5448e7925267c7f737d1fa0d227fd472a657","height":2,"outputs":[{"script_pub_key_hex":"76a914666666666666666666666666666666666666666688ac","value_btf":"50.00000000","value_satoshis":5000000000}],"value_btf":"50.00000000","value_satoshis":5000000000}],"deterministic":true,"external_timestamping":{"instructions":["Save the canonical JSON report.","Run: ots stamp bitflash-fair-launch-report.json","Later verify with: ots verify bitflash-fair-launch-report.json.ots"],"recommended":"OpenTimestamps","status":"not_embedded"},"genesis":{"bits":"0x1f0fffff","coinbase_txid":"1a45b4482532abb29b10e234d3f13132230525a339ecea91658ffa675a5b1325","computed_merkle_root":"1a45b4482532abb29b10e234d3f13132230525a339ecea91658ffa675a5b1325","hash":"5bd7cb255d814e48cebcdfb72da4dc87b34bd774227f8ceb546c5640f4bdc169","merkle_root":"1a45b4482532abb29b10e234d3f13132230525a339ecea91658ffa675a5b1325","nonce":3141,"outputs":[{"script_pub_key_hex":"4104678afdb0fe5548271967f1a67130b7105cd6a828e03909a67962e0ea1f61deb649f6bc3f4cef38c4f35504e51ec112de5c384df7ba0b8d578a4c702b6bf11d5fac","value_btf":"50.00000000","value_satoshis":5000000000}],"time":1753315200,"time_utc":"2025-07-24T00:00:00Z","tx_count":1},"scanned":{"block_count":3,"first_height":0,"last_height":2,"non_coinbase_transactions":2,"total_coinbase_btf":"150.00000000","total_coinbase_satoshis":15000000000},"schema":"bitflash-fair-launch-report-1","source_constants":{"genesis_bits":"0x1f0fffff","genesis_hash":"5bd7cb255d814e48cebcdfb72da4dc87b34bd774227f8ceb546c5640f4bdc169","genesis_merkle_root":"1a45b4482532abb29b10e234d3f13132230525a339ecea91658ffa675a5b1325","genesis_message":"Bitflash 24/Jul/2026 Fair launch: one CPU one vote, no premine","genesis_nonce":3141,"genesis_time":1753315200,"genesis_time_utc":"2025-07-24T00:00:00Z"},"source_mode":"local-block-files","verdict":"FAIR_LAUNCH_BASELINE_VERIFIED"} | |||
diff --git a/tests/fixtures/chain-tools/utxo-proof-verification.json b/tests/fixtures/chain-tools/utxo-proof-verification.json new file mode 100644 index 0000000..a40c673 --- /dev/null +++ b/tests/fixtures/chain-tools/utxo-proof-verification.json | |||
| @@ -0,0 +1 @@ | |||
| {"checks":{"leaf_hash_matches_target":true,"root_matches":true,"schema_is_supported":true},"commitment":{"computed_root":"6e49b47ffccb178b62402235343ae7c55ec4a03effe19debb8c057e8ffc5b3c0","expected_root":"6e49b47ffccb178b62402235343ae7c55ec4a03effe19debb8c057e8ffc5b3c0","leaf_hash":"da964462812e40c918d6349baa57aee1dc838416eca44ccd31b782f0d1542d7b","path_length":3},"deterministic":true,"failures":[],"schema":"bitflash-utxo-proof-verification-1","target":{"created_height":2,"outpoint":"218609b2969dc0b6ae1c378ae255bbed863902987df4c943200e3c291b0e4c2a:1","value_btf":"17.50000000","value_satoshis":1750000000},"verdict":"UTXO_PROOF_VERIFIED"} | |||
diff --git a/tests/fixtures/chain-tools/utxo-proof.json b/tests/fixtures/chain-tools/utxo-proof.json new file mode 100644 index 0000000..154c45b --- /dev/null +++ b/tests/fixtures/chain-tools/utxo-proof.json | |||
| @@ -0,0 +1 @@ | |||
| {"chain":{"coin":100000000,"message_start_hex":"bf205cfd","name":"Bitflash","ticker":"BTF"},"commitment":{"algorithm":"sorted-outpoint-merkle-sha256","leaf_count":5,"leaf_domain":"BTFUTXO1","node_domain":"BTFNODE1","root":"6e49b47ffccb178b62402235343ae7c55ec4a03effe19debb8c057e8ffc5b3c0"},"deterministic":true,"proof":[{"duplicated":false,"hash":"7a4f4f13aaf91f8b6b32d1fb22feaa3d0398782504915e9c156e60fdeca1255d","level":0,"side":"left"},{"duplicated":false,"hash":"5f91d5ba472b7d6ab96e9ce99d689896dad3c264d3abbce4c77f257687608943","level":1,"side":"right"},{"duplicated":false,"hash":"f041ba6a82fbc6759f0632f412dd8aab971ac1317358d6f204af3e57da4d12de","level":2,"side":"right"}],"scanned":{"first_height":0,"last_height":2,"main_chain_block_count":3,"raw_block_count":3,"side_blocks_ignored":0,"tip_hash":"7c0e819cec25ebe29452049f1b35479526ba679bc2c1b00407417109384dea76","tip_time":1753315440,"tip_time_utc":"2025-07-24T00:04:00Z"},"schema":"bitflash-utxo-inclusion-proof-1","target":{"leaf_hash":"da964462812e40c918d6349baa57aee1dc838416eca44ccd31b782f0d1542d7b","leaf_index":1,"outpoint":"218609b2969dc0b6ae1c378ae255bbed863902987df4c943200e3c291b0e4c2a:1","txid":"218609b2969dc0b6ae1c378ae255bbed863902987df4c943200e3c291b0e4c2a","utxo":{"coinbase":false,"created_block_hash":"7c0e819cec25ebe29452049f1b35479526ba679bc2c1b00407417109384dea76","created_height":2,"created_txid":"218609b2969dc0b6ae1c378ae255bbed863902987df4c943200e3c291b0e4c2a","script_pub_key_hex":"76a914555555555555555555555555555555555555555588ac","txid":"218609b2969dc0b6ae1c378ae255bbed863902987df4c943200e3c291b0e4c2a","value_btf":"17.50000000","value_satoshis":1750000000,"vout":1},"vout":1},"verdict":"UTXO_INCLUSION_PROOF_CREATED","warnings":{"legacy_duplicate_output_overwrites":0}} | |||
diff --git a/tests/fixtures/chain-tools/utxo-report.json b/tests/fixtures/chain-tools/utxo-report.json new file mode 100644 index 0000000..c6e98ee --- /dev/null +++ b/tests/fixtures/chain-tools/utxo-report.json | |||
| @@ -0,0 +1 @@ | |||
| {"chain":{"coin":100000000,"message_start_hex":"bf205cfd","name":"Bitflash","ticker":"BTF"},"checks":{"all_scanned_merkle_roots_match":true,"genesis_hash_matches_source":true,"genesis_merkle_matches_source":true,"no_missing_spends":true},"commitment":{"algorithm":"sorted-outpoint-merkle-sha256","leaf_domain":"BTFUTXO1","node_domain":"BTFNODE1","root":"6e49b47ffccb178b62402235343ae7c55ec4a03effe19debb8c057e8ffc5b3c0"},"deterministic":true,"scanned":{"first_height":0,"last_height":2,"main_chain_block_count":3,"raw_block_count":3,"side_blocks_ignored":0,"tip_hash":"7c0e819cec25ebe29452049f1b35479526ba679bc2c1b00407417109384dea76","tip_time":1753315440,"tip_time_utc":"2025-07-24T00:04:00Z"},"schema":"bitflash-utxo-set-commitment-1","stats":{"coinbase_minus_unspent_btf":"1.50000000","coinbase_minus_unspent_satoshis":150000000,"duplicate_outputs":[],"missing_spends":[],"normal_transactions":2,"spent_outputs":8000000000,"total_coinbase_btf":"150.00000000","total_coinbase_satoshis":15000000000,"total_fees_btf":"1.50000000","total_fees_satoshis":150000000,"total_unspent_btf":"148.50000000","total_unspent_satoshis":14850000000,"utxo_count":5},"utxos":{"218609b2969dc0b6ae1c378ae255bbed863902987df4c943200e3c291b0e4c2a:0":{"coinbase":false,"created_block_hash":"7c0e819cec25ebe29452049f1b35479526ba679bc2c1b00407417109384dea76","created_height":2,"created_txid":"218609b2969dc0b6ae1c378ae255bbed863902987df4c943200e3c291b0e4c2a","script_pub_key_hex":"76a914444444444444444444444444444444444444444488ac","txid":"218609b2969dc0b6ae1c378ae255bbed863902987df4c943200e3c291b0e4c2a","value_btf":"12.00000000","value_satoshis":1200000000,"vout":0},"218609b2969dc0b6ae1c378ae255bbed863902987df4c943200e3c291b0e4c2a:1":{"coinbase":false,"created_block_hash":"7c0e819cec25ebe29452049f1b35479526ba679bc2c1b00407417109384dea76","created_height":2,"created_txid":"218609b2969dc0b6ae1c378ae255bbed863902987df4c943200e3c291b0e4c2a","script_pub_key_hex":"76a914555555555555555555555555555555555555555588ac","txid":"218609b2969dc0b6ae1c378ae255bbed863902987df4c943200e3c291b0e4c2a","value_btf":"17.50000000","value_satoshis":1750000000,"vout":1},"24bddfded9e22c2f98d9369e9ac00a4eccc8dcf05a2d1b9e12d94fd9f62ababc:1":{"coinbase":false,"created_block_hash":"17bdb455d41592adbff4aa97b7fca2091fef69600e67306baca829ab5a588b30","created_height":1,"created_txid":"24bddfded9e22c2f98d9369e9ac00a4eccc8dcf05a2d1b9e12d94fd9f62ababc","script_pub_key_hex":"76a914333333333333333333333333333333333333333388ac","txid":"24bddfded9e22c2f98d9369e9ac00a4eccc8dcf05a2d1b9e12d94fd9f62ababc","value_btf":"19.00000000","value_satoshis":1900000000,"vout":1},"d55af2c80e8b70a9ae8f362a8aac5448e7925267c7f737d1fa0d227fd472a657:0":{"coinbase":true,"created_block_hash":"7c0e819cec25ebe29452049f1b35479526ba679bc2c1b00407417109384dea76","created_height":2,"created_txid":"d55af2c80e8b70a9ae8f362a8aac5448e7925267c7f737d1fa0d227fd472a657","script_pub_key_hex":"76a914666666666666666666666666666666666666666688ac","txid":"d55af2c80e8b70a9ae8f362a8aac5448e7925267c7f737d1fa0d227fd472a657","value_btf":"50.00000000","value_satoshis":5000000000,"vout":0},"d6967c16013f1da136183f962aefd2f15e59b6836043f8ef49aadb92f38dca05:0":{"coinbase":true,"created_block_hash":"17bdb455d41592adbff4aa97b7fca2091fef69600e67306baca829ab5a588b30","created_height":1,"created_txid":"d6967c16013f1da136183f962aefd2f15e59b6836043f8ef49aadb92f38dca05","script_pub_key_hex":"76a914111111111111111111111111111111111111111188ac","txid":"d6967c16013f1da136183f962aefd2f15e59b6836043f8ef49aadb92f38dca05","value_btf":"50.00000000","value_satoshis":5000000000,"vout":0}},"verdict":"UTXO_COMMITMENT_VERIFIED","warnings":{"legacy_duplicate_output_overwrites":0}} | |||
diff --git a/tests/scripts/test_bitflash_tools.py b/tests/scripts/test_bitflash_tools.py new file mode 100644 index 0000000..4b18d1e --- /dev/null +++ b/tests/scripts/test_bitflash_tools.py | |||
| @@ -0,0 +1,283 @@ | |||
| 1 | #!/usr/bin/env python3 | ||
| 2 | """Regression tests for deterministic Bitflash Python tooling.""" | ||
| 3 | |||
| 4 | import json | ||
| 5 | import os | ||
| 6 | import struct | ||
| 7 | import subprocess | ||
| 8 | import sys | ||
| 9 | import tempfile | ||
| 10 | from pathlib import Path | ||
| 11 | |||
| 12 | |||
| 13 | ROOT = Path(__file__).resolve().parents[2] | ||
| 14 | SCRIPTS = ROOT / "scripts" | ||
| 15 | FIXTURES = ROOT / "tests" / "fixtures" / "chain-tools" | ||
| 16 | sys.path.insert(0, str(SCRIPTS)) | ||
| 17 | |||
| 18 | import bitflash_chain as chain # noqa: E402 | ||
| 19 | |||
| 20 | |||
| 21 | def run(args, **kwargs): | ||
| 22 | env = dict(os.environ) | ||
| 23 | env["PYTHONIOENCODING"] = "utf-8" | ||
| 24 | return subprocess.run( | ||
| 25 | [sys.executable] + [str(a) for a in args], | ||
| 26 | cwd=str(ROOT), | ||
| 27 | env=env, | ||
| 28 | check=True, | ||
| 29 | text=True, | ||
| 30 | stdout=subprocess.PIPE, | ||
| 31 | stderr=subprocess.PIPE, | ||
| 32 | **kwargs | ||
| 33 | ) | ||
| 34 | |||
| 35 | |||
| 36 | def write_block_file(path, blocks): | ||
| 37 | with Path(path).open("wb") as f: | ||
| 38 | f.write(b"noise") | ||
| 39 | f.write(chain.MAGIC) | ||
| 40 | f.write(struct.pack("<I", chain.MAX_BLOCK_PAYLOAD + 1)) | ||
| 41 | f.write(b"still-noise") | ||
| 42 | for block in blocks: | ||
| 43 | payload = chain.ser_block(block) | ||
| 44 | f.write(chain.MAGIC) | ||
| 45 | f.write(struct.pack("<I", len(payload))) | ||
| 46 | f.write(payload) | ||
| 47 | |||
| 48 | |||
| 49 | def p2pkh_script(byte): | ||
| 50 | return "76a914%s88ac" % (bytes([byte]) * 20).hex() | ||
| 51 | |||
| 52 | |||
| 53 | def make_tx(vin, vout): | ||
| 54 | tx = { | ||
| 55 | "version": 1, | ||
| 56 | "vin": vin, | ||
| 57 | "vout": vout, | ||
| 58 | "lock_time": 0, | ||
| 59 | } | ||
| 60 | tx["txid"] = chain.hash_hex(chain.ser_tx(tx)) | ||
| 61 | return tx | ||
| 62 | |||
| 63 | |||
| 64 | def make_coinbase(label, script_byte, value=50 * chain.COIN): | ||
| 65 | return make_tx( | ||
| 66 | [{ | ||
| 67 | "prevout_hash": "0" * 64, | ||
| 68 | "prevout_n": 0xFFFFFFFF, | ||
| 69 | "script_sig_hex": label.encode("ascii").hex(), | ||
| 70 | "sequence": 0xFFFFFFFF, | ||
| 71 | }], | ||
| 72 | [{ | ||
| 73 | "value_satoshis": value, | ||
| 74 | "value_btf": chain.format_money(value), | ||
| 75 | "script_pub_key_hex": p2pkh_script(script_byte), | ||
| 76 | }], | ||
| 77 | ) | ||
| 78 | |||
| 79 | |||
| 80 | def make_spend(prev_txid, prev_n, outputs, label): | ||
| 81 | return make_tx( | ||
| 82 | [{ | ||
| 83 | "prevout_hash": prev_txid, | ||
| 84 | "prevout_n": prev_n, | ||
| 85 | "script_sig_hex": label.encode("ascii").hex(), | ||
| 86 | "sequence": 0xFFFFFFFF, | ||
| 87 | }], | ||
| 88 | [{ | ||
| 89 | "value_satoshis": value, | ||
| 90 | "value_btf": chain.format_money(value), | ||
| 91 | "script_pub_key_hex": p2pkh_script(script_byte), | ||
| 92 | } for value, script_byte in outputs], | ||
| 93 | ) | ||
| 94 | |||
| 95 | |||
| 96 | def make_block(prev_hash, txs, ntime, nonce): | ||
| 97 | merkle = chain.merkle_root([tx["txid"] for tx in txs]) | ||
| 98 | header = ( | ||
| 99 | struct.pack("<i", 1) | ||
| 100 | + bytes.fromhex(prev_hash)[::-1] | ||
| 101 | + bytes.fromhex(merkle)[::-1] | ||
| 102 | + struct.pack("<III", ntime, chain.GENESIS_BITS, nonce) | ||
| 103 | ) | ||
| 104 | block = { | ||
| 105 | "hash": chain.hash_hex(header), | ||
| 106 | "version": 1, | ||
| 107 | "previous_hash": prev_hash, | ||
| 108 | "merkle_root": merkle, | ||
| 109 | "computed_merkle_root": merkle, | ||
| 110 | "time": ntime, | ||
| 111 | "time_utc": chain.utc(ntime), | ||
| 112 | "bits": "0x%08x" % chain.GENESIS_BITS, | ||
| 113 | "nonce": nonce, | ||
| 114 | "tx_count": len(txs), | ||
| 115 | "transactions": txs, | ||
| 116 | "source_file": "synthetic", | ||
| 117 | "source_offset": 0, | ||
| 118 | "size": 80 + len(chain.ser_compact_size(len(txs))) + sum(len(chain.ser_tx(tx)) for tx in txs), | ||
| 119 | } | ||
| 120 | return block | ||
| 121 | |||
| 122 | |||
| 123 | def fixture_chain(): | ||
| 124 | genesis = chain.reconstructed_genesis() | ||
| 125 | spend1 = make_spend( | ||
| 126 | chain.GENESIS_MERKLE, | ||
| 127 | 0, | ||
| 128 | [(30 * chain.COIN, 0x22), (19 * chain.COIN, 0x33)], | ||
| 129 | "spend genesis with 1 BTF fee", | ||
| 130 | ) | ||
| 131 | block1 = make_block( | ||
| 132 | genesis["hash"], | ||
| 133 | [make_coinbase("fixture coinbase 1", 0x11), spend1], | ||
| 134 | chain.GENESIS_TIME + 120, | ||
| 135 | 4001, | ||
| 136 | ) | ||
| 137 | spend2 = make_spend( | ||
| 138 | spend1["txid"], | ||
| 139 | 0, | ||
| 140 | [(12 * chain.COIN, 0x44), (17 * chain.COIN + 50_000_000, 0x55)], | ||
| 141 | "split spend with 0.5 BTF fee", | ||
| 142 | ) | ||
| 143 | block2 = make_block( | ||
| 144 | block1["hash"], | ||
| 145 | [make_coinbase("fixture coinbase 2", 0x66), spend2], | ||
| 146 | chain.GENESIS_TIME + 240, | ||
| 147 | 4002, | ||
| 148 | ) | ||
| 149 | return [genesis, block1, block2], { | ||
| 150 | "genesis_outpoint": "%s:0" % chain.GENESIS_MERKLE, | ||
| 151 | "first_spend_txid": spend1["txid"], | ||
| 152 | "proof_outpoint": "%s:1" % spend2["txid"], | ||
| 153 | } | ||
| 154 | |||
| 155 | |||
| 156 | def compare_golden(name, actual, update): | ||
| 157 | path = FIXTURES / name | ||
| 158 | if update: | ||
| 159 | path.parent.mkdir(parents=True, exist_ok=True) | ||
| 160 | path.write_bytes(actual) | ||
| 161 | return | ||
| 162 | expected = path.read_bytes() | ||
| 163 | if actual != expected: | ||
| 164 | raise AssertionError("golden mismatch: %s" % name) | ||
| 165 | |||
| 166 | |||
| 167 | def assert_true(cond, msg): | ||
| 168 | if not cond: | ||
| 169 | raise AssertionError(msg) | ||
| 170 | |||
| 171 | |||
| 172 | def run_genesis_smoke(): | ||
| 173 | genesis = chain.reconstructed_genesis() | ||
| 174 | outpoint = "%s:0" % chain.GENESIS_MERKLE | ||
| 175 | |||
| 176 | with tempfile.TemporaryDirectory(prefix="bitflash-tool-tests-") as tmp: | ||
| 177 | tmp = Path(tmp) | ||
| 178 | datadir = tmp / "datadir" | ||
| 179 | datadir.mkdir() | ||
| 180 | blk1 = datadir / "blk0001.dat" | ||
| 181 | blk2 = datadir / "blk0002.dat" | ||
| 182 | write_block_file(blk1, [genesis]) | ||
| 183 | blk2.write_bytes(b"") | ||
| 184 | |||
| 185 | fair = run([SCRIPTS / "verify-fair-launch.py", "--datadir", datadir, | ||
| 186 | "--max-blocks", "1", "--json"]).stdout | ||
| 187 | fair_report = json.loads(fair) | ||
| 188 | assert_true(fair_report["verdict"] == "FAIR_LAUNCH_BASELINE_VERIFIED", | ||
| 189 | "fair launch verifier failed synthetic genesis") | ||
| 190 | |||
| 191 | utxo = run([SCRIPTS / "verify-utxo-set.py", "--datadir", datadir, | ||
| 192 | "--json"]).stdout | ||
| 193 | utxo_report = json.loads(utxo) | ||
| 194 | assert_true(utxo_report["verdict"] == "UTXO_COMMITMENT_VERIFIED", | ||
| 195 | "UTXO verifier failed synthetic genesis") | ||
| 196 | |||
| 197 | proof_path = tmp / "proof.json" | ||
| 198 | run([SCRIPTS / "prove-utxo.py", outpoint, "--datadir", datadir, | ||
| 199 | "--out", proof_path, "--json"]) | ||
| 200 | proof_check = run([SCRIPTS / "verify-utxo-proof.py", proof_path, | ||
| 201 | "--root", utxo_report["commitment"]["root"], "--json"]).stdout | ||
| 202 | proof_report = json.loads(proof_check) | ||
| 203 | assert_true(proof_report["verdict"] == "UTXO_PROOF_VERIFIED", | ||
| 204 | "UTXO proof verifier rejected synthetic proof") | ||
| 205 | |||
| 206 | explorer_dir = tmp / "explorer" | ||
| 207 | run([SCRIPTS / "build-explorer.py", blk1, blk2, explorer_dir]) | ||
| 208 | blocks = json.loads((explorer_dir / "blocks.json").read_text(encoding="ascii")) | ||
| 209 | assert_true(blocks["tipHeight"] == 0 and blocks["count"] == 1, | ||
| 210 | "explorer did not build the synthetic chain") | ||
| 211 | index_html = (explorer_dir / "index.html").read_text(encoding="utf-8") | ||
| 212 | assert_true("innerHTML" not in index_html, "explorer viewer still uses innerHTML") | ||
| 213 | |||
| 214 | try: | ||
| 215 | chain.apply_blocks([genesis, genesis], strict_duplicates=True) | ||
| 216 | except chain.ParseError: | ||
| 217 | pass | ||
| 218 | else: | ||
| 219 | raise AssertionError("strict duplicate output mode did not fail") | ||
| 220 | |||
| 221 | |||
| 222 | def run_golden_fixture(update): | ||
| 223 | blocks, meta = fixture_chain() | ||
| 224 | |||
| 225 | with tempfile.TemporaryDirectory(prefix="bitflash-tool-golden-") as tmp: | ||
| 226 | tmp = Path(tmp) | ||
| 227 | datadir = tmp / "datadir" | ||
| 228 | datadir.mkdir() | ||
| 229 | blk1 = datadir / "blk0001.dat" | ||
| 230 | blk2 = datadir / "blk0002.dat" | ||
| 231 | write_block_file(blk1, blocks[:2]) | ||
| 232 | write_block_file(blk2, blocks[2:]) | ||
| 233 | |||
| 234 | fair = run([SCRIPTS / "verify-fair-launch.py", "--datadir", datadir, | ||
| 235 | "--max-blocks", "3", "--json"]).stdout.encode("ascii") | ||
| 236 | compare_golden("fair-launch-report.json", fair, update) | ||
| 237 | fair_report = json.loads(fair) | ||
| 238 | assert_true(fair_report["scanned"]["non_coinbase_transactions"] == 2, | ||
| 239 | "fair-launch fixture did not count normal transactions") | ||
| 240 | |||
| 241 | utxo = run([SCRIPTS / "verify-utxo-set.py", "--datadir", datadir, | ||
| 242 | "--include-utxos", "--strict-duplicates", "--json"]).stdout.encode("ascii") | ||
| 243 | compare_golden("utxo-report.json", utxo, update) | ||
| 244 | utxo_report = json.loads(utxo) | ||
| 245 | assert_true(utxo_report["stats"]["normal_transactions"] == 2, | ||
| 246 | "UTXO fixture did not apply normal transactions") | ||
| 247 | assert_true(utxo_report["stats"]["total_fees_btf"] == "1.50000000", | ||
| 248 | "UTXO fixture fee total changed") | ||
| 249 | assert_true(meta["genesis_outpoint"] not in utxo_report["utxos"], | ||
| 250 | "spent genesis output still appears in the UTXO map") | ||
| 251 | |||
| 252 | proof_path = tmp / "fixture-proof.json" | ||
| 253 | proof = run([SCRIPTS / "prove-utxo.py", meta["proof_outpoint"], | ||
| 254 | "--datadir", datadir, "--strict-duplicates", | ||
| 255 | "--out", proof_path, "--json"]).stdout.encode("ascii") | ||
| 256 | compare_golden("utxo-proof.json", proof, update) | ||
| 257 | |||
| 258 | proof_check = run([SCRIPTS / "verify-utxo-proof.py", proof_path, | ||
| 259 | "--root", utxo_report["commitment"]["root"], "--json"]).stdout.encode("ascii") | ||
| 260 | compare_golden("utxo-proof-verification.json", proof_check, update) | ||
| 261 | proof_report = json.loads(proof_check) | ||
| 262 | assert_true(proof_report["verdict"] == "UTXO_PROOF_VERIFIED", | ||
| 263 | "fixture UTXO proof did not verify") | ||
| 264 | |||
| 265 | explorer_dir = tmp / "explorer" | ||
| 266 | run([SCRIPTS / "build-explorer.py", blk1, blk2, explorer_dir]) | ||
| 267 | compare_golden("explorer-blocks.json", | ||
| 268 | (explorer_dir / "blocks.json").read_bytes(), update) | ||
| 269 | detail = json.loads((explorer_dir / "block" / "1.json").read_text(encoding="ascii")) | ||
| 270 | assert_true(len(detail["txs"]) == 2, | ||
| 271 | "explorer fixture block did not include the normal transaction") | ||
| 272 | |||
| 273 | |||
| 274 | def main(): | ||
| 275 | update = "--update-goldens" in sys.argv[1:] | ||
| 276 | run_genesis_smoke() | ||
| 277 | run_golden_fixture(update) | ||
| 278 | print("Bitflash script tool regressions passed") | ||
| 279 | return 0 | ||
| 280 | |||
| 281 | |||
| 282 | if __name__ == "__main__": | ||
| 283 | raise SystemExit(main()) | ||