From 1839347459759fb3b23cc747ec4368b724fee008 Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Tue, 11 Aug 2026 14:13:26 -0300 Subject: tools: harden chain audit scripts --- .github/workflows/ci.yml | 3 + README.md | 8 + docs/fair-launch.md | 3 +- docs/utxo-commitment.md | 20 +- scripts/bitflash_chain.py | 453 +++++++++++++++++++++++++++++++++++ scripts/build-explorer.py | 382 ++++++++++++++++++----------- scripts/prove-utxo.py | 43 ++-- scripts/verify-fair-launch.py | 346 +++----------------------- scripts/verify-utxo-proof.py | 23 +- scripts/verify-utxo-set.py | 375 ++--------------------------- tests/scripts/test_bitflash_tools.py | 107 +++++++++ 11 files changed, 897 insertions(+), 866 deletions(-) create mode 100644 scripts/bitflash_chain.py create mode 100644 tests/scripts/test_bitflash_tools.py 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: - name: Run Linux selftests run: make tests + - name: Run Python tool regressions + run: python3 tests/scripts/test_bitflash_tools.py + - name: Run P2P message fuzz smoke run: make fuzz-net-message-smoke diff --git a/README.md b/README.md index c28b0f1..9c6db0e 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,14 @@ and produces canonical JSON whose hash can be compared by independent auditors or timestamped externally with OpenTimestamps. See [fair-launch verification](docs/fair-launch.md). +For a static local block explorer, generate HTML and JSON from either a data +directory or explicit block files: + +```bash +python3 scripts/build-explorer.py --datadir ~/.bitflash ./explorer-out +python3 scripts/build-explorer.py ~/.bitflash/blk0001.dat ~/.bitflash/blk0002.dat ./explorer-out +``` + **Keep your node current.** Consensus rules have changed since the first releases — 1.2.1 fixed a bug that let anyone spend anyone's coins, and 1.2.2 added a per-block signature-operation cap. A node on an older build will accept 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 The verifier reads `blk*.dat` directly from the local Bitflash data directory. It does not use Berkeley DB, does not call the node binary, and does not need -network access. +network access. It uses the same shared Python chain parser as the UTXO +commitment and proof tools, so block serialization fixes land in one place. The report proves the launch baseline that can be checked from the chain: 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 The verifier: -- parses Bitflash block files directly; +- parses Bitflash block files directly with the shared Python chain parser; +- streams `blk*.dat` records instead of loading full block files into memory; - reconstructs the best chain rooted at the Bitflash genesis block; - ignores side blocks that may also be present in `blk*.dat`; - verifies every scanned block merkle root; @@ -98,6 +99,23 @@ last-write-wins semantics for the UTXO map, matching the legacy index model. Those warnings are part of the report so auditors can see the historical debt instead of relying on a polished summary. +For synthetic chains or future clean consensus ranges, auditors can make +duplicates a hard failure: + +```bash +python3 scripts/verify-utxo-set.py --strict-duplicates +``` + +The default remains compatible with the current historical chain because it has +known legacy duplicate-output overwrites. + +## Shared Parser + +The audit scripts share `scripts/bitflash_chain.py` for block parsing, +main-chain selection, UTXO application, canonical JSON, and genesis +reconstruction. That keeps `verify-utxo-set.py`, `verify-fair-launch.py`, +`prove-utxo.py`, and the explorer on the same serialization rules. + ## Future Consensus Path This tool is the low-risk first step toward consensus commitments. A future diff --git a/scripts/bitflash_chain.py b/scripts/bitflash_chain.py new file mode 100644 index 0000000..f3688f1 --- /dev/null +++ b/scripts/bitflash_chain.py @@ -0,0 +1,453 @@ +#!/usr/bin/env python3 +"""Shared Bitflash block/chain parser for deterministic audit tools.""" + +import datetime as _dt +import hashlib +import json +import os +import struct +from pathlib import Path + + +MAGIC = bytes([0xBF, 0x20, 0x5C, 0xFD]) +MAX_BLOCK_PAYLOAD = 32 * 1024 * 1024 +COIN = 100_000_000 +GENESIS_HASH = "5bd7cb255d814e48cebcdfb72da4dc87b34bd774227f8ceb546c5640f4bdc169" +GENESIS_MERKLE = "1a45b4482532abb29b10e234d3f13132230525a339ecea91658ffa675a5b1325" +GENESIS_TIME = 1_753_315_200 +GENESIS_BITS = 0x1F0FFFFF +GENESIS_NONCE = 3141 +GENESIS_TEXT = "Bitflash 24/Jul/2026 Fair launch: one CPU one vote, no premine" + + +class ParseError(Exception): + pass + + +class Reader: + def __init__(self, data): + self.data = data + self.pos = 0 + + def take(self, n): + if self.pos + n > len(self.data): + raise ParseError("unexpected end of data") + out = self.data[self.pos:self.pos + n] + self.pos += n + return out + + def u8(self): + return self.take(1)[0] + + def u32(self): + return struct.unpack(" 1: + nxt = [] + for i in range(0, len(layer), 2): + left = layer[i] + right = layer[i + 1] if i + 1 < len(layer) else left + nxt.append(sha256d(left + right)) + layer = nxt + return layer[0][::-1].hex() + + +def parse_block(payload, file_name="", offset=0): + r = Reader(payload) + header = r.take(80) + version, = struct.unpack(" MAX_BLOCK_PAYLOAD: + offset += 1 + f.seek(offset) + window = f.read(4) + continue + payload_offset = offset + 8 + payload = f.read(size) + if len(payload) != size: + break + try: + yield parse_block(payload, path.name, payload_offset) + except ParseError: + offset += 1 + f.seek(offset) + window = f.read(4) + continue + + offset = f.tell() + window = f.read(4) + + +def read_blocks_from_files(paths, max_blocks=0): + blocks = [] + for path in paths: + for block in iter_blocks_from_file(path): + blocks.append(block) + if max_blocks and len(blocks) >= max_blocks: + return blocks + return blocks + + +def read_blocks(datadir, max_blocks=0): + return read_blocks_from_files(iter_block_files(datadir), max_blocks) + + +def select_main_chain(blocks): + by_hash = {} + for block in blocks: + by_hash.setdefault(block["hash"], block) + if GENESIS_HASH not in by_hash: + raise ParseError("genesis block not found in block files") + + children = {} + for block in by_hash.values(): + children.setdefault(block["previous_hash"], []).append(block) + for v in children.values(): + v.sort(key=lambda b: (b["hash"], b["source_file"], b["source_offset"])) + + best = [] + stack = [(by_hash[GENESIS_HASH], [by_hash[GENESIS_HASH]])] + while stack: + block, path = stack.pop() + if len(path) > len(best) or (len(path) == len(best) and block["hash"] < best[-1]["hash"]): + best = path + for child in children.get(block["hash"], []): + stack.append((child, path + [child])) + return best + + +def is_coinbase(tx): + return ( + len(tx["vin"]) == 1 + and tx["vin"][0]["prevout_hash"] == "0" * 64 + and tx["vin"][0]["prevout_n"] == 0xFFFFFFFF + ) + + +def utxo_leaf_hash(outpoint, utxo): + payload = "|".join([ + "BTFUTXO1", + outpoint, + str(utxo["value_satoshis"]), + utxo["script_pub_key_hex"], + str(utxo["created_height"]), + utxo["created_txid"], + ]).encode("ascii") + return sha256(payload) + + +def utxo_root(utxos): + leaves = [utxo_leaf_hash(k, utxos[k]) for k in sorted(utxos)] + if not leaves: + return sha256(b"BTFUTXO1|empty").hex() + layer = leaves + while len(layer) > 1: + nxt = [] + for i in range(0, len(layer), 2): + left = layer[i] + right = layer[i + 1] if i + 1 < len(layer) else left + nxt.append(sha256(b"BTFNODE1|" + left + right)) + layer = nxt + return layer[0].hex() + + +def apply_blocks(blocks, strict_duplicates=False): + utxos = {} + spent = 0 + missing_spends = [] + duplicate_outputs = [] + total_coinbase = 0 + total_fees = 0 + normal_txs = 0 + + for height, block in enumerate(blocks): + if block["computed_merkle_root"] != block["merkle_root"]: + raise ParseError("bad merkle root at height %d" % height) + for tx in block["transactions"]: + coinbase = is_coinbase(tx) + input_value = 0 + output_value = sum(out["value_satoshis"] for out in tx["vout"]) + if coinbase: + total_coinbase += output_value + else: + normal_txs += 1 + for txin in tx["vin"]: + prev = "%s:%d" % (txin["prevout_hash"], txin["prevout_n"]) + prev_utxo = utxos.pop(prev, None) + if prev_utxo is None: + missing_spends.append({ + "height": height, + "txid": tx["txid"], + "missing_outpoint": prev, + }) + else: + spent += prev_utxo["value_satoshis"] + input_value += prev_utxo["value_satoshis"] + if input_value >= output_value: + total_fees += input_value - output_value + for n, out in enumerate(tx["vout"]): + key = "%s:%d" % (tx["txid"], n) + if key in utxos: + duplicate_outputs.append({"height": height, "outpoint": key}) + if strict_duplicates: + raise ParseError("duplicate output at height %d: %s" % (height, key)) + utxos[key] = { + "txid": tx["txid"], + "vout": n, + "value_satoshis": out["value_satoshis"], + "value_btf": out["value_btf"], + "script_pub_key_hex": out["script_pub_key_hex"], + "created_height": height, + "created_block_hash": block["hash"], + "created_txid": tx["txid"], + "coinbase": coinbase, + } + + total_unspent = sum(u["value_satoshis"] for u in utxos.values()) + return { + "utxos": utxos, + "stats": { + "utxo_count": len(utxos), + "spent_outputs": spent, + "total_unspent_satoshis": total_unspent, + "total_unspent_btf": format_money(total_unspent), + "total_coinbase_satoshis": total_coinbase, + "total_coinbase_btf": format_money(total_coinbase), + "coinbase_minus_unspent_satoshis": total_coinbase - total_unspent, + "coinbase_minus_unspent_btf": format_money(total_coinbase - total_unspent), + "total_fees_satoshis": total_fees, + "total_fees_btf": format_money(total_fees), + "normal_transactions": normal_txs, + "missing_spends": missing_spends, + "duplicate_outputs": duplicate_outputs, + }, + } + + +def genesis_transaction(): + script_sig = ( + bytes.fromhex("04ffff001d") + + b"\x01\x04" + + bytes([len(GENESIS_TEXT)]) + + GENESIS_TEXT.encode("ascii") + ) + pubkey_num = bytes.fromhex( + "5f1df16b2b704c8a578d0bbaf74d385cde12c11ee50455f3c438ef4c3fbcf649" + "b6de611feae06279a60939e028a8d65c10b73071a6f16719274855feb0fd8a6704" + ) + script_pub_key = bytes([65]) + pubkey_num[::-1] + b"\xac" + tx = { + "version": 1, + "vin": [{ + "prevout_hash": "0" * 64, + "prevout_n": 0xFFFFFFFF, + "script_sig_hex": script_sig.hex(), + "sequence": 0xFFFFFFFF, + }], + "vout": [{ + "value_satoshis": 50 * COIN, + "value_btf": format_money(50 * COIN), + "script_pub_key_hex": script_pub_key.hex(), + }], + "lock_time": 0, + } + tx["txid"] = hash_hex(ser_tx(tx)) + return tx + + +def reconstructed_genesis(): + tx = genesis_transaction() + header = ( + struct.pack(": -# blocks.json summary of every main-chain block (newest first) -# block/.json full detail per block (header + all txs) -# index.html self-contained viewer (vanilla JS, same-origin fetch) -# -# Scope (v0, deliberately): block list, block detail, and coinbase. No mempool, -# no per-address history. On this chain the coinbase payout key rotates every -# block, so an address index would be nearly useless; block-level transparency is -# what lets miners and users confirm the chain is honest. -# -# Usage: build-explorer.py - -import sys, os, json, hashlib, struct - -MAGIC = bytes([0xbf, 0x20, 0x5c, 0xfd]) -ADDRESS_VERSION = 25 # Bitflash addresses start with "B" - -def sha256d(b): return hashlib.sha256(hashlib.sha256(b).digest()).digest() +"""Build a static Bitflash block explorer from local blk*.dat files.""" +import argparse +import hashlib +import json +import sys +from pathlib import Path + +import bitflash_chain as chain + + +ADDRESS_VERSION = 25 B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + + def b58check(payload): v = bytes([ADDRESS_VERSION]) + payload - v += sha256d(v)[:4] - n = int.from_bytes(v, 'big') + v += chain.sha256d(v)[:4] + n = int.from_bytes(v, "big") s = "" while n > 0: - n, r = divmod(n, 58); s = B58[r] + s + n, r = divmod(n, 58) + s = B58[r] + s for byte in v: - if byte == 0: s = "1" + s - else: break + if byte == 0: + s = "1" + s + else: + break return s + def hash160(b): - return hashlib.new('ripemd160', hashlib.sha256(b).digest()).digest() - -class R: - def __init__(s, b, o=0): s.b = b; s.o = o - def u(s, n): v = s.b[s.o:s.o+n]; s.o += n; return v - def i32(s): return struct.unpack(' OP_EQUALVERIFY OP_CHECKSIG - if len(spk) == 25 and spk[0] == 0x76 and spk[1] == 0xa9 and spk[2] == 0x14 and spk[23] == 0x88 and spk[24] == 0xac: + return hashlib.new("ripemd160", hashlib.sha256(b).digest()).digest() + + +def spk_info(script_hex): + spk = bytes.fromhex(script_hex) + if ( + len(spk) == 25 and spk[0] == 0x76 and spk[1] == 0xA9 + and spk[2] == 0x14 and spk[23] == 0x88 and spk[24] == 0xAC + ): return {"type": "p2pkh", "address": b58check(spk[3:23])} - # P2PK: OP_CHECKSIG (65- or 33-byte key) - if len(spk) >= 35 and spk[-1] == 0xac: + if len(spk) >= 35 and spk[-1] == 0xAC: if spk[0] == 0x41 and len(spk) == 67: - pk = spk[1:66]; return {"type": "p2pk", "pubkey": pk.hex(), "address": b58check(hash160(pk))} + pk = spk[1:66] + return {"type": "p2pk", "pubkey": pk.hex(), "address": b58check(hash160(pk))} if spk[0] == 0x21 and len(spk) == 35: - pk = spk[1:34]; return {"type": "p2pk", "pubkey": pk.hex(), "address": b58check(hash160(pk))} - return {"type": "other", "hex": spk.hex()[:80]} - -def parse_tx(r): - start = r.o - r.i32() - nin = r.cs(); vin = [] - for _ in range(nin): - ph = r.u(32); idx = r.u32(); r.script(); r.u32() - coinbase = (ph == b'\x00' * 32 and idx == 0xffffffff) - vin.append({"coinbase": coinbase, "prev": (None if coinbase else ph[::-1].hex()), "vout": (None if coinbase else idx)}) - nout = r.cs(); vout = [] - for _ in range(nout): - val = r.i64(); spk = r.script(); vout.append(dict({"value": val}, **spk_info(spk))) - r.u32() - raw = r.b[start:r.o] - return {"txid": sha256d(raw)[::-1].hex(), "vin": vin, "vout": vout, "size": len(raw)} - -def main(): - if len(sys.argv) != 3: - print("usage: build-explorer.py ", file=sys.stderr); sys.exit(2) - blkpath, out = sys.argv[1], sys.argv[2] - data = open(blkpath, 'rb').read() - blocks = {}; o = 0; n = len(data) - while o + 8 <= n: - if data[o:o+4] != MAGIC: o += 1; continue - o += 4; size = struct.unpack('") + args.block_files = None + args.out_dir = args.paths[0] + else: + if len(args.paths) < 2: + raise SystemExit("usage: build-explorer.py ... ") + args.block_files = args.paths[:-1] + args.out_dir = args.paths[-1] + return args + + +def load_blocks(args): + if args.datadir: + return chain.read_blocks(args.datadir, args.max_blocks) + return chain.read_blocks_from_files(args.block_files, args.max_blocks) + + +def build_explorer(raw_blocks, out_dir): + try: + blocks = chain.select_main_chain(raw_blocks) + except chain.ParseError as e: + raise SystemExit(str(e)) + out = Path(out_dir) + (out / "block").mkdir(parents=True, exist_ok=True) + summaries = [] - for h in chain: - b = blocks[h]; H = hc[h] - cb = b["txs"][0]["vout"][0] if b["txs"] and b["txs"][0]["vout"] else {} - summaries.append({"height": H, "hash": h, "time": b["time"], "nTx": len(b["txs"]), - "size": b["size"], "cbValue": cb.get("value", 0), "cbAddr": cb.get("address", "")}) - json.dump(dict({"height": H}, **b), open(os.path.join(out, "block", "%d.json" % H), "w"), separators=(',', ':')) + for height, block in enumerate(blocks): + txs = [explorer_tx(tx) for tx in block["transactions"]] + detail = { + "height": height, + "hash": block["hash"], + "prev": block["previous_hash"], + "merkleRoot": block["merkle_root"], + "version": block["version"], + "time": block["time"], + "bits": int(block["bits"], 16), + "nonce": block["nonce"], + "size": block["size"], + "txs": txs, + } + cb = txs[0]["vout"][0] if txs and txs[0]["vout"] else {} + summaries.append({ + "height": height, + "hash": block["hash"], + "time": block["time"], + "nTx": len(txs), + "size": block["size"], + "cbValue": cb.get("value", 0), + "cbAddr": cb.get("address", ""), + }) + (out / "block" / ("%d.json" % height)).write_text( + json.dumps(detail, sort_keys=True, separators=(",", ":")) + "\n", + encoding="ascii", + ) + summaries.sort(key=lambda x: -x["height"]) - json.dump({"network": "bitflash", "tipHeight": hc[tip], "count": len(chain), "blocks": summaries}, - open(os.path.join(out, "blocks.json"), "w"), separators=(',', ':')) - open(os.path.join(out, "index.html"), "w").write(INDEX_HTML) - print("explorer built: %d main-chain blocks, tip %d, into %s" % (len(chain), hc[tip], out)) + (out / "blocks.json").write_text( + json.dumps({ + "network": "bitflash", + "tipHeight": len(blocks) - 1, + "count": len(blocks), + "blocks": summaries, + }, sort_keys=True, separators=(",", ":")) + "\n", + encoding="ascii", + ) + (out / "index.html").write_text(INDEX_HTML, encoding="utf-8") + return len(blocks), len(blocks) - 1 + + +def main(argv): + args = parse_args(argv) + raw_blocks = load_blocks(args) + if not raw_blocks: + raise SystemExit("no Bitflash blocks parsed") + count, tip = build_explorer(raw_blocks, args.out_dir) + print("explorer built: %d main-chain blocks, tip %d, into %s" % (count, tip, args.out_dir)) + return 0 INDEX_HTML = r""" @@ -144,6 +171,7 @@ INDEX_HTML = r""" body { font: 15px/1.5 system-ui, sans-serif; margin:0; background:var(--bg); color:var(--fg); } header { padding:20px 24px; border-bottom:1px solid var(--line); display:flex; gap:16px; align-items:baseline; flex-wrap:wrap; } h1 { font-size:18px; margin:0; } + h2 { font-size:16px; } .mut { color:var(--mut); } .wrap { padding:16px 24px; max-width:1100px; margin:0 auto; } 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 +182,6 @@ INDEX_HTML = r""" tr.blk:hover td { background:var(--card); } code { font-size:12px; word-break:break-all; } .scroll { overflow-x:auto; } - a { color:var(--acc); text-decoration:none; } .card { border:1px solid var(--line); border-radius:10px; background:var(--card); padding:14px 16px; margin-top:12px; } .kv { display:grid; grid-template-columns:150px 1fr; gap:4px 12px; font-size:13px; } .kv div:nth-child(odd){ color:var(--mut); } @@ -174,7 +201,7 @@ INDEX_HTML = r"""
-

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.

+

Read-only view of the main chain: blocks, their transactions, and the coinbase payout. There is no mempool and no per-address history here.

@@ -186,52 +213,114 @@ INDEX_HTML = r""" @@ -239,5 +328,6 @@ fetch('blocks.json').then(function(r){return r.json();}).then(function(d){ """ + if __name__ == "__main__": - main() + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/prove-utxo.py b/scripts/prove-utxo.py index 7a10d2a..9f2704b 100644 --- a/scripts/prove-utxo.py +++ b/scripts/prove-utxo.py @@ -7,27 +7,16 @@ included in the deterministic UTXO root produced by verify-utxo-set.py. import argparse import hashlib -import importlib.util import json -import os import sys from pathlib import Path +import bitflash_chain as chain SCHEMA = "bitflash-utxo-inclusion-proof-1" NODE_DOMAIN = b"BTFNODE1|" -def load_utxo_tool(): - path = Path(__file__).with_name("verify-utxo-set.py") - spec = importlib.util.spec_from_file_location("bitflash_verify_utxo_set", path) - if spec is None or spec.loader is None: - raise SystemExit("could not load %s" % path) - mod = importlib.util.module_from_spec(spec) - spec.loader.exec_module(mod) - return mod - - def canonical_bytes(obj): return (json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + "\n").encode("ascii") @@ -80,25 +69,27 @@ def build_merkle_proof(keys, leaf_hashes, index): def build_proof(args): - tool = load_utxo_tool() txid, vout = split_outpoint(args.outpoint) outpoint = "%s:%d" % (txid, vout) - raw_blocks = tool.read_blocks(args.datadir, args.max_blocks) + raw_blocks = chain.read_blocks(args.datadir, args.max_blocks) if not raw_blocks: raise SystemExit("no Bitflash blocks found in %s" % args.datadir) - blocks = tool.select_main_chain(raw_blocks) - state = tool.apply_blocks(blocks) + try: + blocks = chain.select_main_chain(raw_blocks) + state = chain.apply_blocks(blocks, strict_duplicates=args.strict_duplicates) + except chain.ParseError as e: + raise SystemExit(str(e)) utxos = state["utxos"] stats = state["stats"] if outpoint not in utxos: raise SystemExit("outpoint is not unspent in the scanned chain: %s" % outpoint) keys = sorted(utxos) - leaf_hashes = [tool.utxo_leaf_hash(k, utxos[k]) for k in keys] + leaf_hashes = [chain.utxo_leaf_hash(k, utxos[k]) for k in keys] index = keys.index(outpoint) proof_path, root = build_merkle_proof(keys, leaf_hashes, index) - direct_root = tool.utxo_root(utxos) + direct_root = chain.utxo_root(utxos) if root != direct_root: raise SystemExit("internal proof root mismatch") @@ -110,8 +101,8 @@ def build_proof(args): "chain": { "name": "Bitflash", "ticker": "BTF", - "message_start_hex": tool.MAGIC.hex(), - "coin": tool.COIN, + "message_start_hex": chain.MAGIC.hex(), + "coin": chain.COIN, }, "commitment": { "algorithm": "sorted-outpoint-merkle-sha256", @@ -169,20 +160,14 @@ def print_text(proof): print("Verdict: %s" % proof["verdict"]) -def default_datadir(): - if os.name == "nt" and os.environ.get("APPDATA"): - return os.path.join(os.environ["APPDATA"], "Bitflash") - if os.environ.get("HOME"): - return os.path.join(os.environ["HOME"], ".bitflash") - return "." - - def main(argv): ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("outpoint", help="unspent output to prove, as txid:vout") - ap.add_argument("--datadir", default=default_datadir(), help="Bitflash data directory") + ap.add_argument("--datadir", default=chain.default_datadir(), help="Bitflash data directory") ap.add_argument("--max-blocks", type=int, default=0, help="number of blocks to scan; 0 means all block files") + ap.add_argument("--strict-duplicates", action="store_true", + help="fail instead of using legacy last-write-wins duplicate output handling") ap.add_argument("--json", action="store_true", help="print canonical JSON") ap.add_argument("--out", help="write canonical JSON proof to this path") 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 @@ #!/usr/bin/env python3 -"""Produce a deterministic Bitflash fair-launch audit report. - -This verifier is intentionally self-contained. It parses Bitflash block files -directly, without Berkeley DB, without the node binary, and without network -access. The canonical JSON output is stable for the same inputs and can be -timestamped later with OpenTimestamps or any other external notary. -""" +"""Produce a deterministic Bitflash fair-launch audit report.""" import argparse -import datetime as _dt import hashlib -import json -import os -import struct import sys from pathlib import Path - -MAGIC = bytes([0xBF, 0x20, 0x5C, 0xFD]) -COIN = 100_000_000 -GENESIS_HASH = "5bd7cb255d814e48cebcdfb72da4dc87b34bd774227f8ceb546c5640f4bdc169" -GENESIS_MERKLE = "1a45b4482532abb29b10e234d3f13132230525a339ecea91658ffa675a5b1325" -GENESIS_TIME = 1_753_315_200 -GENESIS_BITS = 0x1F0FFFFF -GENESIS_NONCE = 3141 -GENESIS_TEXT = "Bitflash 24/Jul/2026 Fair launch: one CPU one vote, no premine" - - -class ParseError(Exception): - pass - - -class Reader: - def __init__(self, data): - self.data = data - self.pos = 0 - - def take(self, n): - if self.pos + n > len(self.data): - raise ParseError("unexpected end of data") - out = self.data[self.pos:self.pos + n] - self.pos += n - return out - - def u8(self): - return self.take(1)[0] - - def u32(self): - return struct.unpack(" 1: - nxt = [] - for i in range(0, len(layer), 2): - left = layer[i] - right = layer[i + 1] if i + 1 < len(layer) else left - nxt.append(sha256d(left + right)) - layer = nxt - return layer[0][::-1].hex() - - -def parse_block(payload, file_name="", offset=0): - r = Reader(payload) - header = r.take(80) - version, = struct.unpack(" len(data): - break - size = struct.unpack(" len(data): - break - blocks.append(parse_block(data[start:end], path.name, start)) - if limit and len(blocks) >= limit: - return blocks - pos = end - return blocks - - -def genesis_transaction(): - script_sig = ( - bytes.fromhex("04ffff001d") - + b"\x01\x04" - + bytes([len(GENESIS_TEXT)]) - + GENESIS_TEXT.encode("ascii") - ) - pubkey_num = bytes.fromhex( - "5f1df16b2b704c8a578d0bbaf74d385cde12c11ee50455f3c438ef4c3fbcf649" - "b6de611feae06279a60939e028a8d65c10b73071a6f16719274855feb0fd8a6704" - ) - # The genesis source uses CScript << CBigNum("0x..."), and CBigNum::getvch() - # returns the minimal little-endian script number representation. - script_pub_key = bytes([65]) + pubkey_num[::-1] + b"\xac" - tx = { - "version": 1, - "vin": [{ - "prevout_hash": "0" * 64, - "prevout_n": 0xFFFFFFFF, - "script_sig_hex": script_sig.hex(), - "sequence": 0xFFFFFFFF, - }], - "vout": [{ - "value_satoshis": 50 * COIN, - "value_btf": format_money(50 * COIN), - "script_pub_key_hex": script_pub_key.hex(), - }], - "lock_time": 0, - } - tx["txid"] = hash_hex(ser_tx(tx)) - return tx - - -def reconstructed_genesis(): - tx = genesis_transaction() - header = ( - struct.pack(" len(self.data): - raise ParseError("unexpected end of data") - out = self.data[self.pos:self.pos + n] - self.pos += n - return out - - def u8(self): - return self.take(1)[0] - - def u32(self): - return struct.unpack(" 1: - nxt = [] - for i in range(0, len(layer), 2): - left = layer[i] - right = layer[i + 1] if i + 1 < len(layer) else left - nxt.append(sha256d(left + right)) - layer = nxt - return layer[0][::-1].hex() - - -def parse_block(payload, file_name="", offset=0): - r = Reader(payload) - header = r.take(80) - version, = struct.unpack(" len(data): - break - size = struct.unpack(" len(data): - break - blocks.append(parse_block(data[start:end], path.name, start)) - if max_blocks and len(blocks) >= max_blocks: - return blocks - pos = end - return blocks - - -def select_main_chain(blocks): - by_hash = {} - for block in blocks: - by_hash.setdefault(block["hash"], block) - if GENESIS_HASH not in by_hash: - raise ParseError("genesis block not found in block files") - - children = {} - for block in by_hash.values(): - children.setdefault(block["previous_hash"], []).append(block) - for v in children.values(): - v.sort(key=lambda b: (b["hash"], b["source_file"], b["source_offset"])) - - best = [] - stack = [(by_hash[GENESIS_HASH], [by_hash[GENESIS_HASH]])] - while stack: - block, path = stack.pop() - if len(path) > len(best) or (len(path) == len(best) and block["hash"] < best[-1]["hash"]): - best = path - for child in children.get(block["hash"], []): - stack.append((child, path + [child])) - return best - - -def is_coinbase(tx): - return ( - len(tx["vin"]) == 1 - and tx["vin"][0]["prevout_hash"] == "0" * 64 - and tx["vin"][0]["prevout_n"] == 0xFFFFFFFF - ) - - -def utxo_leaf_hash(outpoint, utxo): - # Text fields are length-delimited by separators and all variable data is - # hex or decimal ASCII. That keeps the commitment language-neutral. - payload = "|".join([ - "BTFUTXO1", - outpoint, - str(utxo["value_satoshis"]), - utxo["script_pub_key_hex"], - str(utxo["created_height"]), - utxo["created_txid"], - ]).encode("ascii") - return sha256(payload) - - -def utxo_root(utxos): - leaves = [utxo_leaf_hash(k, utxos[k]) for k in sorted(utxos)] - if not leaves: - return sha256(b"BTFUTXO1|empty").hex() - layer = leaves - while len(layer) > 1: - nxt = [] - for i in range(0, len(layer), 2): - left = layer[i] - right = layer[i + 1] if i + 1 < len(layer) else left - nxt.append(sha256(b"BTFNODE1|" + left + right)) - layer = nxt - return layer[0].hex() - - -def apply_blocks(blocks): - utxos = {} - spent = 0 - missing_spends = [] - duplicate_outputs = [] - total_coinbase = 0 - total_fees = 0 - normal_txs = 0 - - for height, block in enumerate(blocks): - if block["computed_merkle_root"] != block["merkle_root"]: - raise ParseError("bad merkle root at height %d" % height) - for tx_index, tx in enumerate(block["transactions"]): - coinbase = is_coinbase(tx) - input_value = 0 - output_value = sum(out["value_satoshis"] for out in tx["vout"]) - if coinbase: - total_coinbase += output_value - else: - normal_txs += 1 - for txin in tx["vin"]: - prev = "%s:%d" % (txin["prevout_hash"], txin["prevout_n"]) - prev_utxo = utxos.pop(prev, None) - if prev_utxo is None: - missing_spends.append({ - "height": height, - "txid": tx["txid"], - "missing_outpoint": prev, - }) - else: - spent += prev_utxo["value_satoshis"] - input_value += prev_utxo["value_satoshis"] - if input_value >= output_value: - total_fees += input_value - output_value - for n, out in enumerate(tx["vout"]): - key = "%s:%d" % (tx["txid"], n) - if key in utxos: - duplicate_outputs.append({"height": height, "outpoint": key}) - utxos[key] = { - "txid": tx["txid"], - "vout": n, - "value_satoshis": out["value_satoshis"], - "value_btf": out["value_btf"], - "script_pub_key_hex": out["script_pub_key_hex"], - "created_height": height, - "created_block_hash": block["hash"], - "created_txid": tx["txid"], - "coinbase": coinbase, - } - - total_unspent = sum(u["value_satoshis"] for u in utxos.values()) - return { - "utxos": utxos, - "stats": { - "utxo_count": len(utxos), - "spent_outputs": spent, - "total_unspent_satoshis": total_unspent, - "total_unspent_btf": format_money(total_unspent), - "total_coinbase_satoshis": total_coinbase, - "total_coinbase_btf": format_money(total_coinbase), - "coinbase_minus_unspent_satoshis": total_coinbase - total_unspent, - "coinbase_minus_unspent_btf": format_money(total_coinbase - total_unspent), - "total_fees_satoshis": total_fees, - "total_fees_btf": format_money(total_fees), - "normal_transactions": normal_txs, - "missing_spends": missing_spends, - "duplicate_outputs": duplicate_outputs, - }, - } - - -def format_money(value): - sign = "-" if value < 0 else "" - value = abs(value) - return "%s%d.%08d" % (sign, value // COIN, value % COIN) - - -def utc(ts): - return _dt.datetime.fromtimestamp(ts, _dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") - - -def default_datadir(): - if os.name == "nt" and os.environ.get("APPDATA"): - return os.path.join(os.environ["APPDATA"], "Bitflash") - if os.environ.get("HOME"): - return os.path.join(os.environ["HOME"], ".bitflash") - return "." - - -def canonical_bytes(report): - return (json.dumps(report, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + "\n").encode("ascii") +import bitflash_chain as chain -def build_report(raw_blocks, include_utxos=False): - blocks = select_main_chain(raw_blocks) - state = apply_blocks(blocks) +def build_report(raw_blocks, include_utxos=False, strict_duplicates=False): + blocks = chain.select_main_chain(raw_blocks) + state = chain.apply_blocks(blocks, strict_duplicates=strict_duplicates) utxos = state["utxos"] stats = state["stats"] tip = blocks[-1] - root = utxo_root(utxos) + root = chain.utxo_root(utxos) checks = { - "genesis_hash_matches_source": blocks[0]["hash"] == GENESIS_HASH, - "genesis_merkle_matches_source": blocks[0]["merkle_root"] == GENESIS_MERKLE, + "genesis_hash_matches_source": blocks[0]["hash"] == chain.GENESIS_HASH, + "genesis_merkle_matches_source": blocks[0]["merkle_root"] == chain.GENESIS_MERKLE, "all_scanned_merkle_roots_match": all(b["computed_merkle_root"] == b["merkle_root"] for b in blocks), "no_missing_spends": len(stats["missing_spends"]) == 0, } @@ -370,8 +31,8 @@ def build_report(raw_blocks, include_utxos=False): "chain": { "name": "Bitflash", "ticker": "BTF", - "message_start_hex": MAGIC.hex(), - "coin": COIN, + "message_start_hex": chain.MAGIC.hex(), + "coin": chain.COIN, }, "commitment": { "algorithm": "sorted-outpoint-merkle-sha256", @@ -400,7 +61,7 @@ def build_report(raw_blocks, include_utxos=False): def print_text(report): - data_hash = hashlib.sha256(canonical_bytes(report)).hexdigest() + data_hash = hashlib.sha256(chain.canonical_bytes(report)).hexdigest() print("Bitflash UTXO-set commitment verifier") print("schema: %s" % report["schema"]) print("canonical report sha256: %s" % data_hash) @@ -437,20 +98,26 @@ def print_text(report): def main(argv): ap = argparse.ArgumentParser(description=__doc__) - ap.add_argument("--datadir", default=default_datadir(), help="Bitflash data directory") + ap.add_argument("--datadir", default=chain.default_datadir(), help="Bitflash data directory") ap.add_argument("--max-blocks", type=int, default=0, help="number of blocks to scan; 0 means all block files") ap.add_argument("--include-utxos", action="store_true", help="include the full sorted UTXO map in JSON output") + ap.add_argument("--strict-duplicates", action="store_true", + help="fail instead of using legacy last-write-wins duplicate output handling") ap.add_argument("--json", action="store_true", help="print canonical JSON") ap.add_argument("--out", help="write canonical JSON to this path") args = ap.parse_args(argv) - blocks = read_blocks(args.datadir, args.max_blocks) + blocks = chain.read_blocks(args.datadir, args.max_blocks) if not blocks: raise SystemExit("no Bitflash blocks found in %s" % args.datadir) - report = build_report(blocks, include_utxos=args.include_utxos) - data = canonical_bytes(report) + try: + report = build_report(blocks, include_utxos=args.include_utxos, + strict_duplicates=args.strict_duplicates) + except chain.ParseError as e: + raise SystemExit(str(e)) + data = chain.canonical_bytes(report) if args.out: Path(args.out).write_bytes(data) if args.json: diff --git a/tests/scripts/test_bitflash_tools.py b/tests/scripts/test_bitflash_tools.py new file mode 100644 index 0000000..ff569e1 --- /dev/null +++ b/tests/scripts/test_bitflash_tools.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Regression tests for deterministic Bitflash Python tooling.""" + +import json +import os +import struct +import subprocess +import sys +import tempfile +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +SCRIPTS = ROOT / "scripts" +sys.path.insert(0, str(SCRIPTS)) + +import bitflash_chain as chain # noqa: E402 + + +def run(args, **kwargs): + env = dict(os.environ) + env["PYTHONIOENCODING"] = "utf-8" + return subprocess.run( + [sys.executable] + [str(a) for a in args], + cwd=str(ROOT), + env=env, + check=True, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + **kwargs + ) + + +def write_block_file(path, blocks): + with Path(path).open("wb") as f: + f.write(b"noise") + f.write(chain.MAGIC) + f.write(struct.pack(" Date: Tue, 11 Aug 2026 14:47:35 -0300 Subject: tools: address audit follow-ups --- scripts/bitflash_chain.py | 69 +++++++++++++++++++++----------------------- scripts/build-explorer.py | 5 +++- scripts/prove-utxo.py | 7 ++--- scripts/verify-utxo-proof.py | 10 +++---- 4 files changed, 44 insertions(+), 47 deletions(-) diff --git a/scripts/bitflash_chain.py b/scripts/bitflash_chain.py index f3688f1..4a3231c 100644 --- a/scripts/bitflash_chain.py +++ b/scripts/bitflash_chain.py @@ -4,6 +4,7 @@ import datetime as _dt import hashlib import json +import mmap import os import struct from pathlib import Path @@ -11,6 +12,9 @@ from pathlib import Path MAGIC = bytes([0xBF, 0x20, 0x5C, 0xFD]) MAX_BLOCK_PAYLOAD = 32 * 1024 * 1024 +UTXO_LEAF_DOMAIN = "BTFUTXO1" +UTXO_NODE_DOMAIN_TAG = "BTFNODE1" +UTXO_NODE_DOMAIN = (UTXO_NODE_DOMAIN_TAG + "|").encode("ascii") COIN = 100_000_000 GENESIS_HASH = "5bd7cb255d814e48cebcdfb72da4dc87b34bd774227f8ceb546c5640f4bdc169" GENESIS_MERKLE = "1a45b4482532abb29b10e234d3f13132230525a339ecea91658ffa675a5b1325" @@ -181,40 +185,33 @@ def iter_block_files(datadir): def iter_blocks_from_file(path): path = Path(path) with path.open("rb") as f: - window = f.read(4) - offset = 0 - while len(window) == 4: - if window != MAGIC: - nxt = f.read(1) - if not nxt: + f.seek(0, os.SEEK_END) + file_size = f.tell() + if file_size == 0: + return + f.seek(0) + mm = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) + try: + pos = 0 + while True: + offset = mm.find(MAGIC, pos) + if offset < 0 or offset + 8 > file_size: break - window = window[1:] + nxt - offset += 1 - continue - - size_bytes = f.read(4) - if len(size_bytes) != 4: - break - size = struct.unpack(" MAX_BLOCK_PAYLOAD: - offset += 1 - f.seek(offset) - window = f.read(4) - continue - payload_offset = offset + 8 - payload = f.read(size) - if len(payload) != size: - break - try: - yield parse_block(payload, path.name, payload_offset) - except ParseError: - offset += 1 - f.seek(offset) - window = f.read(4) - continue - - offset = f.tell() - window = f.read(4) + size = struct.unpack(" MAX_BLOCK_PAYLOAD or end > file_size: + pos = offset + 1 + continue + payload = mm[payload_offset:end] + try: + yield parse_block(payload, path.name, payload_offset) + except ParseError: + pos = offset + 1 + continue + pos = end + finally: + mm.close() def read_blocks_from_files(paths, max_blocks=0): @@ -265,7 +262,7 @@ def is_coinbase(tx): def utxo_leaf_hash(outpoint, utxo): payload = "|".join([ - "BTFUTXO1", + UTXO_LEAF_DOMAIN, outpoint, str(utxo["value_satoshis"]), utxo["script_pub_key_hex"], @@ -285,7 +282,7 @@ def utxo_root(utxos): for i in range(0, len(layer), 2): left = layer[i] right = layer[i + 1] if i + 1 < len(layer) else left - nxt.append(sha256(b"BTFNODE1|" + left + right)) + nxt.append(sha256(UTXO_NODE_DOMAIN + left + right)) layer = nxt return layer[0].hex() @@ -327,9 +324,9 @@ def apply_blocks(blocks, strict_duplicates=False): for n, out in enumerate(tx["vout"]): key = "%s:%d" % (tx["txid"], n) if key in utxos: - duplicate_outputs.append({"height": height, "outpoint": key}) if strict_duplicates: raise ParseError("duplicate output at height %d: %s" % (height, key)) + duplicate_outputs.append({"height": height, "outpoint": key}) utxos[key] = { "txid": tx["txid"], "vout": n, diff --git a/scripts/build-explorer.py b/scripts/build-explorer.py index 0d86b3c..97a7f4e 100755 --- a/scripts/build-explorer.py +++ b/scripts/build-explorer.py @@ -35,7 +35,10 @@ def hash160(b): def spk_info(script_hex): - spk = bytes.fromhex(script_hex) + try: + spk = bytes.fromhex(script_hex) + except ValueError: + return {"type": "malformed", "hex": script_hex[:80]} if ( len(spk) == 25 and spk[0] == 0x76 and spk[1] == 0xA9 and spk[2] == 0x14 and spk[23] == 0x88 and spk[24] == 0xAC diff --git a/scripts/prove-utxo.py b/scripts/prove-utxo.py index 9f2704b..105e677 100644 --- a/scripts/prove-utxo.py +++ b/scripts/prove-utxo.py @@ -14,7 +14,6 @@ from pathlib import Path import bitflash_chain as chain SCHEMA = "bitflash-utxo-inclusion-proof-1" -NODE_DOMAIN = b"BTFNODE1|" def canonical_bytes(obj): @@ -61,7 +60,7 @@ def build_merkle_proof(keys, leaf_hashes, index): for i in range(0, len(layer), 2): left = layer[i] right = layer[i + 1] if i + 1 < len(layer) else left - next_layer.append(hashlib.sha256(NODE_DOMAIN + left + right).digest()) + next_layer.append(hashlib.sha256(chain.UTXO_NODE_DOMAIN + left + right).digest()) layer = next_layer current_index //= 2 level += 1 @@ -106,8 +105,8 @@ def build_proof(args): }, "commitment": { "algorithm": "sorted-outpoint-merkle-sha256", - "leaf_domain": "BTFUTXO1", - "node_domain": "BTFNODE1", + "leaf_domain": chain.UTXO_LEAF_DOMAIN, + "node_domain": chain.UTXO_NODE_DOMAIN_TAG, "root": root, "leaf_count": len(keys), }, diff --git a/scripts/verify-utxo-proof.py b/scripts/verify-utxo-proof.py index c29caa9..50d9356 100644 --- a/scripts/verify-utxo-proof.py +++ b/scripts/verify-utxo-proof.py @@ -10,8 +10,6 @@ from pathlib import Path import bitflash_chain as chain SCHEMA = "bitflash-utxo-inclusion-proof-1" -LEAF_DOMAIN = "BTFUTXO1" -NODE_DOMAIN = b"BTFNODE1|" def canonical_bytes(obj): @@ -32,9 +30,9 @@ def verify(proof, expected_root=None): commitment = proof.get("commitment", {}) if commitment.get("algorithm") != "sorted-outpoint-merkle-sha256": failures.append("unexpected commitment algorithm") - if commitment.get("leaf_domain") != LEAF_DOMAIN: + if commitment.get("leaf_domain") != chain.UTXO_LEAF_DOMAIN: failures.append("unexpected leaf domain") - if commitment.get("node_domain") != "BTFNODE1": + if commitment.get("node_domain") != chain.UTXO_NODE_DOMAIN_TAG: failures.append("unexpected node domain") target = proof.get("target", {}) @@ -64,9 +62,9 @@ def verify(proof, expected_root=None): failures.append("bad sibling hash length at proof level %d" % i) side = step.get("side") if side == "right": - current = chain.sha256(NODE_DOMAIN + current + sibling) + current = chain.sha256(chain.UTXO_NODE_DOMAIN + current + sibling) elif side == "left": - current = chain.sha256(NODE_DOMAIN + sibling + current) + current = chain.sha256(chain.UTXO_NODE_DOMAIN + sibling + current) else: failures.append("bad sibling side at proof level %d" % i) -- cgit v1.2.3 From 0270b35528388241a6e6b843503cea342c640c30 Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Tue, 11 Aug 2026 15:11:40 -0300 Subject: tests: add deterministic chain tool fixtures --- tests/fixtures/chain-tools/explorer-blocks.json | 1 + tests/fixtures/chain-tools/fair-launch-report.json | 1 + .../chain-tools/utxo-proof-verification.json | 1 + tests/fixtures/chain-tools/utxo-proof.json | 1 + tests/fixtures/chain-tools/utxo-report.json | 1 + tests/scripts/test_bitflash_tools.py | 178 ++++++++++++++++++++- 6 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/chain-tools/explorer-blocks.json create mode 100644 tests/fixtures/chain-tools/fair-launch-report.json create mode 100644 tests/fixtures/chain-tools/utxo-proof-verification.json create mode 100644 tests/fixtures/chain-tools/utxo-proof.json create mode 100644 tests/fixtures/chain-tools/utxo-report.json 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 index ff569e1..4b18d1e 100644 --- a/tests/scripts/test_bitflash_tools.py +++ b/tests/scripts/test_bitflash_tools.py @@ -12,6 +12,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] SCRIPTS = ROOT / "scripts" +FIXTURES = ROOT / "tests" / "fixtures" / "chain-tools" sys.path.insert(0, str(SCRIPTS)) import bitflash_chain as chain # noqa: E402 @@ -45,12 +46,130 @@ def write_block_file(path, blocks): f.write(payload) +def p2pkh_script(byte): + return "76a914%s88ac" % (bytes([byte]) * 20).hex() + + +def make_tx(vin, vout): + tx = { + "version": 1, + "vin": vin, + "vout": vout, + "lock_time": 0, + } + tx["txid"] = chain.hash_hex(chain.ser_tx(tx)) + return tx + + +def make_coinbase(label, script_byte, value=50 * chain.COIN): + return make_tx( + [{ + "prevout_hash": "0" * 64, + "prevout_n": 0xFFFFFFFF, + "script_sig_hex": label.encode("ascii").hex(), + "sequence": 0xFFFFFFFF, + }], + [{ + "value_satoshis": value, + "value_btf": chain.format_money(value), + "script_pub_key_hex": p2pkh_script(script_byte), + }], + ) + + +def make_spend(prev_txid, prev_n, outputs, label): + return make_tx( + [{ + "prevout_hash": prev_txid, + "prevout_n": prev_n, + "script_sig_hex": label.encode("ascii").hex(), + "sequence": 0xFFFFFFFF, + }], + [{ + "value_satoshis": value, + "value_btf": chain.format_money(value), + "script_pub_key_hex": p2pkh_script(script_byte), + } for value, script_byte in outputs], + ) + + +def make_block(prev_hash, txs, ntime, nonce): + merkle = chain.merkle_root([tx["txid"] for tx in txs]) + header = ( + struct.pack(" Date: Tue, 11 Aug 2026 15:38:31 -0300 Subject: net: add optional SOCKS5 outbound proxy --- README.md | 6 ++ src/Makefile | 7 +- src/btfrv.cpp | 9 ++ src/main_gui.cpp | 16 +++- src/makefile.mingw | 7 +- src/net.cpp | 4 + src/nostr.cpp | 50 +---------- src/proxy.cpp | 254 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/proxy.h | 31 +++++++ src/readme.txt | 3 + src/selftest.cpp | 42 ++++++++- 11 files changed, 374 insertions(+), 55 deletions(-) create mode 100644 src/proxy.cpp create mode 100644 src/proxy.h diff --git a/README.md b/README.md index c28b0f1..1b68ccd 100644 --- a/README.md +++ b/README.md @@ -310,6 +310,7 @@ Other options worth knowing: ```bash -datadir=PATH # wallet and chain data elsewhere -port=N # P2P listen port, default 8433 +-socks=HOST:PORT # SOCKS5 for outbound Nostr and .btf rendezvous dials -debug # verbose log; without it debug.log is nearly silent -help # full list ``` @@ -318,6 +319,11 @@ Other options worth knowing: needed — the data directory takes an exclusive lock, so a second node pointed at the same one will refuse to start. +`-socks=127.0.0.1:9050` routes outbound Nostr discovery and `.btf` rendezvous +dials through a local SOCKS5 proxy such as Tor. When it is set, the node skips +plain HTTP external-IP probes instead of leaking a direct request outside the +proxy. + ### When something looks wrong **Menu > Diagnostics**, and the same report in `debug.log` every ten minutes: 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 \ -Wno-narrowing -Wno-unused-result CXXFLAGS = -std=gnu++14 -O2 $(WARNFLAGS) -fpermissive -pthread \ - -DSECP256K1_STATIC \ + -DSECP256K1_STATIC -DBITFLASH_NODE_BUILD \ -I$(SECP_INC) -I$(RANDOMX_DIR)/src \ -I/usr/include/nlohmann \ -I$(IMGUI_DIR) @@ -53,7 +53,7 @@ LIBS = -L$(SECP_LIB) -lsecp256k1 \ HEADERS = headers_core.h headers.h util.h main.h serialize.h uint256.h \ key.h bignum.h script.h db.h base58.h net.h nostr.h \ randomx_pow.h btfaddr.h btfchan.h btfrv.h btftunnel.h market.h \ - selftest.h walletcmd.h compat.h sockcount.h font_roboto.h + selftest.h walletcmd.h compat.h sockcount.h proxy.h font_roboto.h IMGUI_OBJS = obj/imgui.o obj/imgui_draw.o obj/imgui_tables.o \ 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 \ NODE_OBJS = obj/util.o obj/script.o obj/db.o obj/net.o obj/main.o \ obj/market.o obj/sha.o obj/nostr.o obj/randomx_pow.o \ obj/btfaddr.o obj/btfchan.o obj/btfrv.o obj/btftunnel.o obj/rpc.o \ - obj/selftest.o obj/bip32.o obj/walletcmd.o + obj/selftest.o obj/bip32.o obj/walletcmd.o obj/proxy.o GUI_OBJS = obj/gui.o obj/main_gui.o @@ -193,5 +193,6 @@ tests: test_bip32 bitflash-node ./bitflash-node -selftest=consensus-limits ./bitflash-node -selftest=pool-stratum ./bitflash-node -selftest=parse-money + ./bitflash-node -selftest=socks5-proxy .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 @@ // Windows/Winsock implementation (Layer 6 will #ifdef this for Linux/BSD sockets). #include "btfrv.h" +#ifdef BITFLASH_NODE_BUILD +#include "proxy.h" +#endif // Portable sockets: Winsock on Windows, BSD sockets on Linux/POSIX (for the // headless relay `bitflashd` that runs on the VPS). @@ -92,6 +95,11 @@ static bool WriteN(SOCKET s, const void* buf, int n) static SOCKET ConnectTo(const char* host, unsigned short port) { +#ifdef BITFLASH_NODE_BUILD + SOCKET s = BtfConnectSocket(host, port, SOCK_RV_DIAL, 10); + if (s == INVALID_SOCKET) + return INVALID_SOCKET; +#else struct addrinfo hints, *res = NULL; memset(&hints, 0, sizeof(hints)); hints.ai_family = AF_INET; @@ -119,6 +127,7 @@ static SOCKET ConnectTo(const char* host, unsigned short port) CLOSESOCK(s); freeaddrinfo(res); return INVALID_SOCKET; } freeaddrinfo(res); +#endif // Clear the timeout -- the socket is now a live tunnel and must not // 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..90a6050 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -1,6 +1,7 @@ // Bitflash entry point -- starts node threads then runs GUI (or headless). #include "headers_core.h" +#include "proxy.h" #include "selftest.h" #include "walletcmd.h" #include // hardware_concurrency, to sanity-check /genproclimit @@ -110,7 +111,8 @@ static void PrintUsage() printf(" /debug\n"); printf(" /gen\n"); printf(" /nogui or /daemon\n"); - printf(" /selftest=wallet-keypool, wallet-hd, wallet-format, wallet-crypto, or wallet-encrypt\n"); + printf(" /selftest=wallet-keypool, wallet-hd, wallet-format, wallet-crypto, wallet-encrypt,\n"); + printf(" net-message, consensus-limits, pool-stratum, parse-money, or socks5-proxy\n"); printf("\n"); printf("Mining mode:\n"); printf(" /operator\n"); @@ -135,6 +137,7 @@ static void PrintUsage() printf("\n"); printf("Network:\n"); printf(" /port=N (P2P listen port, default 8433)\n"); + printf(" /socks=HOST:PORT (SOCKS5 proxy for outbound Nostr and .btf relay dials)\n"); printf(" /btfseed=ADDRESS:ENCHEX (extra bootstrap peer, repeatable)\n"); printf("\n"); printf("Wallet:\n"); @@ -282,6 +285,17 @@ static void ParseStartupArguments(int argc, char* argv[]) if (!announceRelay.empty()) strBtfAnnounceRelay = announceRelay; + string socksProxy = argval2(argc, argv, "/socks", "-socks"); + if (!socksProxy.empty()) + { + string err; + if (!BtfSetSocks5Proxy(socksProxy, err)) + fprintf(stderr, "Ignoring /socks=%s: %s\n", socksProxy.c_str(), err.c_str()); + else + fprintf(stderr, "SOCKS5 proxy enabled for outbound discovery/relay dials: %s\n", + BtfSocks5ProxyName().c_str()); + } + // /btfseed=ADDRESS:ENCHEX -- extra bootstrap peers, repeatable. Useful for // testing the seed path and for private networks that ship no compiled list. for (int i = 1; i < argc; i++) diff --git a/src/makefile.mingw b/src/makefile.mingw index b946e50..28e925c 100644 --- a/src/makefile.mingw +++ b/src/makefile.mingw @@ -21,7 +21,7 @@ WARNFLAGS = -Wformat -Werror=format \ # 64, which capped this node at 63 watchable peers no matter how many it had. CXXFLAGS = -std=gnu++14 -O2 $(WARNFLAGS) -fpermissive -pthread \ -DWIN32 -DNOPCH -DFD_SETSIZE=1024 \ - -DSECP256K1_STATIC \ + -DSECP256K1_STATIC -DBITFLASH_NODE_BUILD \ -I$(DEPS)/include -I$(RANDOMX)/src \ -I$(DEPS)/include/nlohmann \ -IC:/msys64/ucrt64/include \ @@ -42,7 +42,7 @@ IMGUI_OBJS = obj/imgui.o obj/imgui_draw.o obj/imgui_tables.o \ NODE_OBJS = obj/util.o obj/script.o obj/db.o obj/net.o obj/main.o \ obj/market.o obj/sha.o obj/nostr.o obj/randomx_pow.o \ obj/btfaddr.o obj/btfchan.o obj/btfrv.o obj/btftunnel.o obj/rpc.o \ - obj/selftest.o obj/bip32.o obj/walletcmd.o + obj/selftest.o obj/bip32.o obj/walletcmd.o obj/proxy.o GUI_OBJS = obj/gui.o obj/main_gui.o obj/bitflash_res.o @@ -56,7 +56,7 @@ GUI_OBJS = obj/gui.o obj/main_gui.o obj/bitflash_res.o HEADERS = headers_core.h headers.h util.h main.h serialize.h uint256.h \ key.h bignum.h script.h db.h base58.h net.h nostr.h \ randomx_pow.h btfaddr.h btfchan.h btfrv.h btftunnel.h market.h \ - selftest.h walletcmd.h sockcount.h compat.h font_roboto.h + selftest.h walletcmd.h sockcount.h proxy.h compat.h font_roboto.h all: bitflash.exe @@ -117,3 +117,4 @@ tests: test_bip32.exe bitflash.exe ./bitflash.exe -selftest=net-message ./bitflash.exe -selftest=consensus-limits ./bitflash.exe -selftest=pool-stratum + ./bitflash.exe -selftest=socks5-proxy diff --git a/src/net.cpp b/src/net.cpp index b9bb95d..fe9964e 100644 --- a/src/net.cpp +++ b/src/net.cpp @@ -17,6 +17,7 @@ #include #include "btfaddr.h" #include "btftunnel.h" +#include "proxy.h" void ThreadReconnectCachedBtfPeers(void* parg); void ThreadMessageHandler2(void* parg); @@ -361,6 +362,9 @@ string GetDiagnosticsText() bool GetMyExternalIP(unsigned int& ipRet) { + if (BtfSocks5ProxyEnabled()) + return error("GetMyExternalIP() skipped while SOCKS5 proxy is enabled\n"); + // Try several plain-text IP echo services in order. // Each returns just the IPv4 address as the first line of the HTTP body. 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 @@ #include "btfaddr.h" #include "btfchan.h" +#include "proxy.h" using json = nlohmann::json; @@ -540,53 +541,8 @@ public: size_t colon = host.find(':'); if (colon != string::npos) { port = atoi(host.substr(colon + 1).c_str()); host = host.substr(0, colon); } - // Resolve and connect (TCP). AF_UNSPEC so we get both A and AAAA - // records back and try them in the order the resolver prefers, - // rather than hard-failing whenever the IPv4 path to a relay is - // filtered/rate-limited (increasingly common for CDN-fronted - // relays) while a working IPv6 route exists right there in the - // same getaddrinfo() result. - struct addrinfo hints, *res = NULL, *rp = NULL; - memset(&hints, 0, sizeof(hints)); - hints.ai_family = AF_UNSPEC; - hints.ai_socktype = SOCK_STREAM; - char portstr[16]; sprintf(portstr, "%d", port); - if (getaddrinfo(host.c_str(), portstr, &hints, &res) != 0 || !res) - return error("Nostr: getaddrinfo %s failed", host.c_str()); - - bool fConnected = false; - for (rp = res; rp != NULL; rp = rp->ai_next) - { - hSocket = BtfSocketTag(socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol), SOCK_NOSTR); - if (hSocket == INVALID_SOCKET) - continue; - - // Socket receive/send timeout: Windows takes a DWORD of - // milliseconds; POSIX takes a struct timeval. -#ifdef _WIN32 - DWORD tv = timeoutSec * 1000; - setsockopt(hSocket, SOL_SOCKET, SO_RCVTIMEO, (char*)&tv, sizeof(tv)); - setsockopt(hSocket, SOL_SOCKET, SO_SNDTIMEO, (char*)&tv, sizeof(tv)); -#else - struct timeval tv; - tv.tv_sec = timeoutSec; - tv.tv_usec = 0; - setsockopt(hSocket, SOL_SOCKET, SO_RCVTIMEO, (char*)&tv, sizeof(tv)); - setsockopt(hSocket, SOL_SOCKET, SO_SNDTIMEO, (char*)&tv, sizeof(tv)); -#endif - - if (connect(hSocket, rp->ai_addr, (int)rp->ai_addrlen) == 0) - { - fConnected = true; - break; - } - - BtfCloseSocket(hSocket); - hSocket = INVALID_SOCKET; - } - freeaddrinfo(res); - - if (!fConnected) + hSocket = BtfConnectSocket(host, (unsigned short)port, SOCK_NOSTR, timeoutSec); + if (hSocket == INVALID_SOCKET) return error("Nostr: connect %s:%d failed", host.c_str(), port); // TLS diff --git a/src/proxy.cpp b/src/proxy.cpp new file mode 100644 index 0000000..1e62ff9 --- /dev/null +++ b/src/proxy.cpp @@ -0,0 +1,254 @@ +// Copyright (c) 2026 Bitflash developers +// Distributed under the MIT/X11 software license. + +#include "headers_core.h" +#include "proxy.h" +#include "sockcount.h" + +static CCriticalSection cs_socks5; +static bool g_fSocks5Proxy = false; +static std::string g_socks5Host; +static unsigned short g_socks5Port = 0; + +static void SetSocketTimeout(SOCKET s, int nTimeoutSecs) +{ + if (nTimeoutSecs <= 0) + return; +#ifdef _WIN32 + DWORD tv = (DWORD)nTimeoutSecs * 1000; + setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, (const char*)&tv, sizeof(tv)); + setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, (const char*)&tv, sizeof(tv)); +#else + struct timeval tv; + tv.tv_sec = nTimeoutSecs; + tv.tv_usec = 0; + setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); +#endif +} + +static bool ReadN(SOCKET s, void* buf, int n) +{ + char* p = (char*)buf; + int off = 0; + while (off < n) + { + int r = recv(s, p + off, n - off, 0); + if (r <= 0) + return false; + off += r; + } + return true; +} + +static bool WriteN(SOCKET s, const void* buf, int n) +{ + const char* p = (const char*)buf; + int off = 0; + while (off < n) + { + int r = send(s, p + off, n - off, 0); + if (r <= 0) + return false; + off += r; + } + return true; +} + +static bool ValidProxyHostChar(char c) +{ + return isalnum((unsigned char)c) || c == '.' || c == '-' || c == '_'; +} + +bool BtfParseSocks5Proxy(const std::string& spec, std::string& hostOut, + unsigned short& portOut, std::string& errOut) +{ + hostOut.clear(); + portOut = 0; + errOut.clear(); + + size_t colon = spec.rfind(':'); + if (colon == std::string::npos || colon == 0 || colon + 1 >= spec.size()) + { + errOut = "expected HOST:PORT"; + return false; + } + + std::string host = spec.substr(0, colon); + std::string port = spec.substr(colon + 1); + if (host.size() > 255) + { + errOut = "host is too long"; + return false; + } + for (size_t i = 0; i < host.size(); i++) + { + if (!ValidProxyHostChar(host[i])) + { + errOut = "host contains unsupported characters"; + return false; + } + } + for (size_t i = 0; i < port.size(); i++) + { + if (!isdigit((unsigned char)port[i])) + { + errOut = "port is not numeric"; + return false; + } + } + int nPort = atoi(port.c_str()); + if (nPort <= 0 || nPort > 65535) + { + errOut = "port is out of range"; + return false; + } + + hostOut = host; + portOut = (unsigned short)nPort; + return true; +} + +bool BtfSetSocks5Proxy(const std::string& spec, std::string& errOut) +{ + std::string host; + unsigned short port = 0; + if (!BtfParseSocks5Proxy(spec, host, port, errOut)) + return false; + CRITICAL_BLOCK(cs_socks5) + { + g_socks5Host = host; + g_socks5Port = port; + g_fSocks5Proxy = true; + } + return true; +} + +void BtfClearSocks5Proxy() +{ + CRITICAL_BLOCK(cs_socks5) + { + g_fSocks5Proxy = false; + g_socks5Host.clear(); + g_socks5Port = 0; + } +} + +bool BtfSocks5ProxyEnabled() +{ + CRITICAL_BLOCK(cs_socks5) + return g_fSocks5Proxy; + return false; +} + +std::string BtfSocks5ProxyName() +{ + CRITICAL_BLOCK(cs_socks5) + { + if (!g_fSocks5Proxy) + return ""; + return strprintf("%s:%u", g_socks5Host.c_str(), (unsigned)g_socks5Port); + } + return ""; +} + +static SOCKET ConnectDirect(const std::string& host, unsigned short port, + int nSockSite, int nTimeoutSecs) +{ + struct addrinfo hints, *res = NULL, *rp = NULL; + memset(&hints, 0, sizeof(hints)); + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + char portstr[16]; + sprintf(portstr, "%u", (unsigned)port); + if (getaddrinfo(host.c_str(), portstr, &hints, &res) != 0 || !res) + return INVALID_SOCKET; + + SOCKET s = INVALID_SOCKET; + for (rp = res; rp != NULL; rp = rp->ai_next) + { + s = BtfSocketTag(socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol), nSockSite); + if (s == INVALID_SOCKET) + continue; + SetSocketTimeout(s, nTimeoutSecs); + if (connect(s, rp->ai_addr, (int)rp->ai_addrlen) == 0) + break; + BtfCloseSocket(s); + s = INVALID_SOCKET; + } + freeaddrinfo(res); + return s; +} + +static bool Socks5Connect(SOCKET s, const std::string& destHost, + unsigned short destPort) +{ + if (destHost.empty() || destHost.size() > 255) + return false; + + unsigned char hello[3] = { 0x05, 0x01, 0x00 }; // SOCKS5, one method, no auth + if (!WriteN(s, hello, sizeof(hello))) + return false; + unsigned char choice[2] = { 0, 0 }; + if (!ReadN(s, choice, sizeof(choice)) || choice[0] != 0x05 || choice[1] != 0x00) + return false; + + std::vector req; + req.reserve(7 + destHost.size()); + req.push_back(0x05); // version + req.push_back(0x01); // CONNECT + req.push_back(0x00); // reserved + req.push_back(0x03); // domain name; proxy resolves, avoiding local DNS leak + req.push_back((unsigned char)destHost.size()); + req.insert(req.end(), destHost.begin(), destHost.end()); + req.push_back((unsigned char)(destPort >> 8)); + req.push_back((unsigned char)(destPort & 0xff)); + if (!WriteN(s, &req[0], (int)req.size())) + return false; + + unsigned char rep[4] = {0,0,0,0}; + if (!ReadN(s, rep, sizeof(rep)) || rep[0] != 0x05 || rep[1] != 0x00) + return false; + int nAddr = 0; + if (rep[3] == 0x01) nAddr = 4; + else if (rep[3] == 0x04) nAddr = 16; + else if (rep[3] == 0x03) + { + unsigned char len = 0; + if (!ReadN(s, &len, 1)) + return false; + nAddr = len; + } + else + return false; + + std::vector discard(nAddr + 2); + return ReadN(s, &discard[0], (int)discard.size()); +} + +SOCKET BtfConnectSocket(const std::string& destHost, unsigned short destPort, + int nSockSite, int nTimeoutSecs) +{ + std::string proxyHost; + unsigned short proxyPort = 0; + bool fProxy = false; + CRITICAL_BLOCK(cs_socks5) + { + fProxy = g_fSocks5Proxy; + proxyHost = g_socks5Host; + proxyPort = g_socks5Port; + } + + if (!fProxy) + return ConnectDirect(destHost, destPort, nSockSite, nTimeoutSecs); + + SOCKET s = ConnectDirect(proxyHost, proxyPort, nSockSite, nTimeoutSecs); + if (s == INVALID_SOCKET) + return INVALID_SOCKET; + if (!Socks5Connect(s, destHost, destPort)) + { + BtfCloseSocket(s); + return INVALID_SOCKET; + } + return s; +} diff --git a/src/proxy.h b/src/proxy.h new file mode 100644 index 0000000..b5e69c8 --- /dev/null +++ b/src/proxy.h @@ -0,0 +1,31 @@ +// Copyright (c) 2026 Bitflash developers +// Distributed under the MIT/X11 software license. +// +// Optional SOCKS5 outbound proxy support. This is transport plumbing only: +// no consensus, wallet, mining, or .btf identity data depends on it. + +#ifndef BITFLASH_PROXY_H +#define BITFLASH_PROXY_H + +#ifdef _WIN32 +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN 1 +#endif +#include +#else +#include "compat.h" +#endif + +#include + +bool BtfParseSocks5Proxy(const std::string& spec, std::string& hostOut, + unsigned short& portOut, std::string& errOut); +bool BtfSetSocks5Proxy(const std::string& spec, std::string& errOut); +void BtfClearSocks5Proxy(); +bool BtfSocks5ProxyEnabled(); +std::string BtfSocks5ProxyName(); + +SOCKET BtfConnectSocket(const std::string& destHost, unsigned short destPort, + int nSockSite, int nTimeoutSecs); + +#endif diff --git a/src/readme.txt b/src/readme.txt index b8b89c1..96f32c3 100644 --- a/src/readme.txt +++ b/src/readme.txt @@ -42,6 +42,9 @@ The resulting executable is src/bitflash.exe. Command-line options (in addition to the original ones) ------------------------------------------------------- /port=N listen on P2P port N (default 8433) + /socks=HOST:PORT + route outbound Nostr and .btf rendezvous dials through a + SOCKS5 proxy, e.g. /socks=127.0.0.1:9050 for local Tor /solomine mine without requiring a connected peer (local testing) /operator operator mode (run pool server) /participant=ADDR diff --git a/src/selftest.cpp b/src/selftest.cpp index dcb9192..5b36b9d 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -7,6 +7,7 @@ #include "headers_core.h" #include "bip32.h" +#include "proxy.h" #include "selftest.h" #include "walletcmd.h" @@ -1253,6 +1254,43 @@ static int RunParseMoneySelfTest() return nFail == 0 ? 0 : 1; } +static int RunSocks5ProxySelfTest() +{ + printf("socks5-proxy self-test\n"); + int nFail = 0; + std::string host, err; + unsigned short port = 0; + + nFail += Check(BtfParseSocks5Proxy("127.0.0.1:9050", host, port, err) && + host == "127.0.0.1" && port == 9050, + "parses local Tor proxy endpoint") ? 0 : 1; + nFail += Check(BtfParseSocks5Proxy("localhost:9050", host, port, err) && + host == "localhost" && port == 9050, + "parses hostname proxy endpoint") ? 0 : 1; + nFail += Check(!BtfParseSocks5Proxy("localhost", host, port, err), + "rejects missing port") ? 0 : 1; + nFail += Check(!BtfParseSocks5Proxy("localhost:0", host, port, err), + "rejects zero port") ? 0 : 1; + nFail += Check(!BtfParseSocks5Proxy("localhost:70000", host, port, err), + "rejects out-of-range port") ? 0 : 1; + nFail += Check(!BtfParseSocks5Proxy("user:pass@localhost:9050", host, port, err), + "rejects unsupported authenticated proxy syntax") ? 0 : 1; + + BtfClearSocks5Proxy(); + nFail += Check(!BtfSocks5ProxyEnabled(), "starts disabled after clear") ? 0 : 1; + nFail += Check(BtfSetSocks5Proxy("127.0.0.1:9050", err) && + BtfSocks5ProxyEnabled() && + BtfSocks5ProxyName() == "127.0.0.1:9050", + "enables parsed SOCKS5 proxy") ? 0 : 1; + BtfClearSocks5Proxy(); + nFail += Check(!BtfSocks5ProxyEnabled(), "clear disables proxy") ? 0 : 1; + + printf("%s (%d failure%s)\n", nFail == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", + nFail, nFail == 1 ? "" : "s"); + fflush(stdout); + return nFail == 0 ? 0 : 1; +} + int RunSelfTest(const std::string& name) { AttachTerminal(); @@ -1275,8 +1313,10 @@ int RunSelfTest(const std::string& name) return RunPoolStratumSelfTest(); if (name == "parse-money") return RunParseMoneySelfTest(); + if (name == "socks5-proxy") + return RunSocks5ProxySelfTest(); printf("Unknown self-test '%s'\n", name.c_str()); - printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-crypto, wallet-encrypt, net-message, consensus-limits, pool-stratum\n"); + 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"); return 1; } -- cgit v1.2.3 From 066379ebcb1dac720221fe1a1841fa6f59df21d9 Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Tue, 11 Aug 2026 15:51:52 -0300 Subject: net: harden SOCKS5 proxy parsing and tests --- README.md | 2 +- src/proxy.cpp | 115 ++++++++++++++++++++++++------ src/readme.txt | 1 + src/selftest.cpp | 212 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 309 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 1b68ccd..353da25 100644 --- a/README.md +++ b/README.md @@ -322,7 +322,7 @@ the same one will refuse to start. `-socks=127.0.0.1:9050` routes outbound Nostr discovery and `.btf` rendezvous dials through a local SOCKS5 proxy such as Tor. When it is set, the node skips plain HTTP external-IP probes instead of leaking a direct request outside the -proxy. +proxy. IPv6 proxy endpoints use brackets, for example `-socks=[::1]:9050`. ### When something looks wrong diff --git a/src/proxy.cpp b/src/proxy.cpp index 1e62ff9..d7dc324 100644 --- a/src/proxy.cpp +++ b/src/proxy.cpp @@ -5,6 +5,8 @@ #include "proxy.h" #include "sockcount.h" +#include + static CCriticalSection cs_socks5; static bool g_fSocks5Proxy = false; static std::string g_socks5Host; @@ -60,6 +62,40 @@ static bool ValidProxyHostChar(char c) return isalnum((unsigned char)c) || c == '.' || c == '-' || c == '_'; } +static bool ValidProxyIpv6HostChar(char c) +{ + return isxdigit((unsigned char)c) || c == ':' || c == '.'; +} + +static bool ParsePort(const std::string& port, unsigned short& portOut, + std::string& errOut) +{ + if (port.empty()) + { + errOut = "port is empty"; + return false; + } + for (size_t i = 0; i < port.size(); i++) + { + if (!isdigit((unsigned char)port[i])) + { + errOut = "port is not numeric"; + return false; + } + } + + errno = 0; + char* end = NULL; + long nPort = strtol(port.c_str(), &end, 10); + if (errno == ERANGE || end == NULL || *end != '\0' || nPort <= 0 || nPort > 65535) + { + errOut = "port is out of range"; + return false; + } + portOut = (unsigned short)nPort; + return true; +} + bool BtfParseSocks5Proxy(const std::string& spec, std::string& hostOut, unsigned short& portOut, std::string& errOut) { @@ -67,45 +103,76 @@ bool BtfParseSocks5Proxy(const std::string& spec, std::string& hostOut, portOut = 0; errOut.clear(); - size_t colon = spec.rfind(':'); - if (colon == std::string::npos || colon == 0 || colon + 1 >= spec.size()) + std::string host; + std::string port; + bool fBracketedIpv6 = false; + if (!spec.empty() && spec[0] == '[') { - errOut = "expected HOST:PORT"; - return false; + size_t close = spec.find(']'); + if (close == std::string::npos || close == 1 || close + 1 >= spec.size() || spec[close + 1] != ':') + { + errOut = "expected [IPv6]:PORT"; + return false; + } + host = spec.substr(1, close - 1); + port = spec.substr(close + 2); + fBracketedIpv6 = true; + } + else + { + size_t colon = spec.rfind(':'); + if (colon == std::string::npos || colon == 0 || colon + 1 >= spec.size()) + { + errOut = "expected HOST:PORT"; + return false; + } + host = spec.substr(0, colon); + port = spec.substr(colon + 1); + if (host.find(':') != std::string::npos) + { + errOut = "IPv6 proxy hosts must use [ADDR]:PORT"; + return false; + } } - std::string host = spec.substr(0, colon); - std::string port = spec.substr(colon + 1); if (host.size() > 255) { errOut = "host is too long"; return false; } - for (size_t i = 0; i < host.size(); i++) + if (fBracketedIpv6) { - if (!ValidProxyHostChar(host[i])) + bool fHasColon = false; + for (size_t i = 0; i < host.size(); i++) + { + fHasColon = fHasColon || host[i] == ':'; + if (!ValidProxyIpv6HostChar(host[i])) + { + errOut = "IPv6 proxy host contains unsupported characters"; + return false; + } + } + if (!fHasColon) { - errOut = "host contains unsupported characters"; + errOut = "bracketed proxy host is not IPv6"; return false; } } - for (size_t i = 0; i < port.size(); i++) + else { - if (!isdigit((unsigned char)port[i])) + for (size_t i = 0; i < host.size(); i++) { - errOut = "port is not numeric"; - return false; + if (!ValidProxyHostChar(host[i])) + { + errOut = "host contains unsupported characters"; + return false; + } } } - int nPort = atoi(port.c_str()); - if (nPort <= 0 || nPort > 65535) - { - errOut = "port is out of range"; + if (!ParsePort(port, portOut, errOut)) return false; - } hostOut = host; - portOut = (unsigned short)nPort; return true; } @@ -147,6 +214,8 @@ std::string BtfSocks5ProxyName() { if (!g_fSocks5Proxy) return ""; + if (g_socks5Host.find(':') != std::string::npos) + return strprintf("[%s]:%u", g_socks5Host.c_str(), (unsigned)g_socks5Port); return strprintf("%s:%u", g_socks5Host.c_str(), (unsigned)g_socks5Port); } return ""; @@ -207,8 +276,14 @@ static bool Socks5Connect(SOCKET s, const std::string& destHost, return false; unsigned char rep[4] = {0,0,0,0}; - if (!ReadN(s, rep, sizeof(rep)) || rep[0] != 0x05 || rep[1] != 0x00) + if (!ReadN(s, rep, sizeof(rep)) || rep[0] != 0x05) return false; + if (rep[1] != 0x00) + { + error("SOCKS5 CONNECT to %s:%u failed with reply 0x%02x\n", + destHost.c_str(), (unsigned)destPort, (unsigned)rep[1]); + return false; + } int nAddr = 0; if (rep[3] == 0x01) nAddr = 4; else if (rep[3] == 0x04) nAddr = 16; diff --git a/src/readme.txt b/src/readme.txt index 96f32c3..1fa77df 100644 --- a/src/readme.txt +++ b/src/readme.txt @@ -45,6 +45,7 @@ Command-line options (in addition to the original ones) /socks=HOST:PORT route outbound Nostr and .btf rendezvous dials through a SOCKS5 proxy, e.g. /socks=127.0.0.1:9050 for local Tor + or /socks=[::1]:9050 for an IPv6 loopback proxy /solomine mine without requiring a connected peer (local testing) /operator operator mode (run pool server) /participant=ADDR diff --git a/src/selftest.cpp b/src/selftest.cpp index 5b36b9d..aa4e528 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -9,6 +9,7 @@ #include "bip32.h" #include "proxy.h" #include "selftest.h" +#include "sockcount.h" #include "walletcmd.h" extern int RunPoolStratumSelfTest(); @@ -1254,6 +1255,197 @@ static int RunParseMoneySelfTest() return nFail == 0 ? 0 : 1; } +static bool SelfTestReadN(SOCKET s, void* buf, int n) +{ + char* p = (char*)buf; + int off = 0; + while (off < n) + { + int r = recv(s, p + off, n - off, 0); + if (r <= 0) + return false; + off += r; + } + return true; +} + +static bool SelfTestWriteN(SOCKET s, const void* buf, int n) +{ + const char* p = (const char*)buf; + int off = 0; + while (off < n) + { + int r = send(s, p + off, n - off, 0); + if (r <= 0) + return false; + off += r; + } + return true; +} + +static void SelfTestSetSocketTimeout(SOCKET s, int nTimeoutSecs) +{ +#ifdef _WIN32 + DWORD tv = (DWORD)nTimeoutSecs * 1000; + setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, (const char*)&tv, sizeof(tv)); + setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, (const char*)&tv, sizeof(tv)); +#else + struct timeval tv; + tv.tv_sec = nTimeoutSecs; + tv.tv_usec = 0; + setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); +#endif +} + +#ifdef _WIN32 +struct SelfTestWinsock +{ + bool fStarted; + SelfTestWinsock() : fStarted(false) + { + WSADATA wsadata; + fStarted = (WSAStartup(MAKEWORD(2,2), &wsadata) == 0); + } + ~SelfTestWinsock() + { + if (fStarted) + WSACleanup(); + } +}; +#endif + +static bool RunSocks5HandshakeProbe(std::string& errOut) +{ + errOut.clear(); +#ifdef _WIN32 + SelfTestWinsock winsock; + if (!winsock.fStarted) + { + errOut = "could not start Winsock"; + return false; + } +#endif + + SOCKET listener = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (listener == INVALID_SOCKET) + { + errOut = "could not create listener"; + return false; + } + SelfTestSetSocketTimeout(listener, 5); + + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + addr.sin_port = 0; + if (bind(listener, (struct sockaddr*)&addr, sizeof(addr)) != 0 || + listen(listener, 1) != 0) + { + closesocket(listener); + errOut = "could not bind/listen on loopback"; + return false; + } + +#ifdef _WIN32 + int addrLen = sizeof(addr); +#else + socklen_t addrLen = sizeof(addr); +#endif + if (getsockname(listener, (struct sockaddr*)&addr, &addrLen) != 0) + { + closesocket(listener); + errOut = "could not read listener port"; + return false; + } + unsigned short proxyPort = ntohs(addr.sin_port); + + bool fServerOk = false; + std::string serverErr; + std::string requestedHost; + unsigned short requestedPort = 0; + std::thread server([&]() { + SOCKET s = accept(listener, NULL, NULL); + if (s == INVALID_SOCKET) + { + serverErr = "accept failed"; + return; + } + SelfTestSetSocketTimeout(s, 5); + + unsigned char hello[3] = {0,0,0}; + if (!SelfTestReadN(s, hello, sizeof(hello)) || + hello[0] != 0x05 || hello[1] != 0x01 || hello[2] != 0x00) + { + serverErr = "bad SOCKS5 greeting"; + closesocket(s); + return; + } + unsigned char choice[2] = {0x05, 0x00}; + if (!SelfTestWriteN(s, choice, sizeof(choice))) + { + serverErr = "could not write method choice"; + closesocket(s); + return; + } + + unsigned char hdr[5] = {0,0,0,0,0}; + if (!SelfTestReadN(s, hdr, sizeof(hdr)) || + hdr[0] != 0x05 || hdr[1] != 0x01 || hdr[2] != 0x00 || hdr[3] != 0x03) + { + serverErr = "CONNECT did not use domain-name address type"; + closesocket(s); + return; + } + + unsigned char len = hdr[4]; + std::vector rest((size_t)len + 2); + if (!SelfTestReadN(s, &rest[0], (int)rest.size())) + { + serverErr = "could not read CONNECT target"; + closesocket(s); + return; + } + requestedHost.assign((const char*)&rest[0], (size_t)len); + requestedPort = ((unsigned short)rest[len] << 8) | rest[len + 1]; + + unsigned char reply[10] = {0x05, 0x00, 0x00, 0x01, 0,0,0,0, 0,0}; + if (!SelfTestWriteN(s, reply, sizeof(reply))) + { + serverErr = "could not write CONNECT reply"; + closesocket(s); + return; + } + fServerOk = true; + closesocket(s); + }); + + std::string err; + BtfClearSocks5Proxy(); + bool fClientOk = BtfSetSocks5Proxy(strprintf("127.0.0.1:%u", (unsigned)proxyPort), err); + SOCKET client = INVALID_SOCKET; + if (fClientOk) + client = BtfConnectSocket("relay.example", 443, SOCK_NOSTR, 5); + if (client != INVALID_SOCKET) + BtfCloseSocket(client); + BtfClearSocks5Proxy(); + closesocket(listener); + server.join(); + + if (!fClientOk) + errOut = strprintf("could not enable proxy: %s", err.c_str()); + else if (client == INVALID_SOCKET) + errOut = "client SOCKS5 connect failed"; + else if (!fServerOk) + errOut = serverErr.empty() ? "mock server failed" : serverErr; + else if (requestedHost != "relay.example" || requestedPort != 443) + errOut = strprintf("unexpected CONNECT target %s:%u", requestedHost.c_str(), (unsigned)requestedPort); + else + return true; + return false; +} + static int RunSocks5ProxySelfTest() { printf("socks5-proxy self-test\n"); @@ -1267,12 +1459,19 @@ static int RunSocks5ProxySelfTest() nFail += Check(BtfParseSocks5Proxy("localhost:9050", host, port, err) && host == "localhost" && port == 9050, "parses hostname proxy endpoint") ? 0 : 1; + nFail += Check(BtfParseSocks5Proxy("[::1]:9050", host, port, err) && + host == "::1" && port == 9050, + "parses bracketed IPv6 proxy endpoint") ? 0 : 1; + nFail += Check(!BtfParseSocks5Proxy("::1:9050", host, port, err), + "rejects unbracketed IPv6 proxy endpoint") ? 0 : 1; nFail += Check(!BtfParseSocks5Proxy("localhost", host, port, err), "rejects missing port") ? 0 : 1; nFail += Check(!BtfParseSocks5Proxy("localhost:0", host, port, err), "rejects zero port") ? 0 : 1; nFail += Check(!BtfParseSocks5Proxy("localhost:70000", host, port, err), "rejects out-of-range port") ? 0 : 1; + nFail += Check(!BtfParseSocks5Proxy("localhost:999999999999999999999999999999", host, port, err), + "rejects overflowing port") ? 0 : 1; nFail += Check(!BtfParseSocks5Proxy("user:pass@localhost:9050", host, port, err), "rejects unsupported authenticated proxy syntax") ? 0 : 1; @@ -1285,6 +1484,19 @@ static int RunSocks5ProxySelfTest() BtfClearSocks5Proxy(); nFail += Check(!BtfSocks5ProxyEnabled(), "clear disables proxy") ? 0 : 1; + nFail += Check(BtfSetSocks5Proxy("[::1]:9050", err) && + BtfSocks5ProxyEnabled() && + BtfSocks5ProxyName() == "[::1]:9050", + "enables bracketed IPv6 proxy endpoint") ? 0 : 1; + BtfClearSocks5Proxy(); + + std::string handshakeErr; + bool fHandshakeOk = RunSocks5HandshakeProbe(handshakeErr); + nFail += Check(fHandshakeOk, handshakeErr.empty() ? + "completes SOCKS5 domain-name CONNECT handshake" : + strprintf("completes SOCKS5 domain-name CONNECT handshake (%s)", + handshakeErr.c_str()).c_str()) ? 0 : 1; + printf("%s (%d failure%s)\n", nFail == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", nFail, nFail == 1 ? "" : "s"); fflush(stdout); -- cgit v1.2.3 From 6092e1e7da199d6c15b9548d5d4cebcf8babfdcf Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Tue, 11 Aug 2026 16:03:12 -0300 Subject: net: add Tor mode for outbound discovery --- README.md | 9 +++++- docs/tor.md | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main_gui.cpp | 25 +++++++++++++---- src/proxy.cpp | 50 ++++++++++++++++++++++++++++++++- src/proxy.h | 3 ++ src/readme.txt | 3 ++ src/selftest.cpp | 25 +++++++++++++++++ 7 files changed, 192 insertions(+), 7 deletions(-) create mode 100644 docs/tor.md diff --git a/README.md b/README.md index 353da25..66f3041 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,8 @@ Worth being precise, because the difference matters if you are relying on it. **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. -This is unlinkability between peers, not anonymity against a network observer. It is not Tor. +This is unlinkability between peers, not anonymity against a network observer. +For Tor routing, start the node with `-tor`; see [Tor mode](docs/tor.md). --- @@ -311,6 +312,7 @@ Other options worth knowing: -datadir=PATH # wallet and chain data elsewhere -port=N # P2P listen port, default 8433 -socks=HOST:PORT # SOCKS5 for outbound Nostr and .btf rendezvous dials +-tor[=HOST:PORT] # Tor mode; default local Tor SOCKS5 proxy is 127.0.0.1:9050 -debug # verbose log; without it debug.log is nearly silent -help # full list ``` @@ -324,6 +326,11 @@ dials through a local SOCKS5 proxy such as Tor. When it is set, the node skips plain HTTP external-IP probes instead of leaking a direct request outside the proxy. IPv6 proxy endpoints use brackets, for example `-socks=[::1]:9050`. +`-tor` is a privacy shorthand for the usual local Tor SOCKS5 listener at +`127.0.0.1:9050`. It routes outbound Nostr discovery and `.btf` rendezvous +dials through Tor, refuses direct `.onion` resolution, and keeps external-IP +probes disabled. Use `-tor=HOST:PORT` when Tor listens somewhere else. + ### When something looks wrong **Menu > Diagnostics**, and the same report in `debug.log` every ten minutes: 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 @@ +# Tor mode + +Bitflash can route outbound discovery traffic through a local Tor SOCKS5 proxy. +This is transport plumbing only: it does not change consensus, wallet keys, +mining, `.btf` addresses, or the encrypted rendezvous protocol. + +## Client mode + +Start Tor locally, then start Bitflash with: + +```bash +bitflash -tor +``` + +`-tor` is shorthand for: + +```bash +bitflash -socks=127.0.0.1:9050 +``` + +If Tor listens somewhere else: + +```bash +bitflash -tor=127.0.0.1:9150 +bitflash -tor=[::1]:9050 +``` + +When Tor mode is enabled: + +- outbound Nostr relay dials use Tor; +- outbound `.btf` rendezvous relay dials use Tor; +- destination names are sent to Tor as SOCKS5 domain-name CONNECT requests, so + the local DNS resolver does not see them; +- direct `.onion` dials are refused unless a SOCKS5/Tor proxy is enabled; +- plain HTTP external-IP probes are skipped. + +## Onion rendezvous relay + +An operator can expose a Bitflash rendezvous relay as a Tor hidden service. The +node does not create hidden services itself; Tor owns that configuration. + +Example `torrc`: + +```text +HiddenServiceDir /var/lib/tor/bitflash-rendezvous/ +HiddenServicePort 8434 127.0.0.1:8434 +``` + +After restarting Tor, read the generated onion name: + +```bash +sudo cat /var/lib/tor/bitflash-rendezvous/hostname +``` + +Then run the relay on localhost/VPS as usual and announce the onion endpoint from +nodes that use Tor: + +```bash +bitflash -tor -rvrelay=exampleexampleexampleexampleexampleexampleexampleexample.onion:8434 +bitflash -tor -announcerelay=exampleexampleexampleexampleexampleexampleexampleexample.onion:8434 +``` + +For a pool operator that wants the pool descriptor to point at an onion +rendezvous relay, use both: + +```bash +bitflash -operator -tor \ + -rvrelay=exampleexampleexampleexampleexampleexampleexampleexample.onion:8434 \ + -announcerelay=exampleexampleexampleexampleexampleexampleexampleexample.onion:8434 +``` + +## Full-node hidden service + +The legacy P2P listener still binds to port `8433`. You can expose it through +Tor manually: + +```text +HiddenServiceDir /var/lib/tor/bitflash-node/ +HiddenServicePort 8433 127.0.0.1:8433 +``` + +Current automatic peer discovery is `.btf`/rendezvous based, not onion-address +gossip. Treat direct full-node onion access as an operator/admin tool unless a +future release adds first-class onion peer advertisements. diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 90a6050..5194023 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -138,6 +138,7 @@ static void PrintUsage() printf("Network:\n"); printf(" /port=N (P2P listen port, default 8433)\n"); printf(" /socks=HOST:PORT (SOCKS5 proxy for outbound Nostr and .btf relay dials)\n"); + printf(" /tor[=HOST:PORT] (Tor mode; default SOCKS5 proxy is 127.0.0.1:9050)\n"); printf(" /btfseed=ADDRESS:ENCHEX (extra bootstrap peer, repeatable)\n"); printf("\n"); printf("Wallet:\n"); @@ -285,16 +286,30 @@ static void ParseStartupArguments(int argc, char* argv[]) if (!announceRelay.empty()) strBtfAnnounceRelay = announceRelay; - string socksProxy = argval2(argc, argv, "/socks", "-socks"); - if (!socksProxy.empty()) + bool fTorMode = arg(argc, argv, "/tor") || arg(argc, argv, "-tor"); + if (fTorMode) { + string torProxy = argval2(argc, argv, "/tor", "-tor"); string err; - if (!BtfSetSocks5Proxy(socksProxy, err)) - fprintf(stderr, "Ignoring /socks=%s: %s\n", socksProxy.c_str(), err.c_str()); + if (!BtfEnableTorProxy(torProxy, err)) + fprintf(stderr, "Ignoring /tor=%s: %s\n", torProxy.c_str(), err.c_str()); else - fprintf(stderr, "SOCKS5 proxy enabled for outbound discovery/relay dials: %s\n", + fprintf(stderr, "Tor mode enabled through SOCKS5 proxy %s\n", BtfSocks5ProxyName().c_str()); } + else + { + string socksProxy = argval2(argc, argv, "/socks", "-socks"); + if (!socksProxy.empty()) + { + string err; + if (!BtfSetSocks5Proxy(socksProxy, err)) + fprintf(stderr, "Ignoring /socks=%s: %s\n", socksProxy.c_str(), err.c_str()); + else + fprintf(stderr, "SOCKS5 proxy enabled for outbound discovery/relay dials: %s\n", + BtfSocks5ProxyName().c_str()); + } + } // /btfseed=ADDRESS:ENCHEX -- extra bootstrap peers, repeatable. Useful for // testing the seed path and for private networks that ship no compiled list. diff --git a/src/proxy.cpp b/src/proxy.cpp index d7dc324..7d30911 100644 --- a/src/proxy.cpp +++ b/src/proxy.cpp @@ -9,8 +9,10 @@ static CCriticalSection cs_socks5; static bool g_fSocks5Proxy = false; +static bool g_fTorProxy = false; static std::string g_socks5Host; static unsigned short g_socks5Port = 0; +static const char* DEFAULT_TOR_SOCKS5_PROXY = "127.0.0.1:9050"; static void SetSocketTimeout(SOCKET s, int nTimeoutSecs) { @@ -67,6 +69,24 @@ static bool ValidProxyIpv6HostChar(char c) return isxdigit((unsigned char)c) || c == ':' || c == '.'; } +static std::string LowerProxyHost(std::string host) +{ + if (!host.empty() && host[host.size() - 1] == '.') + host.erase(host.size() - 1); + for (size_t i = 0; i < host.size(); i++) + host[i] = (char)tolower((unsigned char)host[i]); + return host; +} + +bool BtfIsTorOnionHost(const std::string& host) +{ + std::string lower = LowerProxyHost(host); + static const char* suffix = ".onion"; + size_t suffixLen = strlen(suffix); + return lower.size() > suffixLen && + lower.compare(lower.size() - suffixLen, suffixLen, suffix) == 0; +} + static bool ParsePort(const std::string& port, unsigned short& portOut, std::string& errOut) { @@ -176,7 +196,8 @@ bool BtfParseSocks5Proxy(const std::string& spec, std::string& hostOut, return true; } -bool BtfSetSocks5Proxy(const std::string& spec, std::string& errOut) +static bool SetSocks5Proxy(const std::string& spec, bool fTorMode, + std::string& errOut) { std::string host; unsigned short port = 0; @@ -187,15 +208,27 @@ bool BtfSetSocks5Proxy(const std::string& spec, std::string& errOut) g_socks5Host = host; g_socks5Port = port; g_fSocks5Proxy = true; + g_fTorProxy = fTorMode; } return true; } +bool BtfSetSocks5Proxy(const std::string& spec, std::string& errOut) +{ + return SetSocks5Proxy(spec, false, errOut); +} + +bool BtfEnableTorProxy(const std::string& spec, std::string& errOut) +{ + return SetSocks5Proxy(spec.empty() ? DEFAULT_TOR_SOCKS5_PROXY : spec, true, errOut); +} + void BtfClearSocks5Proxy() { CRITICAL_BLOCK(cs_socks5) { g_fSocks5Proxy = false; + g_fTorProxy = false; g_socks5Host.clear(); g_socks5Port = 0; } @@ -208,6 +241,13 @@ bool BtfSocks5ProxyEnabled() return false; } +bool BtfTorProxyEnabled() +{ + CRITICAL_BLOCK(cs_socks5) + return g_fSocks5Proxy && g_fTorProxy; + return false; +} + std::string BtfSocks5ProxyName() { CRITICAL_BLOCK(cs_socks5) @@ -315,7 +355,15 @@ SOCKET BtfConnectSocket(const std::string& destHost, unsigned short destPort, } if (!fProxy) + { + if (BtfIsTorOnionHost(destHost)) + { + error("Refusing direct .onion connection to %s:%u; use /tor or /socks\n", + destHost.c_str(), (unsigned)destPort); + return INVALID_SOCKET; + } return ConnectDirect(destHost, destPort, nSockSite, nTimeoutSecs); + } SOCKET s = ConnectDirect(proxyHost, proxyPort, nSockSite, nTimeoutSecs); if (s == INVALID_SOCKET) diff --git a/src/proxy.h b/src/proxy.h index b5e69c8..6429cf2 100644 --- a/src/proxy.h +++ b/src/proxy.h @@ -21,8 +21,11 @@ bool BtfParseSocks5Proxy(const std::string& spec, std::string& hostOut, unsigned short& portOut, std::string& errOut); bool BtfSetSocks5Proxy(const std::string& spec, std::string& errOut); +bool BtfEnableTorProxy(const std::string& spec, std::string& errOut); void BtfClearSocks5Proxy(); bool BtfSocks5ProxyEnabled(); +bool BtfTorProxyEnabled(); +bool BtfIsTorOnionHost(const std::string& host); std::string BtfSocks5ProxyName(); SOCKET BtfConnectSocket(const std::string& destHost, unsigned short destPort, diff --git a/src/readme.txt b/src/readme.txt index 1fa77df..794fe20 100644 --- a/src/readme.txt +++ b/src/readme.txt @@ -46,6 +46,9 @@ Command-line options (in addition to the original ones) route outbound Nostr and .btf rendezvous dials through a SOCKS5 proxy, e.g. /socks=127.0.0.1:9050 for local Tor or /socks=[::1]:9050 for an IPv6 loopback proxy + /tor[=HOST:PORT] + Tor mode for outbound Nostr and .btf rendezvous dials; + defaults to the local Tor SOCKS5 proxy at 127.0.0.1:9050 /solomine mine without requiring a connected peer (local testing) /operator operator mode (run pool server) /participant=ADDR diff --git a/src/selftest.cpp b/src/selftest.cpp index aa4e528..cca5335 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -1474,6 +1474,11 @@ static int RunSocks5ProxySelfTest() "rejects overflowing port") ? 0 : 1; nFail += Check(!BtfParseSocks5Proxy("user:pass@localhost:9050", host, port, err), "rejects unsupported authenticated proxy syntax") ? 0 : 1; + nFail += Check(BtfIsTorOnionHost("abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcd.onion") && + BtfIsTorOnionHost("Relay.Example.ONION.") && + !BtfIsTorOnionHost("relay.example") && + !BtfIsTorOnionHost(".onion"), + "detects .onion hosts without accepting lookalikes") ? 0 : 1; BtfClearSocks5Proxy(); nFail += Check(!BtfSocks5ProxyEnabled(), "starts disabled after clear") ? 0 : 1; @@ -1490,6 +1495,26 @@ static int RunSocks5ProxySelfTest() "enables bracketed IPv6 proxy endpoint") ? 0 : 1; BtfClearSocks5Proxy(); + nFail += Check(BtfEnableTorProxy("", err) && + BtfSocks5ProxyEnabled() && + BtfTorProxyEnabled() && + BtfSocks5ProxyName() == "127.0.0.1:9050", + "enables Tor mode on the default local SOCKS5 endpoint") ? 0 : 1; + BtfClearSocks5Proxy(); + + nFail += Check(BtfEnableTorProxy("[::1]:9050", err) && + BtfSocks5ProxyEnabled() && + BtfTorProxyEnabled() && + BtfSocks5ProxyName() == "[::1]:9050", + "enables Tor mode on a custom IPv6 endpoint") ? 0 : 1; + BtfClearSocks5Proxy(); + + nFail += Check(BtfSetSocks5Proxy("127.0.0.1:9051", err) && + BtfSocks5ProxyEnabled() && + !BtfTorProxyEnabled(), + "plain SOCKS5 mode is distinct from Tor mode") ? 0 : 1; + BtfClearSocks5Proxy(); + std::string handshakeErr; bool fHandshakeOk = RunSocks5HandshakeProbe(handshakeErr); nFail += Check(fHandshakeOk, handshakeErr.empty() ? -- cgit v1.2.3 From a0a300c240c91fa0c1cd1dc60ff5420a89cc33f8 Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Tue, 11 Aug 2026 16:12:25 -0300 Subject: net: warn when Tor mode overrides SOCKS --- src/main_gui.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 5194023..bef8503 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -289,6 +289,11 @@ static void ParseStartupArguments(int argc, char* argv[]) bool fTorMode = arg(argc, argv, "/tor") || arg(argc, argv, "-tor"); if (fTorMode) { + string socksProxy = argval2(argc, argv, "/socks", "-socks"); + if (!socksProxy.empty()) + fprintf(stderr, "Warning: /tor takes precedence over /socks=%s\n", + socksProxy.c_str()); + string torProxy = argval2(argc, argv, "/tor", "-tor"); string err; if (!BtfEnableTorProxy(torProxy, err)) -- cgit v1.2.3 From 0d125b59899258400ad3ffa73e1efb80e9fcf81a Mon Sep 17 00:00:00 2001 From: bitflash Date: Wed, 12 Aug 2026 09:09:03 -0300 Subject: release: bump version to 1.2.17 (hardened audit tooling) --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 1d9965a..6cea1a8 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ ROOT := $(shell pwd) NPROC := $(shell nproc 2>/dev/null || echo 2) SUDO := $(shell [ "$$(id -u)" = "0" ] && echo "" || echo "sudo") -VERSION := 1.2.16 +VERSION := 1.2.17 SECP256K1_REPO := https://github.com/bitcoin-core/secp256k1 SECP256K1_COMMIT := 7fecac74aed8e1fd9078380d67dd04663705c989 -- cgit v1.2.3 From 4d3a5ed4a188e6d5b62c7fd82c6f7ad41e9518f4 Mon Sep 17 00:00:00 2001 From: bitflash Date: Wed, 12 Aug 2026 09:59:55 -0300 Subject: docs: update social banner to the new coin logo --- docs/social.png | Bin 208957 -> 162267 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/social.png b/docs/social.png index 19e2a3b..9a08b02 100644 Binary files a/docs/social.png and b/docs/social.png differ -- cgit v1.2.3