upleb.uk

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

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/ci.yml3
-rw-r--r--Makefile2
-rw-r--r--README.md23
-rw-r--r--docs/fair-launch.md3
-rw-r--r--docs/social.pngbin208957 -> 162267 bytes
-rw-r--r--docs/tor.md84
-rw-r--r--docs/utxo-commitment.md20
-rw-r--r--scripts/bitflash_chain.py450
-rwxr-xr-xscripts/build-explorer.py385
-rw-r--r--scripts/prove-utxo.py50
-rw-r--r--scripts/verify-fair-launch.py346
-rw-r--r--scripts/verify-utxo-proof.py29
-rw-r--r--scripts/verify-utxo-set.py375
-rw-r--r--src/Makefile7
-rw-r--r--src/btfrv.cpp9
-rw-r--r--src/main_gui.cpp36
-rw-r--r--src/makefile.mingw10
-rw-r--r--src/net.cpp4
-rw-r--r--src/nostr.cpp50
-rw-r--r--src/proxy.cpp377
-rw-r--r--src/proxy.h34
-rw-r--r--src/readme.txt7
-rw-r--r--src/selftest.cpp279
-rw-r--r--tests/fixtures/chain-tools/explorer-blocks.json1
-rw-r--r--tests/fixtures/chain-tools/fair-launch-report.json1
-rw-r--r--tests/fixtures/chain-tools/utxo-proof-verification.json1
-rw-r--r--tests/fixtures/chain-tools/utxo-proof.json1
-rw-r--r--tests/fixtures/chain-tools/utxo-report.json1
-rw-r--r--tests/scripts/test_bitflash_tools.py283
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
diff --git a/Makefile b/Makefile
index 1d9965a..6cea1a8 100644
--- a/Makefile
+++ b/Makefile
@@ -10,7 +10,7 @@
10ROOT := $(shell pwd) 10ROOT := $(shell pwd)
11NPROC := $(shell nproc 2>/dev/null || echo 2) 11NPROC := $(shell nproc 2>/dev/null || echo 2)
12SUDO := $(shell [ "$$(id -u)" = "0" ] && echo "" || echo "sudo") 12SUDO := $(shell [ "$$(id -u)" = "0" ] && echo "" || echo "sudo")
13VERSION := 1.2.16 13VERSION := 1.2.17
14 14
15SECP256K1_REPO := https://github.com/bitcoin-core/secp256k1 15SECP256K1_REPO := https://github.com/bitcoin-core/secp256k1
16SECP256K1_COMMIT := 7fecac74aed8e1fd9078380d67dd04663705c989 16SECP256K1_COMMIT := 7fecac74aed8e1fd9078380d67dd04663705c989
diff --git a/README.md b/README.md
index f7d9468..ef4f39b 100644
--- a/README.md
+++ b/README.md
@@ -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
39This is unlinkability between peers, not anonymity against a network observer. It is not Tor. 39This is unlinkability between peers, not anonymity against a network observer.
40For 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
104or timestamped externally with OpenTimestamps. See 105or timestamped externally with OpenTimestamps. See
105[fair-launch verification](docs/fair-launch.md). 106[fair-launch verification](docs/fair-launch.md).
106 107
108For a static local block explorer, generate HTML and JSON from either a data
109directory or explicit block files:
110
111```bash
112python3 scripts/build-explorer.py --datadir ~/.bitflash ./explorer-out
113python3 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
108releases — 1.2.1 fixed a bug that let anyone spend anyone's coins, and 1.2.2 117releases — 1.2.1 fixed a bug that let anyone spend anyone's coins, and 1.2.2
109added a per-block signature-operation cap. A node on an older build will accept 118added 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:
318needed — the data directory takes an exclusive lock, so a second node pointed at 329needed — the data directory takes an exclusive lock, so a second node pointed at
319the same one will refuse to start. 330the same one will refuse to start.
320 331
332`-socks=127.0.0.1:9050` routes outbound Nostr discovery and `.btf` rendezvous
333dials through a local SOCKS5 proxy such as Tor. When it is set, the node skips
334plain HTTP external-IP probes instead of leaking a direct request outside the
335proxy. 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
339dials through Tor, refuses direct `.onion` resolution, and keeps external-IP
340probes 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
9The verifier reads `blk*.dat` directly from the local Bitflash data directory. 9The verifier reads `blk*.dat` directly from the local Bitflash data directory.
10It does not use Berkeley DB, does not call the node binary, and does not need 10It does not use Berkeley DB, does not call the node binary, and does not need
11network access. 11network access. It uses the same shared Python chain parser as the UTXO
12commitment and proof tools, so block serialization fixes land in one place.
12 13
13The report proves the launch baseline that can be checked from the chain: 14The 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
3Bitflash can route outbound discovery traffic through a local Tor SOCKS5 proxy.
4This is transport plumbing only: it does not change consensus, wallet keys,
5mining, `.btf` addresses, or the encrypted rendezvous protocol.
6
7## Client mode
8
9Start Tor locally, then start Bitflash with:
10
11```bash
12bitflash -tor
13```
14
15`-tor` is shorthand for:
16
17```bash
18bitflash -socks=127.0.0.1:9050
19```
20
21If Tor listens somewhere else:
22
23```bash
24bitflash -tor=127.0.0.1:9150
25bitflash -tor=[::1]:9050
26```
27
28When 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
39An operator can expose a Bitflash rendezvous relay as a Tor hidden service. The
40node does not create hidden services itself; Tor owns that configuration.
41
42Example `torrc`:
43
44```text
45HiddenServiceDir /var/lib/tor/bitflash-rendezvous/
46HiddenServicePort 8434 127.0.0.1:8434
47```
48
49After restarting Tor, read the generated onion name:
50
51```bash
52sudo cat /var/lib/tor/bitflash-rendezvous/hostname
53```
54
55Then run the relay on localhost/VPS as usual and announce the onion endpoint from
56nodes that use Tor:
57
58```bash
59bitflash -tor -rvrelay=exampleexampleexampleexampleexampleexampleexampleexample.onion:8434
60bitflash -tor -announcerelay=exampleexampleexampleexampleexampleexampleexampleexample.onion:8434
61```
62
63For a pool operator that wants the pool descriptor to point at an onion
64rendezvous relay, use both:
65
66```bash
67bitflash -operator -tor \
68 -rvrelay=exampleexampleexampleexampleexampleexampleexampleexample.onion:8434 \
69 -announcerelay=exampleexampleexampleexampleexampleexampleexampleexample.onion:8434
70```
71
72## Full-node hidden service
73
74The legacy P2P listener still binds to port `8433`. You can expose it through
75Tor manually:
76
77```text
78HiddenServiceDir /var/lib/tor/bitflash-node/
79HiddenServicePort 8433 127.0.0.1:8433
80```
81
82Current automatic peer discovery is `.btf`/rendezvous based, not onion-address
83gossip. Treat direct full-node onion access as an operator/admin tool unless a
84future 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
22The verifier: 22The 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.
98Those warnings are part of the report so auditors can see the historical debt 99Those warnings are part of the report so auditors can see the historical debt
99instead of relying on a polished summary. 100instead of relying on a polished summary.
100 101
102For synthetic chains or future clean consensus ranges, auditors can make
103duplicates a hard failure:
104
105```bash
106python3 scripts/verify-utxo-set.py --strict-duplicates
107```
108
109The default remains compatible with the current historical chain because it has
110known legacy duplicate-output overwrites.
111
112## Shared Parser
113
114The audit scripts share `scripts/bitflash_chain.py` for block parsing,
115main-chain selection, UTXO application, canonical JSON, and genesis
116reconstruction. 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
103This tool is the low-risk first step toward consensus commitments. A future 121This 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
4import datetime as _dt
5import hashlib
6import json
7import mmap
8import os
9import struct
10from pathlib import Path
11
12
13MAGIC = bytes([0xBF, 0x20, 0x5C, 0xFD])
14MAX_BLOCK_PAYLOAD = 32 * 1024 * 1024
15UTXO_LEAF_DOMAIN = "BTFUTXO1"
16UTXO_NODE_DOMAIN_TAG = "BTFNODE1"
17UTXO_NODE_DOMAIN = (UTXO_NODE_DOMAIN_TAG + "|").encode("ascii")
18COIN = 100_000_000
19GENESIS_HASH = "5bd7cb255d814e48cebcdfb72da4dc87b34bd774227f8ceb546c5640f4bdc169"
20GENESIS_MERKLE = "1a45b4482532abb29b10e234d3f13132230525a339ecea91658ffa675a5b1325"
21GENESIS_TIME = 1_753_315_200
22GENESIS_BITS = 0x1F0FFFFF
23GENESIS_NONCE = 3141
24GENESIS_TEXT = "Bitflash 24/Jul/2026 Fair launch: one CPU one vote, no premine"
25
26
27class ParseError(Exception):
28 pass
29
30
31class Reader:
32 def __init__(self, data):
33 self.data = data
34 self.pos = 0
35
36 def take(self, n):
37 if self.pos + n > len(self.data):
38 raise ParseError("unexpected end of data")
39 out = self.data[self.pos:self.pos + n]
40 self.pos += n
41 return out
42
43 def u8(self):
44 return self.take(1)[0]
45
46 def u32(self):
47 return struct.unpack("<I", self.take(4))[0]
48
49 def i32(self):
50 return struct.unpack("<i", self.take(4))[0]
51
52 def i64(self):
53 return struct.unpack("<q", self.take(8))[0]
54
55
56def sha256(data):
57 return hashlib.sha256(data).digest()
58
59
60def sha256d(data):
61 return hashlib.sha256(hashlib.sha256(data).digest()).digest()
62
63
64def hash_hex(serialized):
65 return sha256d(serialized)[::-1].hex()
66
67
68def compact_size(r):
69 first = r.u8()
70 if first < 253:
71 return first
72 if first == 253:
73 return struct.unpack("<H", r.take(2))[0]
74 if first == 254:
75 return r.u32()
76 return struct.unpack("<Q", r.take(8))[0]
77
78
79def read_varbytes(r):
80 return r.take(compact_size(r))
81
82
83def ser_compact_size(n):
84 if n < 253:
85 return bytes([n])
86 if n <= 0xFFFF:
87 return b"\xfd" + struct.pack("<H", n)
88 if n <= 0xFFFFFFFF:
89 return b"\xfe" + struct.pack("<I", n)
90 return b"\xff" + struct.pack("<Q", n)
91
92
93def ser_varbytes(b):
94 return ser_compact_size(len(b)) + b
95
96
97def ser_tx(tx):
98 out = bytearray()
99 out += struct.pack("<i", tx["version"])
100 out += ser_compact_size(len(tx["vin"]))
101 for txin in tx["vin"]:
102 out += bytes.fromhex(txin["prevout_hash"])[::-1]
103 out += struct.pack("<I", txin["prevout_n"])
104 out += ser_varbytes(bytes.fromhex(txin["script_sig_hex"]))
105 out += struct.pack("<I", txin["sequence"])
106 out += ser_compact_size(len(tx["vout"]))
107 for txout in tx["vout"]:
108 out += struct.pack("<q", txout["value_satoshis"])
109 out += ser_varbytes(bytes.fromhex(txout["script_pub_key_hex"]))
110 out += struct.pack("<I", tx["lock_time"])
111 return bytes(out)
112
113
114def parse_tx(r):
115 start = r.pos
116 tx = {"version": r.i32(), "vin": [], "vout": [], "lock_time": 0}
117 for _ in range(compact_size(r)):
118 tx["vin"].append({
119 "prevout_hash": r.take(32)[::-1].hex(),
120 "prevout_n": r.u32(),
121 "script_sig_hex": read_varbytes(r).hex(),
122 "sequence": r.u32(),
123 })
124 for _ in range(compact_size(r)):
125 value = r.i64()
126 tx["vout"].append({
127 "value_satoshis": value,
128 "value_btf": format_money(value),
129 "script_pub_key_hex": read_varbytes(r).hex(),
130 })
131 tx["lock_time"] = r.u32()
132 raw = r.data[start:r.pos]
133 tx["txid"] = hash_hex(raw)
134 return tx
135
136
137def merkle_root(txids):
138 if not txids:
139 return "0" * 64
140 layer = [bytes.fromhex(x)[::-1] for x in txids]
141 while len(layer) > 1:
142 nxt = []
143 for i in range(0, len(layer), 2):
144 left = layer[i]
145 right = layer[i + 1] if i + 1 < len(layer) else left
146 nxt.append(sha256d(left + right))
147 layer = nxt
148 return layer[0][::-1].hex()
149
150
151def parse_block(payload, file_name="", offset=0):
152 r = Reader(payload)
153 header = r.take(80)
154 version, = struct.unpack("<i", header[0:4])
155 prev = header[4:36][::-1].hex()
156 merkle = header[36:68][::-1].hex()
157 ntime, bits, nonce = struct.unpack("<III", header[68:80])
158 txs = [parse_tx(r) for _ in range(compact_size(r))]
159 if r.pos != len(payload):
160 raise ParseError("trailing bytes inside block")
161 txids = [tx["txid"] for tx in txs]
162 return {
163 "hash": hash_hex(header),
164 "version": version,
165 "previous_hash": prev,
166 "merkle_root": merkle,
167 "computed_merkle_root": merkle_root(txids),
168 "time": ntime,
169 "time_utc": utc(ntime),
170 "bits": "0x%08x" % bits,
171 "nonce": nonce,
172 "tx_count": len(txs),
173 "transactions": txs,
174 "source_file": file_name,
175 "source_offset": offset,
176 "size": len(payload),
177 }
178
179
180def iter_block_files(datadir):
181 for path in sorted(Path(datadir).glob("blk*.dat")):
182 yield path
183
184
185def iter_blocks_from_file(path):
186 path = Path(path)
187 with path.open("rb") as f:
188 f.seek(0, os.SEEK_END)
189 file_size = f.tell()
190 if file_size == 0:
191 return
192 f.seek(0)
193 mm = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
194 try:
195 pos = 0
196 while True:
197 offset = mm.find(MAGIC, pos)
198 if offset < 0 or offset + 8 > file_size:
199 break
200 size = struct.unpack("<I", mm[offset + 4:offset + 8])[0]
201 payload_offset = offset + 8
202 end = payload_offset + size
203 if size == 0 or size > MAX_BLOCK_PAYLOAD or end > file_size:
204 pos = offset + 1
205 continue
206 payload = mm[payload_offset:end]
207 try:
208 yield parse_block(payload, path.name, payload_offset)
209 except ParseError:
210 pos = offset + 1
211 continue
212 pos = end
213 finally:
214 mm.close()
215
216
217def read_blocks_from_files(paths, max_blocks=0):
218 blocks = []
219 for path in paths:
220 for block in iter_blocks_from_file(path):
221 blocks.append(block)
222 if max_blocks and len(blocks) >= max_blocks:
223 return blocks
224 return blocks
225
226
227def read_blocks(datadir, max_blocks=0):
228 return read_blocks_from_files(iter_block_files(datadir), max_blocks)
229
230
231def select_main_chain(blocks):
232 by_hash = {}
233 for block in blocks:
234 by_hash.setdefault(block["hash"], block)
235 if GENESIS_HASH not in by_hash:
236 raise ParseError("genesis block not found in block files")
237
238 children = {}
239 for block in by_hash.values():
240 children.setdefault(block["previous_hash"], []).append(block)
241 for v in children.values():
242 v.sort(key=lambda b: (b["hash"], b["source_file"], b["source_offset"]))
243
244 best = []
245 stack = [(by_hash[GENESIS_HASH], [by_hash[GENESIS_HASH]])]
246 while stack:
247 block, path = stack.pop()
248 if len(path) > len(best) or (len(path) == len(best) and block["hash"] < best[-1]["hash"]):
249 best = path
250 for child in children.get(block["hash"], []):
251 stack.append((child, path + [child]))
252 return best
253
254
255def is_coinbase(tx):
256 return (
257 len(tx["vin"]) == 1
258 and tx["vin"][0]["prevout_hash"] == "0" * 64
259 and tx["vin"][0]["prevout_n"] == 0xFFFFFFFF
260 )
261
262
263def utxo_leaf_hash(outpoint, utxo):
264 payload = "|".join([
265 UTXO_LEAF_DOMAIN,
266 outpoint,
267 str(utxo["value_satoshis"]),
268 utxo["script_pub_key_hex"],
269 str(utxo["created_height"]),
270 utxo["created_txid"],
271 ]).encode("ascii")
272 return sha256(payload)
273
274
275def utxo_root(utxos):
276 leaves = [utxo_leaf_hash(k, utxos[k]) for k in sorted(utxos)]
277 if not leaves:
278 return sha256(b"BTFUTXO1|empty").hex()
279 layer = leaves
280 while len(layer) > 1:
281 nxt = []
282 for i in range(0, len(layer), 2):
283 left = layer[i]
284 right = layer[i + 1] if i + 1 < len(layer) else left
285 nxt.append(sha256(UTXO_NODE_DOMAIN + left + right))
286 layer = nxt
287 return layer[0].hex()
288
289
290def apply_blocks(blocks, strict_duplicates=False):
291 utxos = {}
292 spent = 0
293 missing_spends = []
294 duplicate_outputs = []
295 total_coinbase = 0
296 total_fees = 0
297 normal_txs = 0
298
299 for height, block in enumerate(blocks):
300 if block["computed_merkle_root"] != block["merkle_root"]:
301 raise ParseError("bad merkle root at height %d" % height)
302 for tx in block["transactions"]:
303 coinbase = is_coinbase(tx)
304 input_value = 0
305 output_value = sum(out["value_satoshis"] for out in tx["vout"])
306 if coinbase:
307 total_coinbase += output_value
308 else:
309 normal_txs += 1
310 for txin in tx["vin"]:
311 prev = "%s:%d" % (txin["prevout_hash"], txin["prevout_n"])
312 prev_utxo = utxos.pop(prev, None)
313 if prev_utxo is None:
314 missing_spends.append({
315 "height": height,
316 "txid": tx["txid"],
317 "missing_outpoint": prev,
318 })
319 else:
320 spent += prev_utxo["value_satoshis"]
321 input_value += prev_utxo["value_satoshis"]
322 if input_value >= output_value:
323 total_fees += input_value - output_value
324 for n, out in enumerate(tx["vout"]):
325 key = "%s:%d" % (tx["txid"], n)
326 if key in utxos:
327 if strict_duplicates:
328 raise ParseError("duplicate output at height %d: %s" % (height, key))
329 duplicate_outputs.append({"height": height, "outpoint": key})
330 utxos[key] = {
331 "txid": tx["txid"],
332 "vout": n,
333 "value_satoshis": out["value_satoshis"],
334 "value_btf": out["value_btf"],
335 "script_pub_key_hex": out["script_pub_key_hex"],
336 "created_height": height,
337 "created_block_hash": block["hash"],
338 "created_txid": tx["txid"],
339 "coinbase": coinbase,
340 }
341
342 total_unspent = sum(u["value_satoshis"] for u in utxos.values())
343 return {
344 "utxos": utxos,
345 "stats": {
346 "utxo_count": len(utxos),
347 "spent_outputs": spent,
348 "total_unspent_satoshis": total_unspent,
349 "total_unspent_btf": format_money(total_unspent),
350 "total_coinbase_satoshis": total_coinbase,
351 "total_coinbase_btf": format_money(total_coinbase),
352 "coinbase_minus_unspent_satoshis": total_coinbase - total_unspent,
353 "coinbase_minus_unspent_btf": format_money(total_coinbase - total_unspent),
354 "total_fees_satoshis": total_fees,
355 "total_fees_btf": format_money(total_fees),
356 "normal_transactions": normal_txs,
357 "missing_spends": missing_spends,
358 "duplicate_outputs": duplicate_outputs,
359 },
360 }
361
362
363def genesis_transaction():
364 script_sig = (
365 bytes.fromhex("04ffff001d")
366 + b"\x01\x04"
367 + bytes([len(GENESIS_TEXT)])
368 + GENESIS_TEXT.encode("ascii")
369 )
370 pubkey_num = bytes.fromhex(
371 "5f1df16b2b704c8a578d0bbaf74d385cde12c11ee50455f3c438ef4c3fbcf649"
372 "b6de611feae06279a60939e028a8d65c10b73071a6f16719274855feb0fd8a6704"
373 )
374 script_pub_key = bytes([65]) + pubkey_num[::-1] + b"\xac"
375 tx = {
376 "version": 1,
377 "vin": [{
378 "prevout_hash": "0" * 64,
379 "prevout_n": 0xFFFFFFFF,
380 "script_sig_hex": script_sig.hex(),
381 "sequence": 0xFFFFFFFF,
382 }],
383 "vout": [{
384 "value_satoshis": 50 * COIN,
385 "value_btf": format_money(50 * COIN),
386 "script_pub_key_hex": script_pub_key.hex(),
387 }],
388 "lock_time": 0,
389 }
390 tx["txid"] = hash_hex(ser_tx(tx))
391 return tx
392
393
394def reconstructed_genesis():
395 tx = genesis_transaction()
396 header = (
397 struct.pack("<i", 1)
398 + bytes(32)
399 + bytes.fromhex(tx["txid"])[::-1]
400 + struct.pack("<III", GENESIS_TIME, GENESIS_BITS, GENESIS_NONCE)
401 )
402 return {
403 "hash": hash_hex(header),
404 "version": 1,
405 "previous_hash": "0" * 64,
406 "merkle_root": tx["txid"],
407 "computed_merkle_root": tx["txid"],
408 "time": GENESIS_TIME,
409 "time_utc": utc(GENESIS_TIME),
410 "bits": "0x%08x" % GENESIS_BITS,
411 "nonce": GENESIS_NONCE,
412 "tx_count": 1,
413 "transactions": [tx],
414 "source_file": "reconstructed-from-source-constants",
415 "source_offset": 0,
416 "size": 80 + len(ser_compact_size(1)) + len(ser_tx(tx)),
417 }
418
419
420def ser_block(block):
421 header = (
422 struct.pack("<i", block["version"])
423 + bytes.fromhex(block["previous_hash"])[::-1]
424 + bytes.fromhex(block["merkle_root"])[::-1]
425 + struct.pack("<III", block["time"], int(block["bits"], 16), block["nonce"])
426 )
427 txs = block["transactions"]
428 return header + ser_compact_size(len(txs)) + b"".join(ser_tx(tx) for tx in txs)
429
430
431def format_money(value):
432 sign = "-" if value < 0 else ""
433 value = abs(value)
434 return "%s%d.%08d" % (sign, value // COIN, value % COIN)
435
436
437def utc(ts):
438 return _dt.datetime.fromtimestamp(ts, _dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
439
440
441def default_datadir():
442 if os.name == "nt" and os.environ.get("APPDATA"):
443 return os.path.join(os.environ["APPDATA"], "Bitflash")
444 if os.environ.get("HOME"):
445 return os.path.join(os.environ["HOME"], ".bitflash")
446 return "."
447
448
449def canonical_bytes(report):
450 return (json.dumps(report, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + "\n").encode("ascii")
diff --git a/scripts/build-explorer.py b/scripts/build-explorer.py
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
16import sys, os, json, hashlib, struct
17
18MAGIC = bytes([0xbf, 0x20, 0x5c, 0xfd])
19ADDRESS_VERSION = 25 # Bitflash addresses start with "B"
20
21def sha256d(b): return hashlib.sha256(hashlib.sha256(b).digest()).digest()
22 3
4import argparse
5import hashlib
6import json
7import sys
8from pathlib import Path
9
10import bitflash_chain as chain
11
12
13ADDRESS_VERSION = 25
23B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" 14B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
15
16
24def b58check(payload): 17def 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
36def hash160(b): 33def hash160(b):
37 return hashlib.new('ripemd160', hashlib.sha256(b).digest()).digest() 34 return hashlib.new("ripemd160", hashlib.sha256(b).digest()).digest()
38 35
39class R: 36
40 def __init__(s, b, o=0): s.b = b; s.o = o 37def 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
53def 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]}
65def parse_tx(r): 55
66 start = r.o 56
67 r.i32() 57def 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 = []
80def 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) 75def 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): 96def 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] 102def 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
154def 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
134INDEX_HTML = r"""<!doctype html> 164INDEX_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>
189var SUM = [], BYH = {}, SHOWN = 200; 219var SUM = [], SHOWN = 200;
190function btf(sat){ return (sat/1e8).toFixed(8).replace(/0+$/,'').replace(/\.$/,'')+" BTF"; } 220function btf(sat){ return (sat/1e8).toFixed(8).replace(/0+$/,'').replace(/\.$/,'')+" BTF"; }
191function ts(t){ return new Date(t*1000).toISOString().replace('T',' ').replace('.000Z',' UTC'); } 221function ts(t){ return new Date(t*1000).toISOString().replace('T',' ').replace('.000Z',' UTC'); }
192function esc(s){ return String(s).replace(/[&<>]/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;'}[c];}); } 222function short(h){ return h ? h.slice(0,10)+"..."+h.slice(-6) : "-"; }
193function short(h){ return h ? h.slice(0,10)+"…"+h.slice(-6) : "-"; } 223function 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}
229function code(text){ return E('code', '', text); }
230function clear(e){ while(e.firstChild) e.removeChild(e.firstChild); }
231function append(parent){
232 for(var i=1;i<arguments.length;i++) parent.appendChild(arguments[i]);
233 return parent;
234}
235function row(cells, cls){
236 var tr = E('tr', cls || '');
237 cells.forEach(function(c){ append(tr, E('td', '', c)); });
238 return tr;
239}
195function renderList(filter){ 240function 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}
205function ioSide(title, items, html){ 260function 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}
264function 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}
208function openBlock(h){ 270function 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}
231document.getElementById('home').onclick=function(){ document.getElementById('detail').style.display='none'; document.getElementById('listview').style.display='block'; this.style.display='none'; }; 319document.getElementById('home').onclick=function(){
320 document.getElementById('detail').style.display='none';
321 document.getElementById('listview').style.display='block';
322 this.style.display='none';
323};
232document.getElementById('q').oninput=function(){ renderList(this.value); }; 324document.getElementById('q').oninput=function(){ renderList(this.value); };
233fetch('blocks.json').then(function(r){return r.json();}).then(function(d){ 325fetch('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
242if __name__ == "__main__": 335if __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
8import argparse 8import argparse
9import hashlib 9import hashlib
10import importlib.util
11import json 10import json
12import os
13import sys 11import sys
14from pathlib import Path 12from pathlib import Path
15 13
14import bitflash_chain as chain
16 15
17SCHEMA = "bitflash-utxo-inclusion-proof-1" 16SCHEMA = "bitflash-utxo-inclusion-proof-1"
18NODE_DOMAIN = b"BTFNODE1|"
19
20
21def 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
31def canonical_bytes(obj): 19def 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
82def build_proof(args): 70def 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
172def 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
180def main(argv): 162def 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
4This verifier is intentionally self-contained. It parses Bitflash block files
5directly, without Berkeley DB, without the node binary, and without network
6access. The canonical JSON output is stable for the same inputs and can be
7timestamped later with OpenTimestamps or any other external notary.
8"""
9 3
10import argparse 4import argparse
11import datetime as _dt
12import hashlib 5import hashlib
13import json
14import os
15import struct
16import sys 6import sys
17from pathlib import Path 7from pathlib import Path
18 8
19 9import bitflash_chain as chain
20MAGIC = bytes([0xBF, 0x20, 0x5C, 0xFD])
21COIN = 100_000_000
22GENESIS_HASH = "5bd7cb255d814e48cebcdfb72da4dc87b34bd774227f8ceb546c5640f4bdc169"
23GENESIS_MERKLE = "1a45b4482532abb29b10e234d3f13132230525a339ecea91658ffa675a5b1325"
24GENESIS_TIME = 1_753_315_200
25GENESIS_BITS = 0x1F0FFFFF
26GENESIS_NONCE = 3141
27GENESIS_TEXT = "Bitflash 24/Jul/2026 Fair launch: one CPU one vote, no premine"
28
29
30class ParseError(Exception):
31 pass
32
33
34class 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
59def sha256d(data):
60 return hashlib.sha256(hashlib.sha256(data).digest()).digest()
61
62
63def hash_hex(serialized):
64 return sha256d(serialized)[::-1].hex()
65
66
67def 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
78def read_varbytes(r):
79 return r.take(compact_size(r))
80
81
82def 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
92def ser_varbytes(b):
93 return ser_compact_size(len(b)) + b
94
95
96def 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
113def 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
141def 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
155def 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
183def iter_block_files(datadir):
184 for path in sorted(Path(datadir).glob("blk*.dat")):
185 yield path
186
187
188def 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
211def 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
244def 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
269def 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
275def utc(ts):
276 return _dt.datetime.fromtimestamp(ts, _dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
277
278
279def 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
287def 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
295def canonical_bytes(report):
296 return (json.dumps(report, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + "\n").encode("ascii")
297 10
298 11
299def build_report(blocks, source_mode): 12def 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
418def main(argv): 131def 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
7import sys 7import sys
8from pathlib import Path 8from pathlib import Path
9 9
10import bitflash_chain as chain
10 11
11SCHEMA = "bitflash-utxo-inclusion-proof-1" 12SCHEMA = "bitflash-utxo-inclusion-proof-1"
12LEAF_DOMAIN = "BTFUTXO1"
13NODE_DOMAIN = b"BTFNODE1|"
14 13
15 14
16def canonical_bytes(obj): 15def 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
20def sha256(data):
21 return hashlib.sha256(data).digest()
22
23
24def 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
36def load_proof(path): 19def 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
4This is deliberately outside consensus. It lets auditors reconstruct the chain
5state from disk blocks and compare the same canonical UTXO root independently.
6"""
7 3
8import argparse 4import argparse
9import datetime as _dt
10import hashlib 5import hashlib
11import json
12import os
13import struct
14import sys 6import sys
15from pathlib import Path 7from pathlib import Path
16 8
17 9import bitflash_chain as chain
18MAGIC = bytes([0xBF, 0x20, 0x5C, 0xFD])
19COIN = 100_000_000
20GENESIS_HASH = "5bd7cb255d814e48cebcdfb72da4dc87b34bd774227f8ceb546c5640f4bdc169"
21GENESIS_MERKLE = "1a45b4482532abb29b10e234d3f13132230525a339ecea91658ffa675a5b1325"
22
23
24class ParseError(Exception):
25 pass
26
27
28class 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
53def sha256(data):
54 return hashlib.sha256(data).digest()
55
56
57def sha256d(data):
58 return hashlib.sha256(hashlib.sha256(data).digest()).digest()
59
60
61def hash_hex(serialized):
62 return sha256d(serialized)[::-1].hex()
63
64
65def 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
76def read_varbytes(r):
77 return r.take(compact_size(r))
78
79
80def 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
90def ser_varbytes(b):
91 return ser_compact_size(len(b)) + b
92
93
94def 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
111def 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
132def 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
146def 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
174def 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
197def 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
221def 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
229def 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
243def 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
258def 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
329def 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
335def utc(ts):
336 return _dt.datetime.fromtimestamp(ts, _dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
337
338
339def 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
347def canonical_bytes(report):
348 return (json.dumps(report, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + "\n").encode("ascii")
349 10
350 11
351def build_report(raw_blocks, include_utxos=False): 12def 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
402def print_text(report): 63def 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
438def main(argv): 99def 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
32CXXFLAGS = -std=gnu++14 -O2 $(WARNFLAGS) -fpermissive -pthread \ 32CXXFLAGS = -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 \
53HEADERS = headers_core.h headers.h util.h main.h serialize.h uint256.h \ 53HEADERS = 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
58IMGUI_OBJS = obj/imgui.o obj/imgui_draw.o obj/imgui_tables.o \ 58IMGUI_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 \
61NODE_OBJS = obj/util.o obj/script.o obj/db.o obj/net.o obj/main.o \ 61NODE_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
66GUI_OBJS = obj/gui.o obj/main_gui.o 66GUI_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
93static SOCKET ConnectTo(const char* host, unsigned short port) 96static 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
17CXXFLAGS = -std=gnu++14 -O2 $(WARNFLAGS) -fpermissive -pthread \ 17CXXFLAGS = -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 \
37NODE_OBJS = obj/util.o obj/script.o obj/db.o obj/net.o obj/main.o \ 42NODE_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
42GUI_OBJS = obj/gui.o obj/main_gui.o obj/bitflash_res.o 47GUI_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
51HEADERS = headers_core.h headers.h util.h main.h serialize.h uint256.h \ 56HEADERS = 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
56all: bitflash.exe 61all: 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
23void ThreadReconnectCachedBtfPeers(void* parg); 24void ThreadReconnectCachedBtfPeers(void* parg);
24void ThreadMessageHandler2(void* parg); 25void ThreadMessageHandler2(void* parg);
@@ -377,6 +378,9 @@ string GetDiagnosticsText()
377 378
378bool GetMyExternalIP(unsigned int& ipRet) 379bool 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
33using json = nlohmann::json; 34using 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
10static CCriticalSection cs_socks5;
11static bool g_fSocks5Proxy = false;
12static bool g_fTorProxy = false;
13static std::string g_socks5Host;
14static unsigned short g_socks5Port = 0;
15static const char* DEFAULT_TOR_SOCKS5_PROXY = "127.0.0.1:9050";
16
17static 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
34static 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
48static 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
62static bool ValidProxyHostChar(char c)
63{
64 return isalnum((unsigned char)c) || c == '.' || c == '-' || c == '_';
65}
66
67static bool ValidProxyIpv6HostChar(char c)
68{
69 return isxdigit((unsigned char)c) || c == ':' || c == '.';
70}
71
72static 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
81bool 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
90static 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
119bool 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
199static 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
216bool BtfSetSocks5Proxy(const std::string& spec, std::string& errOut)
217{
218 return SetSocks5Proxy(spec, false, errOut);
219}
220
221bool BtfEnableTorProxy(const std::string& spec, std::string& errOut)
222{
223 return SetSocks5Proxy(spec.empty() ? DEFAULT_TOR_SOCKS5_PROXY : spec, true, errOut);
224}
225
226void 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
237bool BtfSocks5ProxyEnabled()
238{
239 CRITICAL_BLOCK(cs_socks5)
240 return g_fSocks5Proxy;
241 return false;
242}
243
244bool BtfTorProxyEnabled()
245{
246 CRITICAL_BLOCK(cs_socks5)
247 return g_fSocks5Proxy && g_fTorProxy;
248 return false;
249}
250
251std::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
264static 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
292static 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
344SOCKET 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
21bool BtfParseSocks5Proxy(const std::string& spec, std::string& hostOut,
22 unsigned short& portOut, std::string& errOut);
23bool BtfSetSocks5Proxy(const std::string& spec, std::string& errOut);
24bool BtfEnableTorProxy(const std::string& spec, std::string& errOut);
25void BtfClearSocks5Proxy();
26bool BtfSocks5ProxyEnabled();
27bool BtfTorProxyEnabled();
28bool BtfIsTorOnionHost(const std::string& host);
29std::string BtfSocks5ProxyName();
30
31SOCKET 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.
42Command-line options (in addition to the original ones) 42Command-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
13extern int RunPoolStratumSelfTest(); 15extern 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
1258static 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
1272static 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
1286static 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
1302struct 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
1318static 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
1449static 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
1256int RunSelfTest(const std::string& name) 1531int 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
4import json
5import os
6import struct
7import subprocess
8import sys
9import tempfile
10from pathlib import Path
11
12
13ROOT = Path(__file__).resolve().parents[2]
14SCRIPTS = ROOT / "scripts"
15FIXTURES = ROOT / "tests" / "fixtures" / "chain-tools"
16sys.path.insert(0, str(SCRIPTS))
17
18import bitflash_chain as chain # noqa: E402
19
20
21def 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
36def 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
49def p2pkh_script(byte):
50 return "76a914%s88ac" % (bytes([byte]) * 20).hex()
51
52
53def 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
64def 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
80def 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
96def 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
123def 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
156def 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
167def assert_true(cond, msg):
168 if not cond:
169 raise AssertionError(msg)
170
171
172def 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
222def 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
274def 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
282if __name__ == "__main__":
283 raise SystemExit(main())