From 7aaaa7a684e33591125963df1eb4da6da2fa6495 Mon Sep 17 00:00:00 2001 From: Bitflash Date: Tue, 4 Aug 2026 15:14:46 -0300 Subject: rpc: build the pool block template the way the solo miner does (#141) Two bugs in RebuildJob(), the operator's Stratum template builder. It is a separate path from BitcoinMiner() and had drifted from it. A single pass over mapTransactions is not enough. That map is keyed by hash and carries no dependency order, so a transaction spending another still-unconfirmed transaction's output -- change from a recent send, usually -- fails ConnectInputs whenever its parent sits later in map order. One pass drops it, and map order does not change between rebuilds, so every later rebuild drops it at the same point. That is deterministic exclusion rather than bad luck: where pool operators mine most blocks, a transaction of that shape can stay unconfirmed for as long as its parent is. Now it retries until a pass adds nothing, which is what the solo builder has always done. And the fees reached nobody. nFees was declared inside the scan loop, so each transaction reset it and the running total was discarded, while the coinbase line passed a hardcoded 0. An operator collected fees into a block and paid none of them out. The block stayed valid, since paying less than allowed always is, so nothing ever complained. Both the diagnosis and the fix are Vic-Nas's, from his fork of this code. He deleted the fork, so this brings the work back rather than letting it go with it. His description of the failure was precise enough that finding the same two defects here took one read of our RebuildJob. --- src/rpc.cpp | 61 ++++++++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 48 insertions(+), 13 deletions(-) diff --git a/src/rpc.cpp b/src/rpc.cpp index 41c0328..d73b09c 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -167,29 +167,64 @@ static bool RebuildJob() CBlock block; block.vtx.push_back(txNew); + // Fill the block the same way BitcoinMiner() does, because this is the + // same job: a block that gets mined and submitted for real. + // + // It used to differ in two ways, and both were bugs. + // + // One pass over mapTransactions is not enough. That map is keyed by + // hash, so it has no dependency order, and a transaction spending the + // still-unconfirmed output of another (change from a recent send, most + // commonly) fails ConnectInputs whenever its parent happens to sit + // later in map order. A single pass then drops it -- and because map + // order does not change between rebuilds, every later rebuild drops it + // again at the same point. Deterministic exclusion, not bad luck: if + // pool operators mine most of the blocks, a transaction of that shape + // can stay unconfirmed for as long as its parent does. + // + // Diagnosis and fix both come from Vic-Nas, who found it in his fork + // of this code and described it exactly. The fork has since been + // deleted, so this carries it back rather than losing it. + int64 nFees = 0; { CTxDB txdb("r"); CRITICAL_BLOCK(cs_mapTransactions) { map pool; + vector vfAlreadyAdded(mapTransactions.size()); + bool fFoundSomething = true; unsigned int sz = 0; - for (auto& kv : mapTransactions) { - CTransaction& tx = kv.second; - if (tx.IsCoinBase() || !tx.IsFinal()) continue; - int64 nFees = 0; - map tmp(pool); - if (!tx.ConnectInputs(txdb, tmp, CDiskTxPos(1,1,1), 0, nFees, - false, true, tx.GetMinFee(block.vtx.size() < 100))) - continue; - pool = tmp; - block.vtx.push_back(tx); - sz += ::GetSerializeSize(tx, SER_NETWORK); - if (sz > MAX_SIZE / 2) break; + while (fFoundSomething && sz < MAX_SIZE / 2) + { + fFoundSomething = false; + unsigned int n = 0; + for (map::iterator mi = mapTransactions.begin(); + mi != mapTransactions.end(); ++mi, ++n) + { + if (vfAlreadyAdded[n]) + continue; + CTransaction& tx = (*mi).second; + if (tx.IsCoinBase() || !tx.IsFinal()) continue; + map tmp(pool); + if (!tx.ConnectInputs(txdb, tmp, CDiskTxPos(1,1,1), 0, nFees, + false, true, tx.GetMinFee(block.vtx.size() < 100))) + continue; + swap(pool, tmp); + block.vtx.push_back(tx); + sz += ::GetSerializeSize(tx, SER_NETWORK); + vfAlreadyAdded[n] = true; + fFoundSomething = true; + } } } } - block.vtx[0].vout[0].nValue = block.GetBlockValue(pindexPrev->nHeight + 1, 0); + // And the fees actually go to the coinbase now. nFees was declared + // inside the scan loop, so every transaction reset it and the total was + // thrown away, while this line passed a hardcoded 0. A pool operator + // collected fees into a block and paid none of them to itself. Solo + // mining has always passed nFees here; only this builder did not. + block.vtx[0].vout[0].nValue = block.GetBlockValue(pindexPrev->nHeight + 1, nFees); block.hashPrevBlock = pindexPrev->GetBlockHash(); block.hashMerkleRoot = block.BuildMerkleTree(); block.nTime = max((unsigned int)(pindexPrev->GetMedianTimePast() + 1), -- cgit v1.2.3 From 5ac1fd55eea9d4b5e9b34be50d3b684693e3a935 Mon Sep 17 00:00:00 2001 From: mtg0001 <106211915+mtg0001@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:25:34 -0300 Subject: build: pin external source dependencies (#139) Co-authored-by: Gilcimar Duarte --- Makefile | 43 ++++++++++++++++++++++++++++++++++++------- docs/dependencies.md | 18 ++++++++++++++++++ 2 files changed, 54 insertions(+), 7 deletions(-) create mode 100644 docs/dependencies.md diff --git a/Makefile b/Makefile index ea720f1..0788b61 100644 --- a/Makefile +++ b/Makefile @@ -12,6 +12,11 @@ NPROC := $(shell nproc 2>/dev/null || echo 2) SUDO := $(shell [ "$$(id -u)" = "0" ] && echo "" || echo "sudo") VERSION := 1.2.15 +SECP256K1_REPO := https://github.com/bitcoin-core/secp256k1 +SECP256K1_COMMIT := 7fecac74aed8e1fd9078380d67dd04663705c989 +RANDOMX_REPO := https://github.com/tevador/RandomX +RANDOMX_COMMIT := 1e9d4b2df63fa6edf46b06789486e23c0fcaa55a + # ---- Linux ---------------------------------------------------------------- linux: deps-linux @@ -47,7 +52,13 @@ deps-secp256k1: @if [ ! -f /usr/local/lib/libsecp256k1.a ]; then \ echo "==> building libsecp256k1"; \ rm -rf $(ROOT)/secp256k1-build; \ - git clone --depth 1 https://github.com/bitcoin-core/secp256k1 $(ROOT)/secp256k1-build; \ + git init -q $(ROOT)/secp256k1-build; \ + cd $(ROOT)/secp256k1-build && \ + git remote add origin $(SECP256K1_REPO) && \ + git fetch -q --depth 1 origin $(SECP256K1_COMMIT) && \ + git checkout -q --detach FETCH_HEAD && \ + test "$$(git rev-parse HEAD)" = "$(SECP256K1_COMMIT)" && \ + echo "==> libsecp256k1 pinned at $(SECP256K1_COMMIT)" && \ cd $(ROOT)/secp256k1-build && ./autogen.sh && \ ./configure --enable-module-schnorrsig --enable-module-extrakeys \ --disable-shared --with-pic --disable-benchmark --disable-tests && \ @@ -61,7 +72,13 @@ deps-randomx: @if [ ! -f $(HOME)/RandomX/build/librandomx.a ]; then \ echo "==> building RandomX"; \ rm -rf $(HOME)/RandomX; \ - git clone --depth 1 https://github.com/tevador/RandomX $(HOME)/RandomX; \ + git init -q $(HOME)/RandomX; \ + cd $(HOME)/RandomX && \ + git remote add origin $(RANDOMX_REPO) && \ + git fetch -q --depth 1 origin $(RANDOMX_COMMIT) && \ + git checkout -q --detach FETCH_HEAD && \ + test "$$(git rev-parse HEAD)" = "$(RANDOMX_COMMIT)" && \ + echo "==> RandomX pinned at $(RANDOMX_COMMIT)" && \ mkdir -p $(HOME)/RandomX/build; \ cd $(HOME)/RandomX/build && cmake .. -DCMAKE_BUILD_TYPE=Release && \ make -j$(NPROC) randomx; \ @@ -120,8 +137,14 @@ deps-windows: autoconf automake libtool @if [ ! -f deps/lib/libsecp256k1.a ]; then \ rm -rf secp256k1-build; \ - git clone --depth 1 https://github.com/bitcoin-core/secp256k1 secp256k1-build; \ - cd secp256k1-build && ./autogen.sh && \ + git init -q secp256k1-build; \ + cd secp256k1-build && \ + git remote add origin $(SECP256K1_REPO) && \ + git fetch -q --depth 1 origin $(SECP256K1_COMMIT) && \ + git checkout -q --detach FETCH_HEAD && \ + test "$$(git rev-parse HEAD)" = "$(SECP256K1_COMMIT)" && \ + echo "==> secp256k1 pinned at $(SECP256K1_COMMIT)" && \ + cd $(ROOT)/secp256k1-build && ./autogen.sh && \ ./configure --prefix=$(ROOT)/deps \ --enable-module-schnorrsig --enable-module-extrakeys \ --disable-shared --with-pic --disable-benchmark --disable-tests && \ @@ -132,9 +155,15 @@ deps-windows: fi @if [ ! -f RandomX/build/librandomx.a ]; then \ rm -rf RandomX; \ - git clone --depth 1 https://github.com/tevador/RandomX; \ - mkdir -p RandomX/build; \ - cd RandomX/build && cmake .. -G "Unix Makefiles" -DCMAKE_BUILD_TYPE=Release && \ + git init -q RandomX; \ + cd RandomX && \ + git remote add origin $(RANDOMX_REPO) && \ + git fetch -q --depth 1 origin $(RANDOMX_COMMIT) && \ + git checkout -q --detach FETCH_HEAD && \ + test "$$(git rev-parse HEAD)" = "$(RANDOMX_COMMIT)" && \ + echo "==> RandomX pinned at $(RANDOMX_COMMIT)" && \ + mkdir -p $(ROOT)/RandomX/build; \ + cd $(ROOT)/RandomX/build && cmake .. -G "Unix Makefiles" -DCMAKE_BUILD_TYPE=Release && \ make randomx; \ echo "==> RandomX done"; \ else \ diff --git a/docs/dependencies.md b/docs/dependencies.md new file mode 100644 index 0000000..9fd45b6 --- /dev/null +++ b/docs/dependencies.md @@ -0,0 +1,18 @@ +# Build Dependencies + +Bitflash links consensus-adjacent cryptographic and proof-of-work code from +external repositories. Fresh builds must not follow upstream `master` silently: +a changed upstream branch can break reproducible builds or change behavior in +code that every node relies on. + +The top-level `Makefile` pins source dependencies by full commit hash: + +| dependency | repository | pinned commit | +|---|---|---| +| libsecp256k1 | https://github.com/bitcoin-core/secp256k1 | `7fecac74aed8e1fd9078380d67dd04663705c989` | +| RandomX | https://github.com/tevador/RandomX | `1e9d4b2df63fa6edf46b06789486e23c0fcaa55a` | + +When updating either dependency, rebuild from a clean dependency directory and +run the full selftest suite before opening the PR. The PR should explain why the +dependency changed and whether the update can affect consensus, addresses, +signatures, proof-of-work validation, or mining. -- cgit v1.2.3 From 173b4649e90f4f5ba49e92569eb063709e647c7d Mon Sep 17 00:00:00 2001 From: mtg0001 <106211915+mtg0001@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:26:56 -0300 Subject: ci: add GitHub Actions build and selftests (#140) Co-authored-by: Gilcimar Duarte --- .github/workflows/ci.yml | 62 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..985ba8a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,62 @@ +name: CI + +on: + push: + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + linux-headless: + name: Linux headless selftests + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v5 + + - name: Install Linux dependencies + run: make deps-linux + + - name: Run Linux selftests + run: make tests + + windows-ucrt64: + name: Windows UCRT64 selftests + runs-on: windows-latest + defaults: + run: + shell: msys2 {0} + steps: + - name: Check out repository + uses: actions/checkout@v5 + + - name: Set up MSYS2 + uses: msys2/setup-msys2@v2 + with: + msystem: UCRT64 + update: true + install: >- + git + make + mingw-w64-ucrt-x86_64-gcc + mingw-w64-ucrt-x86_64-glfw + mingw-w64-ucrt-x86_64-openssl + mingw-w64-ucrt-x86_64-db + mingw-w64-ucrt-x86_64-libsodium + mingw-w64-ucrt-x86_64-nlohmann-json + mingw-w64-ucrt-x86_64-boost + mingw-w64-ucrt-x86_64-cmake + autoconf + automake + libtool + + - name: Build Windows dependencies + run: make deps-windows + + - name: Run Windows selftests + run: cd src && make -f makefile.mingw tests -- cgit v1.2.3 From b3a5a858e536acf65e6c6761293626628f2227bf Mon Sep 17 00:00:00 2001 From: mtg0001 <106211915+mtg0001@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:31:55 -0300 Subject: fuzz: add P2P message parser harness (#142) * fuzz: add P2P message parser harness * fuzz: avoid duplicate legacy header includes * fuzz: seed the corpus with our network magic, not Bitcoin's The two seed inputs began f5 be b6 d9, which is Bitcoin's message start. Ours is bf 20 5c fd. CMessageHeader::IsValid rejects on that field first, so both seeds died at the first check and the smoke run exercised the reject path and nothing else. libFuzzer would have to mutate its way onto four exact bytes before reaching any parser, which does not happen in 128 runs. The job would have gone green while covering none of what it was added to cover. --------- Co-authored-by: Gilcimar Duarte Co-authored-by: Bitflash-sh --- .github/workflows/ci.yml | 3 +++ Makefile | 7 +++++-- src/Makefile | 34 +++++++++++++++++++++++++++++++++- src/fuzz_net_message.cpp | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 src/fuzz_net_message.cpp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 985ba8a..9efd66f 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 P2P message fuzz smoke + run: make fuzz-net-message-smoke + windows-ucrt64: name: Windows UCRT64 selftests runs-on: windows-latest diff --git a/Makefile b/Makefile index 0788b61..736f5d8 100644 --- a/Makefile +++ b/Makefile @@ -42,7 +42,7 @@ deps-apt: @if command -v apt-get >/dev/null 2>&1; then \ $(SUDO) apt-get install -y build-essential cmake git pkg-config autoconf \ libtool libssl-dev libdb5.3++-dev libsodium-dev nlohmann-json3-dev \ - libboost-system-dev libglfw3-dev libgl-dev python3-pil; \ + libboost-system-dev libglfw3-dev libgl-dev python3-pil clang; \ else \ echo "Not an apt system — install: g++ cmake git autoconf libtool"; \ echo " libssl libdb++ libsodium nlohmann-json boost glfw3 opengl python3-pil"; \ @@ -191,6 +191,9 @@ clean: tests: $(MAKE) -C src -f Makefile tests +fuzz-net-message-smoke: deps-linux + $(MAKE) -C src -f Makefile fuzz-net-message-smoke + checksums: ./scripts/make-release-checksums.sh @@ -201,5 +204,5 @@ verify-release: ./scripts/verify-release.sh $(if $(TAG),$(TAG),latest) .PHONY: linux windows clean appimage \ - tests checksums sign-checksums verify-release \ + tests fuzz-net-message-smoke checksums sign-checksums verify-release \ deps-linux deps-windows deps-apt deps-secp256k1 deps-randomx diff --git a/src/Makefile b/src/Makefile index 3577dd6..ac0226e 100644 --- a/src/Makefile +++ b/src/Makefile @@ -2,6 +2,7 @@ # Called by: make linux (root Makefile) CXX ?= g++ +FUZZ_CXX ?= clang++ SECP_INC ?= /usr/local/include SECP_LIB ?= /usr/local/lib @@ -33,6 +34,9 @@ CXXFLAGS = -std=gnu++14 -O2 $(WARNFLAGS) -fpermissive -pthread \ -I$(SECP_INC) -I$(RANDOMX_DIR)/src \ -I/usr/include/nlohmann \ -I$(IMGUI_DIR) +FUZZ_CXXFLAGS = $(filter-out -O2,$(CXXFLAGS)) -O1 -g -fno-omit-frame-pointer \ + -fsanitize=fuzzer-no-link,address,undefined +FUZZ_LDFLAGS = -fsanitize=fuzzer,address,undefined LIBS = -L$(SECP_LIB) -lsecp256k1 \ -L$(RANDOMX_DIR)/build -lrandomx \ @@ -117,6 +121,9 @@ NODE_LIBS = -L$(SECP_LIB) -lsecp256k1 \ NODE_ONLY_OBJS = $(patsubst obj/%,obj-node/%,$(NODE_OBJS)) \ obj-node/main_gui.o obj-node/headless.o +FUZZ_OBJS = $(patsubst obj/%,obj-fuzz/%,$(NODE_OBJS)) \ + obj-fuzz/fuzz_net_message.o + obj-node/%.o: %.cpp $(HEADERS) @mkdir -p obj-node $(CXX) -c $(CXXFLAGS) -DBITFLASH_NO_GUI -o $@ $< @@ -129,8 +136,31 @@ bitflash-node: $(NODE_ONLY_OBJS) $(CXX) $(CXXFLAGS) -o $@ $^ $(NODE_LIBS) @echo "Built: bitflash-node (headless, no libGL needed)" +obj-fuzz/%.o: %.cpp $(HEADERS) + @mkdir -p obj-fuzz + $(FUZZ_CXX) -c $(FUZZ_CXXFLAGS) -DBITFLASH_FUZZ -o $@ $< + +obj-fuzz/sha.o: sha.cpp sha.h + @mkdir -p obj-fuzz + $(FUZZ_CXX) -c $(FUZZ_CXXFLAGS) -DBITFLASH_FUZZ -O1 -o $@ $< + +obj-fuzz/fuzz_net_message.o: fuzz_net_message.cpp $(HEADERS) + @mkdir -p obj-fuzz + $(FUZZ_CXX) -c $(FUZZ_CXXFLAGS) -DBITFLASH_FUZZ -o $@ $< + +fuzz-net-message: $(FUZZ_OBJS) + $(FUZZ_CXX) $(filter-out -fsanitize=fuzzer-no-link%,$(FUZZ_CXXFLAGS)) \ + $(FUZZ_LDFLAGS) -o $@ $^ $(NODE_LIBS) + @echo "Built: fuzz-net-message" + +fuzz-net-message-smoke: fuzz-net-message + @mkdir -p ../work/fuzz-corpus/net-message + @printf '\277\040\134\375ping\000\000\000\000\000\000\000\000\000\000\000\000' > ../work/fuzz-corpus/net-message/ping-header + @printf '\277\040\134\375version\000\000\000\000\000\000\000\000\000' > ../work/fuzz-corpus/net-message/version-header + ./fuzz-net-message -runs=128 ../work/fuzz-corpus/net-message + clean: - rm -rf obj obj-node bitflash bitflash-node test_bip32 + rm -rf obj obj-node obj-fuzz bitflash bitflash-node fuzz-net-message test_bip32 test_bip32: test_bip32.cpp bip32.cpp bip32.h bip39_english.h $(CXX) $(CXXFLAGS) -o $@ test_bip32.cpp bip32.cpp -lssl -lcrypto @@ -144,3 +174,5 @@ tests: test_bip32 bitflash-node ./bitflash-node -selftest=wallet-encrypt ./bitflash-node -selftest=net-message ./bitflash-node -selftest=consensus-limits + +.PHONY: tests fuzz-net-message-smoke diff --git a/src/fuzz_net_message.cpp b/src/fuzz_net_message.cpp new file mode 100644 index 0000000..1104d90 --- /dev/null +++ b/src/fuzz_net_message.cpp @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Bitflash developers +// Distributed under the MIT/X11 software license. +// +// libFuzzer harness for the P2P message envelope parser. It feeds arbitrary +// receive-buffer bytes into ProcessMessages() through a dummy CNode, without +// opening sockets or starting node threads. + +#include "headers_core.h" + +#include +#include + +// Stand-ins normally supplied by the GUI/headless entry objects. +map mapAddressBook; +bool gPoolServerRunning = false; +void MainFrameRepaint() {} + +extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) +{ + // Keep this first target focused on parser behavior, not multi-megabyte + // allocation pressure. The protocol size guards have their own selftest. + if (size > 4096) + return 0; + + CNode node(INVALID_SOCKET, CAddress("127.0.0.1")); + node.nVersion = 0; // exercise pre-version gating plus the version parser + if (size > 0) + node.vRecv.write((const char*)data, (int)size); + + ProcessMessages(&node); + return 0; +} -- cgit v1.2.3 From c3f6b68f907b03865d2c417ee39fe9526787196f Mon Sep 17 00:00:00 2001 From: Bitflash Date: Thu, 6 Aug 2026 10:46:26 -0300 Subject: release: sign SHA256SUMS with a pinned key, verify against it verify-release.sh embeds the Bitflash release signing key and now requires that the checksum signature validate against that exact key, not merely against some key in the caller's keyring (#138). It also fetches releases from releases.bitflash.network instead of the retired GitHub host, resolving 'latest' from latest.txt. Release signing key fingerprint: 910A 2B4C CA87 9E81 FB4B 2AEA 1D4A 53D3 B78A A4B8 --- docs/release-verification.md | 78 ++++++++++++++++++++----------- scripts/verify-release.sh | 108 +++++++++++++++++++++++-------------------- 2 files changed, 108 insertions(+), 78 deletions(-) diff --git a/docs/release-verification.md b/docs/release-verification.md index ecacd5f..32de74f 100644 --- a/docs/release-verification.md +++ b/docs/release-verification.md @@ -3,60 +3,82 @@ Bitflash releases publish binaries and a `SHA256SUMS` file. The checksum file proves that the file you downloaded matches the file the release page names. A detached signature, `SHA256SUMS.asc`, proves that the checksum file itself was -signed by a trusted release key. +signed by the Bitflash release key. That gives users two separate checks: -1. `gpg --verify SHA256SUMS.asc SHA256SUMS` checks who signed the checksums. -2. `sha256sum -c SHA256SUMS` checks the binaries against those checksums. +1. `gpg --verify SHA256SUMS.asc SHA256SUMS` — who signed the checksums. +2. `sha256sum -c SHA256SUMS` — the binaries against those checksums. -## User Check +But the first check is only worth something if you require a **specific** key. +A good signature by *some* key proves nothing; anyone can make one. -From a shell with `curl` or `wget` and `sha256sum`: +## The release signing key -```bash -scripts/verify-release.sh latest +```text +fingerprint 910A 2B4C CA87 9E81 FB4B 2AEA 1D4A 53D3 B78A A4B8 +uid Bitflash Releases (bitflash.network release signing) + ``` -For a specific release: +The public key is published at +`https://releases.bitflash.network/bitflash-release-key.asc` and embedded in +`scripts/verify-release.sh`, so the tool's trust anchor lives in this +repository rather than on the server it verifies. + +## User check + +From a shell with `curl` or `wget`, `sha256sum` and `gpg`: ```bash -scripts/verify-release.sh v1.2.13 +scripts/verify-release.sh latest ``` -For release audits, require the signature: +For a specific release: ```bash -scripts/verify-release.sh v1.2.13 --require-signature +scripts/verify-release.sh v1.2.15 ``` -Older releases may not have `SHA256SUMS.asc`. In that case the script warns and -still checks file integrity. New release audits should use `--require-signature`. - -## Maintainer Flow +The script downloads the release, imports the pinned key into a throwaway +keyring, and **refuses anything that is not a valid signature by the key above** +before checking the hashes. Releases from before signing was introduced ship +only `SHA256SUMS`; without `--require-signature` those are checked for integrity +and reported as unsigned. -After building release assets in the repository root: +By hand, if you would rather not run the script: ```bash -scripts/make-release-checksums.sh --sign --local-user RELEASE_KEY_ID +curl -O https://releases.bitflash.network/bitflash-release-key.asc +gpg --import bitflash-release-key.asc +gpg --verify SHA256SUMS.asc SHA256SUMS # must name the fingerprint above +sha256sum -c SHA256SUMS ``` -Upload all built assets plus: +## Maintainer flow -```text -SHA256SUMS -SHA256SUMS.asc +The signing key lives on a dedicated location, not in the build tree. After +building the release assets in the repository root, write and sign the +checksums: + +```bash +sha256sum Bitflash-*.zip Bitflash-*.AppImage bitflash-node-* > SHA256SUMS +gpg --armor --detach-sign --output SHA256SUMS.asc SHA256SUMS ``` -Keep the private signing key offline or on a dedicated release machine. Publish -the public key fingerprint in the release notes and keep using the same key for -future releases unless there is a clearly announced rotation. +Publish, alongside the assets, `SHA256SUMS` and `SHA256SUMS.asc`, and update +`latest.txt` on the releases host to the new tag. Keep using the same key; if it +is ever rotated, announce the new fingerprint clearly and update the embedded +key in `verify-release.sh`. -## Why This Matters +## Why this matters `SHA256SUMS` alone protects against a broken download, but not against someone -replacing both a binary and the checksum file. Signing `SHA256SUMS` means an -attacker must also have the release signing key to make the replacement verify. +replacing both a binary and the checksum file. Signing `SHA256SUMS`, and +requiring a pinned key, means an attacker must also hold the release signing key +to make the replacement verify. Serving from bitflash.network makes this the +guarantee that matters: without a pinned key, "trust the site" is the only +thing standing behind a download. This is not reproducible builds yet. It is the smaller, immediate step that -makes every release asset auditable by users before they run it. +makes every release asset auditable before anyone runs it. diff --git a/scripts/verify-release.sh b/scripts/verify-release.sh index 23e71d1..4fd4558 100755 --- a/scripts/verify-release.sh +++ b/scripts/verify-release.sh @@ -1,23 +1,50 @@ #!/usr/bin/env bash set -euo pipefail -repo="Bitflash-sh/bitflash" -tag="${1:-latest}" +# Verifies a Bitflash release: the checksum file's signature against a pinned +# key, then the assets against the checksums. +# +# The signing key is embedded below, not fetched. That is the whole point of a +# pinned key: the trust anchor ships with this script (in git), so it does not +# depend on the same server that serves the release. A server that is +# compromised can swap the binaries and the checksums, but it cannot forge a +# signature by this key. + +host="https://releases.bitflash.network" require_signature=0 +# The Bitflash release signing key. Verification requires this exact key -- +# gpg's own "good signature" is not enough, because a good signature by *some* +# key proves nothing. See issue #138. +RELEASE_KEY_FP="910A2B4CCA879E81FB4B2AEA1D4A53D3B78AA4B8" +read -r -d '' RELEASE_KEY <<'KEY' || true +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mDMEanSOmRYJKwYBBAHaRw8BAQdAXj24i2BYUcAmOpoXwjE4ZTynHsifCZ+niwQK +HqO0nMa0UEJpdGZsYXNoIFJlbGVhc2VzIChiaXRmbGFzaC5uZXR3b3JrIHJlbGVh +c2Ugc2lnbmluZykgPHJlbGVhc2VzQGJpdGZsYXNoLm5ldHdvcms+iJAEExYKADgW +IQSRCitMyoeegftLKuodSlPTt4qkuAUCanSOmQIbAwULCQgHAgYVCgkICwIEFgID +AQIeAQIXgAAKCRAdSlPTt4qkuIi9AP96X3OcstiCTTgKOr16Vhds3LyuZ19q3Apq +4KAcNuOUMAD+NqgJ1CqkLGPXfZLCtYBCRHXNYWxMMEk+ViLcRtdLMg4= +=mxic +-----END PGP PUBLIC KEY BLOCK----- +KEY + usage() { cat <<'EOF' Usage: scripts/verify-release.sh [tag|latest] [--require-signature] Downloads the release checksum file and every asset named in it, then verifies: - 1. SHA256SUMS.asc, if present, against SHA256SUMS with gpg. + 1. SHA256SUMS.asc against SHA256SUMS, requiring the pinned Bitflash release + key (fingerprint 910A 2B4C CA87 9E81 FB4B 2AEA 1D4A 53D3 B78A A4B8). 2. SHA256SUMS against the downloaded assets. -Use --require-signature for release audits. Without it, older releases that -ship only SHA256SUMS are checked for integrity and reported as unsigned. +Releases from before signing was introduced ship only SHA256SUMS; without +--require-signature those are checked for integrity and reported as unsigned. EOF } +tag="${1:-latest}" for arg in "${@:2}"; do case "$arg" in --require-signature) require_signature=1 ;; @@ -25,19 +52,9 @@ for arg in "${@:2}"; do *) echo "unknown option: $arg" >&2; usage >&2; exit 2 ;; esac done +if [ "$tag" = "-h" ] || [ "$tag" = "--help" ]; then usage; exit 0; fi -if [ "$tag" = "-h" ] || [ "$tag" = "--help" ]; then - usage - exit 0 -fi - -need() { - if ! command -v "$1" >/dev/null 2>&1; then - echo "missing required command: $1" >&2 - exit 1 - fi -} - +need() { command -v "$1" >/dev/null 2>&1 || { echo "missing required command: $1" >&2; exit 1; }; } need sed need awk @@ -46,39 +63,25 @@ if command -v sha256sum >/dev/null 2>&1; then elif command -v shasum >/dev/null 2>&1; then sha_check=(shasum -a 256 -c SHA256SUMS) else - echo "missing required command: sha256sum or shasum" >&2 - exit 1 + echo "missing required command: sha256sum or shasum" >&2; exit 1 fi download() { - local url="$1" - local out="$2" - if command -v curl >/dev/null 2>&1; then - curl -fsSL "$url" -o "$out" - elif command -v wget >/dev/null 2>&1; then - wget -q "$url" -O "$out" - else - echo "missing required command: curl or wget" >&2 - exit 1 - fi + if command -v curl >/dev/null 2>&1; then curl -fsSL "$1" -o "$2" + elif command -v wget >/dev/null 2>&1; then wget -q "$1" -O "$2" + else echo "missing required command: curl or wget" >&2; exit 1; fi } if [ "$tag" = "latest" ]; then - tag="$(download "https://api.github.com/repos/$repo/releases/latest" - | - sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | - head -1)" - if [ -z "$tag" ]; then - echo "could not resolve latest release tag" >&2 - exit 1 - fi + tag="$(download "$host/latest.txt" - 2>/dev/null | tr -d '[:space:]' || true)" + [ -n "$tag" ] || { echo "could not resolve the latest release tag from $host/latest.txt" >&2; exit 1; } fi workdir="$(mktemp -d "${TMPDIR:-/tmp}/bitflash-release-verify.XXXXXX")" cleanup() { rm -rf "$workdir"; } trap cleanup EXIT -base_url="https://github.com/$repo/releases/download/$tag" - +base_url="$host/$tag" echo "Verifying Bitflash $tag" echo "Working directory: $workdir" cd "$workdir" @@ -86,27 +89,32 @@ cd "$workdir" download "$base_url/SHA256SUMS" SHA256SUMS if download "$base_url/SHA256SUMS.asc" SHA256SUMS.asc 2>/dev/null; then - if ! command -v gpg >/dev/null 2>&1; then - echo "SHA256SUMS.asc exists, but gpg is not installed" >&2 + need gpg + # Verify in a throwaway keyring holding ONLY the pinned key, and require that + # the signature validates against it with the pinned fingerprint. This is the + # difference between "signed" and "signed by Bitflash". + keyring="$workdir/keyring" + mkdir -p "$keyring"; chmod 700 "$keyring" + printf '%s\n' "$RELEASE_KEY" | GNUPGHOME="$keyring" gpg --batch --quiet --import 2>/dev/null + status="$(GNUPGHOME="$keyring" gpg --batch --status-fd=1 --verify SHA256SUMS.asc SHA256SUMS 2>/dev/null || true)" + if printf '%s' "$status" | grep -q "VALIDSIG.*$RELEASE_KEY_FP"; then + echo "Signature OK -- signed by the pinned Bitflash release key" + else + echo "SIGNATURE REJECTED: not a valid signature by the pinned release key" >&2 + echo " expected key: $RELEASE_KEY_FP" >&2 exit 1 fi - gpg --verify SHA256SUMS.asc SHA256SUMS - echo "Signature OK" else if [ "$require_signature" -eq 1 ]; then - echo "release does not publish SHA256SUMS.asc" >&2 - exit 1 + echo "release does not publish SHA256SUMS.asc" >&2; exit 1 fi - echo "WARNING: no SHA256SUMS.asc found; checking hashes only" + echo "WARNING: no SHA256SUMS.asc found; checking hashes only (unsigned release)" fi awk '{print $2}' SHA256SUMS | while IFS= read -r file; do - file="${file#\*}" - file="${file#./}" + file="${file#\*}"; file="${file#./}" [ -z "$file" ] && continue - case "$file" in - */*|..*) echo "refusing unexpected checksum path: $file" >&2; exit 1 ;; - esac + case "$file" in */*|..*) echo "refusing unexpected checksum path: $file" >&2; exit 1 ;; esac echo "Downloading $file" download "$base_url/$file" "$file" done -- cgit v1.2.3 From 09ff5751264de6101f1bf0981accac0123351a7a Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Fri, 7 Aug 2026 10:55:48 -0300 Subject: nostr: verify relay TLS and lock peer counts (#1) --- src/nostr.cpp | 85 +++++++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 77 insertions(+), 8 deletions(-) diff --git a/src/nostr.cpp b/src/nostr.cpp index 1fd60ad..9bc538f 100644 --- a/src/nostr.cpp +++ b/src/nostr.cpp @@ -6,6 +6,10 @@ #include "headers.h" #include #include +#include +#ifdef _WIN32 +#include +#endif // We link libsecp256k1 statically (without this the header assumes DLL import). // Both makefiles also pass -DSECP256K1_STATIC; the guard keeps that from // warning while leaving this file correct if built without the flag. @@ -446,6 +450,41 @@ static bool BuildSignedEvent(CNostrKey& key, int kind, const json& tags, static const uint64 MAX_WS_FRAME = 1 << 20; // 1 MiB per frame static const uint64 MAX_WS_MESSAGE = 4 << 20; // 4 MiB reassembled +static int BtfConnectedNodeCount() +{ + int nNodes = 0; + CRITICAL_BLOCK(cs_vNodes) + { + nNodes = (int)vNodes.size(); + } + return nNodes; +} + +#ifdef _WIN32 +static bool BtfLoadWindowsRootCertificates(SSL_CTX* sslctx) +{ + HCERTSTORE hStore = CertOpenSystemStoreA(0, "ROOT"); + if (!hStore) + return false; + + bool fLoaded = false; + X509_STORE* x509store = SSL_CTX_get_cert_store(sslctx); + PCCERT_CONTEXT pCert = NULL; + while ((pCert = CertEnumCertificatesInStore(hStore, pCert)) != NULL) + { + const unsigned char* p = pCert->pbCertEncoded; + X509* x509 = d2i_X509(NULL, &p, pCert->cbCertEncoded); + if (!x509) + continue; + if (X509_STORE_add_cert(x509store, x509) == 1) + fLoaded = true; + X509_free(x509); + } + CertCloseStore(hStore, 0); + return fLoaded; +} +#endif + class CWebSocket { public: @@ -555,11 +594,26 @@ public: { sslctx = SSL_CTX_new(TLS_client_method()); if (!sslctx) return false; + SSL_CTX_set_verify(sslctx, SSL_VERIFY_PEER, NULL); + bool fTrustLoaded = (SSL_CTX_set_default_verify_paths(sslctx) == 1); +#ifdef _WIN32 + fTrustLoaded = BtfLoadWindowsRootCertificates(sslctx) || fTrustLoaded; +#endif + if (!fTrustLoaded) + return error("Nostr: failed to load TLS trust store"); ssl = SSL_new(sslctx); + if (!ssl) return false; SSL_set_fd(ssl, (int)hSocket); - SSL_set_tlsext_host_name(ssl, host.c_str()); // SNI + if (SSL_set_tlsext_host_name(ssl, host.c_str()) != 1) + return error("Nostr: failed to set TLS SNI for %s", host.c_str()); + if (SSL_set1_host(ssl, host.c_str()) != 1) + return error("Nostr: failed to set TLS hostname for %s", host.c_str()); if (SSL_connect(ssl) != 1) return error("Nostr: TLS handshake with %s failed", host.c_str()); + long nVerify = SSL_get_verify_result(ssl); + if (nVerify != X509_V_OK) + return error("Nostr: TLS certificate verification failed for %s: %s", + host.c_str(), X509_verify_cert_error_string(nVerify)); } // WebSocket handshake (HTTP Upgrade) @@ -1016,6 +1070,7 @@ static bool ResolveDescriptor(CWebSocket& ws, void* ctx, const string& btfAddr, json filter = json::object(); filter["authors"] = json::array({ HexEncode(pk, 32) }); filter["kinds"] = json::array({ BTF_DESC_KIND }); + filter["#d"] = json::array({ BTF_DESC_DTAG }); filter["limit"] = 1; json req = json::array({ "REQ", "btf-resolve", filter }); if (!ws.SendText(req.dump())) @@ -1126,6 +1181,7 @@ bool BtfResolveMany(const std::vector& btfAddrs, json filter = json::object(); filter["authors"] = authors; filter["kinds"] = json::array({ BTF_DESC_KIND }); + filter["#d"] = json::array({ BTF_DESC_DTAG }); filter["limit"] = (int)authors.size(); json req = json::array({ "REQ", "btf-resolve-many", filter }); if (!ws.SendText(req.dump())) @@ -1536,8 +1592,9 @@ static void ConnectDiscoveredBtfPeers() // plus a random sample, not one or the other. int64 now = GetTime(); int nBudget = (int)BTF_DIALS_PER_PASS; + int nNodeCount = BtfConnectedNodeCount(); { - int nRoom = (int)BTF_TARGET_CONN - (int)vNodes.size(); + int nRoom = (int)BTF_TARGET_CONN - nNodeCount; if (nRoom < nBudget) nBudget = nRoom; } if (nBudget < 0) nBudget = 0; @@ -1551,7 +1608,6 @@ static void ConnectDiscoveredBtfPeers() // Reliability slice: walk the tiered (good -> untried -> bad) order. for (const string& addr : peers) { - if ((int)vNodes.size() >= (int)BTF_TARGET_CONN) break; if ((int)candidates.size() >= nSortedSlots) break; map::iterator bi = g_btfPeerBackoff.find(addr); if (bi != g_btfPeerBackoff.end() && now < bi->second) @@ -1576,7 +1632,7 @@ static void ConnectDiscoveredBtfPeers() int nWant = min(nRandomSlots, (int)pool.size()); for (int i = 0; i < nWant; i++) { - if ((int)vNodes.size() + (int)candidates.size() >= (int)BTF_TARGET_CONN) break; + if (nNodeCount + (int)candidates.size() >= (int)BTF_TARGET_CONN) break; if ((int)candidates.size() >= nBudget) break; size_t j = i + (size_t)GetRand((uint64)(pool.size() - i)); std::swap(pool[i], pool[j]); @@ -1631,12 +1687,10 @@ static void ConnectDiscoveredBtfPeers() // not the shared Nostr relays, so there's no thundering-herd effect here. // Each thread writes its result back via shared state guarded by cs_btfPeerState. struct DialCtx { string addr; BtfResolvedPeer rp; }; - vector ctxs; for (const string& addr : toDial) { DialCtx* ctx = new DialCtx{addr, resolved[addr]}; - ctxs.push_back(ctx); - _beginthread([](void* arg) { + uintptr_t tid = _beginthread([](void* arg) { DialCtx* ctx = (DialCtx*)arg; string addr = ctx->addr; string meeting = ctx->rp.meetingHostPort; @@ -1669,6 +1723,21 @@ static void ConnectDiscoveredBtfPeers() } } }, 0, ctx); + if (tid == (uintptr_t)-1) + { + LogPrint("nostr", "Nostr: failed to create .btf dial thread for %s\n", addr.c_str()); + CRITICAL_BLOCK(cs_btfPeerState) + { + int n = ++g_btfPeerFails[addr]; + int64 cap = (n >= BTF_CHRONIC_FAIL_THRESHOLD) ? BTF_BACKOFF_CAP_SECS_DEAD + : BTF_BACKOFF_CAP_SECS; + int64 secs = 30; + for (int k = 1; k < n && secs < cap; k++) secs *= 2; + if (secs > cap) secs = cap; + g_btfPeerBackoff[addr] = GetTime() + secs; + } + delete ctx; + } } // Wait long enough for all parallel dials to finish (each has a 10s socket @@ -1768,7 +1837,7 @@ void ThreadNostrSeed(void* parg) // Re-discover periodically. Sleep less aggressively when // we have no connections yet so we find peers faster on startup. - int nSleepSecs = vNodes.empty() ? 20 : 60; + int nSleepSecs = (BtfConnectedNodeCount() == 0) ? 20 : 60; for (int i = 0; i < nSleepSecs && !fShutdown; i++) Sleep(1000); } -- cgit v1.2.3 From d925be57e22213554b0ab2a64d7618611fedf81d Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Fri, 7 Aug 2026 10:55:50 -0300 Subject: wallet: cleanse unlocked secrets before clearing them (#2) --- src/db.cpp | 19 +++++++++++++++++-- src/main.cpp | 56 +++++++++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 64 insertions(+), 11 deletions(-) diff --git a/src/db.cpp b/src/db.cpp index c8642e9..244d5a7 100644 --- a/src/db.cpp +++ b/src/db.cpp @@ -1404,6 +1404,13 @@ static bool EncryptPrivateKeyForWallet(const CKeyingMaterial& vchMasterKey, return true; } +static void SecureClearBytes(vector& vch) +{ + if (!vch.empty()) + OPENSSL_cleanse(&vch[0], vch.size()); + vch.clear(); +} + bool EncryptWallet(const string& strPassphrase, string& strBackupRet, string& strErrorRet) { strBackupRet.clear(); @@ -1423,6 +1430,7 @@ bool EncryptWallet(const string& strPassphrase, string& strBackupRet, string& st map, CPrivKey> mapPlainKeys; vector vchPlainHDMaster; vector vchPlainHDChainCode; + bool fHadHDSeed = false; unsigned int nHDNextSnapshot = 0; { CRITICAL_BLOCK(cs_mapKeys) @@ -1433,6 +1441,7 @@ bool EncryptWallet(const string& strPassphrase, string& strBackupRet, string& st { vchPlainHDMaster = vchHDMaster; vchPlainHDChainCode = vchHDChainCode; + fHadHDSeed = !vchPlainHDMaster.empty() || !vchPlainHDChainCode.empty(); nHDNextSnapshot = nHDNext; } } @@ -1483,11 +1492,13 @@ bool EncryptWallet(const string& strPassphrase, string& strBackupRet, string& st vector vchCryptedHDMasterNew; vector vchCryptedHDChainCodeNew; - if (!vchPlainHDMaster.empty() || !vchPlainHDChainCode.empty()) + if (fHadHDSeed) { if (vchPlainHDMaster.size() != 32 || vchPlainHDChainCode.size() != 32) { strErrorRet = "wallet has an incomplete HD seed"; + SecureClearBytes(vchPlainHDMaster); + SecureClearBytes(vchPlainHDChainCode); return false; } if (!EncryptSecret(vchMasterKey, vchPlainHDMaster, @@ -1496,8 +1507,12 @@ bool EncryptWallet(const string& strPassphrase, string& strBackupRet, string& st WalletSecretIV("hdchaincode"), vchCryptedHDChainCodeNew)) { strErrorRet = "could not encrypt the HD seed"; + SecureClearBytes(vchPlainHDMaster); + SecureClearBytes(vchPlainHDChainCode); return false; } + SecureClearBytes(vchPlainHDMaster); + SecureClearBytes(vchPlainHDChainCode); } vector > > vNewPool; @@ -1506,7 +1521,7 @@ bool EncryptWallet(const string& strPassphrase, string& strBackupRet, string& st for (int i = 0; i < KEYPOOL_SIZE; i++) { CKey key; - if (!vchPlainHDMaster.empty()) + if (fHadHDSeed) { string strDeriveError; if (!DeriveHDKey(nHDNextNew, key, strDeriveError)) diff --git a/src/main.cpp b/src/main.cpp index 0342650..e149062 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -165,6 +165,9 @@ int MinerThreadCount() return (int)(nCores - 1); } +static void SecureClearBytes(vector& vch); +static void SecureClearKeyingMaterial(CKeyingMaterial& vch); + bool AddKey(const CKey& key) { if (IsWalletEncrypted()) @@ -174,9 +177,13 @@ bool AddKey(const CKey& key) vector vchCryptedSecret; if (IsWalletLocked()) return error("AddKey() : wallet is locked\n"); - if (!EncryptSecret(vWalletMasterKey, vector(vchPrivKey.begin(), vchPrivKey.end()), - WalletKeyIV(key.GetPubKey()), vchCryptedSecret)) + vector vchPlainSecret(vchPrivKey.begin(), vchPrivKey.end()); + if (!EncryptSecret(vWalletMasterKey, vchPlainSecret, WalletKeyIV(key.GetPubKey()), vchCryptedSecret)) + { + SecureClearBytes(vchPlainSecret); return error("AddKey() : encrypting key failed\n"); + } + SecureClearBytes(vchPlainSecret); if (!AddCryptedKey(key.GetPubKey(), vchCryptedSecret)) return false; return CWalletDB().WriteCryptedKey(key.GetPubKey(), vchCryptedSecret); @@ -200,17 +207,31 @@ bool IsWalletLocked() return fWalletEncrypted && fWalletLocked; } +static void SecureClearBytes(vector& vch) +{ + if (!vch.empty()) + OPENSSL_cleanse(&vch[0], vch.size()); + vch.clear(); +} + +static void SecureClearKeyingMaterial(CKeyingMaterial& vch) +{ + if (!vch.empty()) + OPENSSL_cleanse(&vch[0], vch.size()); + vch.clear(); +} + void LockWallet() { if (!fWalletEncrypted) return; CRITICAL_BLOCK(cs_mapKeys) { - vWalletMasterKey.clear(); + SecureClearKeyingMaterial(vWalletMasterKey); if (!vchCryptedHDMaster.empty()) - vchHDMaster.clear(); + SecureClearBytes(vchHDMaster); if (!vchCryptedHDChainCode.empty()) - vchHDChainCode.clear(); + SecureClearBytes(vchHDChainCode); fWalletLocked = true; } } @@ -235,14 +256,14 @@ bool DeriveWalletPassphraseKey(const string& strPassphrase, chKey, chIV); if (n != 32) { - memset(chKey, 0, sizeof(chKey)); - memset(chIV, 0, sizeof(chIV)); + OPENSSL_cleanse(chKey, sizeof(chKey)); + OPENSSL_cleanse(chIV, sizeof(chIV)); return false; } vchKeyRet.assign(chKey, chKey + sizeof(chKey)); vchIVRet.assign(chIV, chIV + sizeof(chIV)); - memset(chKey, 0, sizeof(chKey)); - memset(chIV, 0, sizeof(chIV)); + OPENSSL_cleanse(chKey, sizeof(chKey)); + OPENSSL_cleanse(chIV, sizeof(chIV)); return true; } @@ -303,6 +324,8 @@ bool DecryptSecret(const CKeyingMaterial& vchKey, vchPlaintextRet.swap(vchOut); fOk = true; } + if (!fOk) + SecureClearBytes(vchOut); EVP_CIPHER_CTX_free(ctx); return fOk; } @@ -378,6 +401,7 @@ bool GetWalletPrivKey(const vector& vchPubKey, } CPrivKey vchPrivKey(vchPlaintext.begin(), vchPlaintext.end()); + SecureClearBytes(vchPlaintext); CKey key; if (!key.SetPrivKey(vchPrivKey) || key.GetPubKey() != vchPubKey) { @@ -412,11 +436,20 @@ bool UnlockWallet(const string& strPassphrase, string& strErrorRet) vector vchMasterPlain; if (!DecryptSecret(vchPassKey, kMasterKey.vchCryptedKey, vchPassIV, vchMasterPlain)) + { + SecureClearBytes(vchPassIV); continue; + } if (vchMasterPlain.size() != 32) + { + SecureClearBytes(vchMasterPlain); + SecureClearBytes(vchPassIV); continue; + } CKeyingMaterial vchCandidate(vchMasterPlain.begin(), vchMasterPlain.end()); + SecureClearBytes(vchMasterPlain); + SecureClearBytes(vchPassIV); bool fValid = mapCryptedKeys.empty(); for (map, vector >::iterator mci = mapCryptedKeys.begin(); mci != mapCryptedKeys.end(); ++mci) @@ -425,6 +458,7 @@ bool UnlockWallet(const string& strPassphrase, string& strErrorRet) if (!DecryptSecret(vchCandidate, (*mci).second, WalletKeyIV((*mci).first), vchPlaintext)) break; CPrivKey vchPrivKey(vchPlaintext.begin(), vchPlaintext.end()); + SecureClearBytes(vchPlaintext); CKey key; if (key.SetPrivKey(vchPrivKey) && key.GetPubKey() == (*mci).first) fValid = true; @@ -440,10 +474,12 @@ bool UnlockWallet(const string& strPassphrase, string& strErrorRet) WalletSecretIV("hdmaster"), vchPlainHDMaster) || vchPlainHDMaster.size() != 32) { + SecureClearBytes(vchPlainHDMaster); strErrorRet = "could not decrypt HD master key"; return false; } vchHDMaster = vchPlainHDMaster; + SecureClearBytes(vchPlainHDMaster); } if (!vchCryptedHDChainCode.empty()) { @@ -452,10 +488,12 @@ bool UnlockWallet(const string& strPassphrase, string& strErrorRet) WalletSecretIV("hdchaincode"), vchPlainHDChainCode) || vchPlainHDChainCode.size() != 32) { + SecureClearBytes(vchPlainHDChainCode); strErrorRet = "could not decrypt HD chain code"; return false; } vchHDChainCode = vchPlainHDChainCode; + SecureClearBytes(vchPlainHDChainCode); } fWalletLocked = false; return true; -- cgit v1.2.3 From 44004e350ba7a8e7e67338c8f7eb2589fbbf0a3c Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Fri, 7 Aug 2026 10:55:51 -0300 Subject: net: disable legacy order messages (#3) --- src/main.cpp | 46 +++++++++------------------------------------- 1 file changed, 9 insertions(+), 37 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index e149062..2610c45 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -3210,44 +3210,16 @@ bool ProcessMessage(CNode* pfrom, string strCommand, CDataStream& vRecv) } - else if (strCommand == "checkorder") + else if (strCommand == "checkorder" || strCommand == "submitorder") { - uint256 hashReply; - CWalletTx order; - vRecv >> hashReply >> order; - - /// we have a chance to check the order here - - // Keep giving the same key to the same ip until they use it - if (!mapReuseKey.count(pfrom->addr.ip)) - mapReuseKey[pfrom->addr.ip] = GenerateNewKey(); - - // Send back approval of order and pubkey to use - CScript scriptPubKey; - scriptPubKey << mapReuseKey[pfrom->addr.ip] << OP_CHECKSIG; - pfrom->PushMessage("reply", hashReply, (int)0, scriptPubKey); - } - - - else if (strCommand == "submitorder") - { - uint256 hashReply; - CWalletTx wtxNew; - vRecv >> hashReply >> wtxNew; - - // Broadcast - if (!wtxNew.AcceptWalletTransaction()) - { - pfrom->PushMessage("reply", hashReply, (int)1); - return error("submitorder AcceptWalletTransaction() failed, returning error 1"); - } - wtxNew.fTimeReceivedIsTxTime = true; - AddToWallet(wtxNew); - wtxNew.RelayWalletTransaction(); - mapReuseKey.erase(pfrom->addr.ip); - - // Send back confirmation - pfrom->PushMessage("reply", hashReply, (int)0); + // Legacy Bitcoin 0.1 marketplace messages are not part of Bitflash's + // supported protocol. They used to let a remote peer make this wallet + // reserve a key or import/broadcast a wallet transaction. Keep the + // command names explicit so old peers get a quiet no-op instead of a + // wallet side effect. + if (LogAcceptsCategory("net")) + printf("ProcessMessage(%s) : disabled legacy order message\n", strCommand.c_str()); + vRecv.clear(); } -- cgit v1.2.3 From 4de5cca7e7a0a7104dae8d414add33f6e9562e4a Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Fri, 7 Aug 2026 10:55:54 -0300 Subject: fuzz: add script parser harness (#5) --- .github/workflows/ci.yml | 3 +++ Makefile | 5 +++- src/Makefile | 27 ++++++++++++++++++---- src/fuzz_script.cpp | 59 ++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 88 insertions(+), 6 deletions(-) create mode 100644 src/fuzz_script.cpp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9efd66f..ba8074c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,9 @@ jobs: - name: Run P2P message fuzz smoke run: make fuzz-net-message-smoke + - name: Run script fuzz smoke + run: make fuzz-script-smoke + windows-ucrt64: name: Windows UCRT64 selftests runs-on: windows-latest diff --git a/Makefile b/Makefile index 736f5d8..d0f1b26 100644 --- a/Makefile +++ b/Makefile @@ -194,6 +194,9 @@ tests: fuzz-net-message-smoke: deps-linux $(MAKE) -C src -f Makefile fuzz-net-message-smoke +fuzz-script-smoke: deps-linux + $(MAKE) -C src -f Makefile fuzz-script-smoke + checksums: ./scripts/make-release-checksums.sh @@ -204,5 +207,5 @@ verify-release: ./scripts/verify-release.sh $(if $(TAG),$(TAG),latest) .PHONY: linux windows clean appimage \ - tests fuzz-net-message-smoke checksums sign-checksums verify-release \ + tests fuzz-net-message-smoke fuzz-script-smoke checksums sign-checksums verify-release \ deps-linux deps-windows deps-apt deps-secp256k1 deps-randomx diff --git a/src/Makefile b/src/Makefile index ac0226e..cc52fd6 100644 --- a/src/Makefile +++ b/src/Makefile @@ -121,8 +121,9 @@ NODE_LIBS = -L$(SECP_LIB) -lsecp256k1 \ NODE_ONLY_OBJS = $(patsubst obj/%,obj-node/%,$(NODE_OBJS)) \ obj-node/main_gui.o obj-node/headless.o -FUZZ_OBJS = $(patsubst obj/%,obj-fuzz/%,$(NODE_OBJS)) \ - obj-fuzz/fuzz_net_message.o +FUZZ_BASE_OBJS = $(patsubst obj/%,obj-fuzz/%,$(NODE_OBJS)) +FUZZ_NET_MESSAGE_OBJS = $(FUZZ_BASE_OBJS) obj-fuzz/fuzz_net_message.o +FUZZ_SCRIPT_OBJS = $(FUZZ_BASE_OBJS) obj-fuzz/fuzz_script.o obj-node/%.o: %.cpp $(HEADERS) @mkdir -p obj-node @@ -148,19 +149,35 @@ obj-fuzz/fuzz_net_message.o: fuzz_net_message.cpp $(HEADERS) @mkdir -p obj-fuzz $(FUZZ_CXX) -c $(FUZZ_CXXFLAGS) -DBITFLASH_FUZZ -o $@ $< -fuzz-net-message: $(FUZZ_OBJS) +obj-fuzz/fuzz_script.o: fuzz_script.cpp $(HEADERS) + @mkdir -p obj-fuzz + $(FUZZ_CXX) -c $(FUZZ_CXXFLAGS) -DBITFLASH_FUZZ -o $@ $< + +fuzz-net-message: $(FUZZ_NET_MESSAGE_OBJS) $(FUZZ_CXX) $(filter-out -fsanitize=fuzzer-no-link%,$(FUZZ_CXXFLAGS)) \ $(FUZZ_LDFLAGS) -o $@ $^ $(NODE_LIBS) @echo "Built: fuzz-net-message" +fuzz-script: $(FUZZ_SCRIPT_OBJS) + $(FUZZ_CXX) $(filter-out -fsanitize=fuzzer-no-link%,$(FUZZ_CXXFLAGS)) \ + $(FUZZ_LDFLAGS) -o $@ $^ $(NODE_LIBS) + @echo "Built: fuzz-script" + fuzz-net-message-smoke: fuzz-net-message @mkdir -p ../work/fuzz-corpus/net-message @printf '\277\040\134\375ping\000\000\000\000\000\000\000\000\000\000\000\000' > ../work/fuzz-corpus/net-message/ping-header @printf '\277\040\134\375version\000\000\000\000\000\000\000\000\000' > ../work/fuzz-corpus/net-message/version-header ./fuzz-net-message -runs=128 ../work/fuzz-corpus/net-message +fuzz-script-smoke: fuzz-script + @mkdir -p ../work/fuzz-corpus/script + @printf '\000\121' > ../work/fuzz-corpus/script/op-true + @printf '\001\166\251\024\001\002\003\004\005\006\007\010\011\012\013\014\015\016\017\020\021\022\023\024\210\254' > ../work/fuzz-corpus/script/p2pkh-shape + @printf '\000\115\377\377' > ../work/fuzz-corpus/script/truncated-pushdata2 + ./fuzz-script -runs=128 ../work/fuzz-corpus/script + clean: - rm -rf obj obj-node obj-fuzz bitflash bitflash-node fuzz-net-message test_bip32 + rm -rf obj obj-node obj-fuzz bitflash bitflash-node fuzz-net-message fuzz-script test_bip32 test_bip32: test_bip32.cpp bip32.cpp bip32.h bip39_english.h $(CXX) $(CXXFLAGS) -o $@ test_bip32.cpp bip32.cpp -lssl -lcrypto @@ -175,4 +192,4 @@ tests: test_bip32 bitflash-node ./bitflash-node -selftest=net-message ./bitflash-node -selftest=consensus-limits -.PHONY: tests fuzz-net-message-smoke +.PHONY: tests fuzz-net-message-smoke fuzz-script-smoke diff --git a/src/fuzz_script.cpp b/src/fuzz_script.cpp new file mode 100644 index 0000000..28c84f6 --- /dev/null +++ b/src/fuzz_script.cpp @@ -0,0 +1,59 @@ +// Copyright (c) 2026 Bitflash developers +// Distributed under the MIT/X11 software license. +// +// libFuzzer harness for raw script parsing and evaluation. It keeps the +// target deliberately small: arbitrary bytes become one CScript, then the +// parser, standard-script extractors and EvalScript all see that same input. + +#include "headers_core.h" + +#include +#include + +// Stand-ins normally supplied by the GUI/headless entry objects. +map mapAddressBook; +bool gPoolServerRunning = false; +void MainFrameRepaint() {} + +extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) +{ + if (size < 1 || size > 4096) + return 0; + + try + { + int nHashType = data[0]; + const unsigned char* pbegin = data + 1; + const unsigned char* pend = data + size; + CScript script(pbegin, pend); + + CScript::const_iterator pc = script.begin(); + opcodetype opcode; + vector vchPush; + while (pc < script.end()) + { + if (!script.GetOp(pc, opcode, vchPush)) + break; + } + + script.GetSigOpCount(); + script.ToString(); + + vector vchPubKey; + ExtractPubKey(script, false, vchPubKey); + uint160 hash160; + ExtractHash160(script, hash160); + + CTransaction tx; + tx.vin.push_back(CTxIn(COutPoint(), CScript())); + tx.vout.push_back(CTxOut(0, CScript())); + + vector > stack; + EvalScript(script, tx, 0, nHashType, &stack); + } + catch (...) + { + } + + return 0; +} -- cgit v1.2.3 From b72f403c054838e4b2203f48f757df0dad017628 Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Fri, 7 Aug 2026 10:56:24 -0300 Subject: randomx: serialize cache initialization (#4) --- src/randomx_pow.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/randomx_pow.cpp b/src/randomx_pow.cpp index d617ba2..1d058d4 100644 --- a/src/randomx_pow.cpp +++ b/src/randomx_pow.cpp @@ -19,6 +19,7 @@ static randomx_cache* g_cache = NULL; static randomx_dataset* g_dataset = NULL; static randomx_vm* g_vmVerify = NULL; static CCriticalSection g_csVerify; +static std::mutex g_csInit; static bool g_fInit = false; static bool g_fFast = false; @@ -28,6 +29,10 @@ bool RandomXInit() if (g_fInit) return true; + std::lock_guard lock(g_csInit); + if (g_fInit) + return true; + // Detect the best flags for this CPU (JIT, hardware AES, Argon2). g_flags = randomx_get_flags(); -- cgit v1.2.3 From 0b01d6cfce8be68150f5e06c11e13e0443372c4d Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Fri, 7 Aug 2026 11:25:45 -0300 Subject: pool: make payout ledger crash-safe --- src/rpc.cpp | 247 +++++++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 196 insertions(+), 51 deletions(-) diff --git a/src/rpc.cpp b/src/rpc.cpp index d73b09c..8f3740d 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -251,36 +251,72 @@ static bool RebuildJob() // Payouts -- event loop thread only // --------------------------------------------------------------------------- struct PendingPayout { + std::string id; + int blockHeight; int matureAtHeight; std::map amounts; // address -> satoshis + std::map paidTxids; // address -> txid + int64 nextAttempt; + + PendingPayout() : blockHeight(0), matureAtHeight(0), nextAttempt(0) {} }; static std::vector gPendingPayouts; +static std::mutex gPayoutLedgerMutex; static std::string PayoutFilePath() { return GetAppDir() + "/pending_payouts.json"; } -static void SavePendingPayouts() +static std::string PayoutIdForBlock(int blockHeight) +{ + return strprintf("block-%d", blockHeight); +} + +static bool PayoutFullyPaid(const PendingPayout& pp) +{ + for (const auto& kv : pp.amounts) + if (pp.paidTxids.count(kv.first) == 0) + return false; + return true; +} + +static void SavePendingPayoutsLocked() { nlohmann::json arr = nlohmann::json::array(); for (const auto& pp : gPendingPayouts) { nlohmann::json obj; + obj["id"] = pp.id; + obj["blockHeight"] = pp.blockHeight; obj["matureAtHeight"] = pp.matureAtHeight; + obj["nextAttempt"] = pp.nextAttempt; nlohmann::json amounts = nlohmann::json::object(); for (const auto& kv : pp.amounts) amounts[kv.first] = kv.second; obj["amounts"] = amounts; + nlohmann::json paid = nlohmann::json::object(); + for (const auto& kv : pp.paidTxids) + paid[kv.first] = kv.second; + obj["paidTxids"] = paid; arr.push_back(obj); } std::string path = PayoutFilePath(); - FILE* f = fopen(path.c_str(), "w"); + std::string tmp = path + ".tmp"; + FILE* f = fopen(tmp.c_str(), "w"); if (f) { std::string s = arr.dump(2); fwrite(s.c_str(), 1, s.size(), f); - fclose(f); + if (fclose(f) != 0) { + LogPrint("payout", "[payout] WARNING: could not close %s\n", tmp.c_str()); + return; + } +#ifdef _WIN32 + remove(path.c_str()); +#endif + if (rename(tmp.c_str(), path.c_str()) != 0) + LogPrint("payout", "[payout] WARNING: could not replace %s\n", path.c_str()); } else { - LogPrint("payout", "[payout] WARNING: could not write %s\n", path.c_str()); + LogPrint("payout", "[payout] WARNING: could not write %s\n", tmp.c_str()); } } @@ -297,11 +333,25 @@ static void LoadPendingPayouts() fclose(f); try { nlohmann::json arr = nlohmann::json::parse(s); + std::lock_guard lk(gPayoutLedgerMutex); for (const auto& obj : arr) { PendingPayout pp; + if (obj.contains("blockHeight")) + pp.blockHeight = obj["blockHeight"].get(); pp.matureAtHeight = obj["matureAtHeight"].get(); + if (obj.contains("id") && obj["id"].is_string()) + pp.id = obj["id"].get(); + if (pp.blockHeight == 0) + pp.blockHeight = pp.matureAtHeight - COINBASE_MATURITY; + if (pp.id.empty()) + pp.id = PayoutIdForBlock(pp.blockHeight); + if (obj.contains("nextAttempt")) + pp.nextAttempt = obj["nextAttempt"].get(); for (auto it = obj["amounts"].begin(); it != obj["amounts"].end(); ++it) pp.amounts[it.key()] = it.value().get(); + if (obj.contains("paidTxids") && obj["paidTxids"].is_object()) + for (auto it = obj["paidTxids"].begin(); it != obj["paidTxids"].end(); ++it) + pp.paidTxids[it.key()] = it.value().get(); gPendingPayouts.push_back(pp); } LogPrint("payout", "[payout] loaded %zu pending payout(s) from disk\n", @@ -319,6 +369,35 @@ static std::vector gPayoutExecQueue; static std::mutex gPayoutExecMutex; static std::atomic gPayoutThreadRunning{false}; +static std::set gQueuedPayouts; + +static bool FindRecordedPoolPayout(const std::string& id, + const std::string& address, + int64 amount, + std::string& txidRet) +{ + CRITICAL_BLOCK(cs_mapWallet) + { + for (map::const_iterator it = mapWallet.begin(); + it != mapWallet.end(); ++it) + { + const CWalletTx& wtx = it->second; + map::const_iterator idIt = wtx.mapValue.find("pool_payout_id"); + if (idIt == wtx.mapValue.end() || idIt->second != id) + continue; + map::const_iterator addrIt = wtx.mapValue.find("pool_payout_address"); + if (addrIt == wtx.mapValue.end() || addrIt->second != address) + continue; + map::const_iterator amtIt = wtx.mapValue.find("pool_payout_amount"); + if (amtIt == wtx.mapValue.end() || amtIt->second != strprintf("%lld", amount)) + continue; + txidRet = wtx.GetHash().GetHex(); + return true; + } + } + return false; +} + static void QueuePayouts(int blockHeight, const std::map& shareCount, uint64 total) @@ -341,6 +420,8 @@ static void QueuePayouts(int blockHeight, int64 minerReward = (int64)((double)nSubsidy * (1.0 - feeFrac)); PendingPayout pp; + pp.id = PayoutIdForBlock(blockHeight); + pp.blockHeight = blockHeight; pp.matureAtHeight = blockHeight + COINBASE_MATURITY; int skipped = 0; @@ -364,8 +445,11 @@ static void QueuePayouts(int blockHeight, pp.amounts[kv.first] = amount; } - gPendingPayouts.push_back(pp); - SavePendingPayouts(); + { + std::lock_guard lk(gPayoutLedgerMutex); + gPendingPayouts.push_back(pp); + SavePendingPayoutsLocked(); + } int64 operatorCut = nSubsidy - minerReward; LogPrint("payout", @@ -383,42 +467,33 @@ static void QueuePayouts(int blockHeight, : ""); } -// Called from event loop: move matured entries to the exec queue (fast). -// NOT crash-safe, despite the ordering below looking like it is. -// -// A matured payout is erased from gPendingPayouts, the file is rewritten -// without it, and only then does it reach the exec queue -- which lives in -// memory alone. SavePendingPayouts writes gPendingPayouts and nothing else; -// there is no exec-queue section of the file to recover from. Die anywhere -// between the rewrite and SendMoney and those miners are simply never paid, -// with no record that they were owed anything. -// -// Making this safe needs the payment to be idempotent -- record the txid and -// check the wallet for it on restart -- rather than a reordering, since -// re-queueing blindly would double-pay whoever was already sent to. +// Called from event loop: copy matured entries to the exec queue (fast). +// The ledger entry stays on disk until every recipient has a txid recorded. +// On restart, FindRecordedPoolPayout() checks wallet metadata before sending, +// so a crash after SendMoney but before the ledger update does not double-pay. static void FlushMaturePayouts() { std::vector ready; - for (auto it = gPendingPayouts.begin(); it != gPendingPayouts.end(); ) { - if (nBestHeight < it->matureAtHeight) { ++it; continue; } - LogPrint("payout", "[payout] block height %d matured -- queuing %zu payments\n", - it->matureAtHeight, it->amounts.size()); - ready.push_back(*it); // copy first; erase after save - ++it; - } - if (!ready.empty()) { - // Erase from pending then save before handing off to exec thread, - // so the file never contains an entry that is already being sent. - for (auto it = gPendingPayouts.begin(); it != gPendingPayouts.end(); ) { - if (nBestHeight >= it->matureAtHeight) - it = gPendingPayouts.erase(it); - else - ++it; + { + std::lock_guard ledger(gPayoutLedgerMutex); + std::lock_guard queue(gPayoutExecMutex); + int64 now = GetTime(); + for (const PendingPayout& pp : gPendingPayouts) { + if (nBestHeight < pp.matureAtHeight) + continue; + if (pp.nextAttempt > now) + continue; + if (PayoutFullyPaid(pp)) + continue; + if (gQueuedPayouts.count(pp.id)) + continue; + LogPrint("payout", "[payout] block height %d matured -- queuing payout %s (%zu recipients)\n", + pp.matureAtHeight, pp.id.c_str(), pp.amounts.size()); + ready.push_back(pp); + gQueuedPayouts.insert(pp.id); } - SavePendingPayouts(); - std::lock_guard lk(gPayoutExecMutex); for (auto& pp : ready) - gPayoutExecQueue.push_back(std::move(pp)); + gPayoutExecQueue.push_back(pp); } } @@ -437,10 +512,21 @@ static void PayoutThreadFn(void*) batch.swap(gPayoutExecQueue); } - for (auto& pp : batch) { + for (auto& ppQueued : batch) { LogPrint("payout", "[payout] executing payments for block matured at %d" - " (%zu miners)\n", pp.matureAtHeight, pp.amounts.size()); - for (auto& kv : pp.amounts) { + " (%zu miners)\n", ppQueued.matureAtHeight, ppQueued.amounts.size()); + bool fAbortBatch = false; + for (auto& kv : ppQueued.amounts) { + bool alreadyPaid = false; + { + std::lock_guard lk(gPayoutLedgerMutex); + for (const PendingPayout& pp : gPendingPayouts) + if (pp.id == ppQueued.id && pp.paidTxids.count(kv.first)) + alreadyPaid = true; + } + if (alreadyPaid) + continue; + uint160 h160; if (!AddressToHash160(kv.first, h160)) { // Should not happen -- bad addresses are filtered in QueuePayouts @@ -451,16 +537,70 @@ static void PayoutThreadFn(void*) CScript sc; sc << OP_DUP << OP_HASH160 << h160 << OP_EQUALVERIFY << OP_CHECKSIG; CWalletTx wtx; - wtx.mapValue["comment"] = strprintf("Pool payout height %d", - pp.matureAtHeight); - if (SendMoney(sc, kv.second, wtx)) + wtx.mapValue["comment"] = strprintf("Pool payout height %d", ppQueued.matureAtHeight); + wtx.mapValue["pool_payout_id"] = ppQueued.id; + wtx.mapValue["pool_payout_address"] = kv.first; + wtx.mapValue["pool_payout_amount"] = strprintf("%lld", kv.second); + + std::string txid; + if (FindRecordedPoolPayout(ppQueued.id, kv.first, kv.second, txid)) + { + LogPrint("payout", "[payout] recovered recorded payout %s to %s" + " from wallet tx %s\n", + FormatMoney(kv.second).c_str(), kv.first.c_str(), txid.c_str()); + } + else if (SendMoney(sc, kv.second, wtx)) + { + txid = wtx.GetHash().GetHex(); LogPrint("payout", "[payout] paid %s to %s\n", - FormatMoney(kv.second).c_str(), kv.first.c_str()); + FormatMoney(kv.second).c_str(), kv.first.c_str()); + } else + { LogPrint("payout", "[payout] FAILED to pay %s to %s" - " (insufficient funds or wallet locked?)\n", - FormatMoney(kv.second).c_str(), kv.first.c_str()); + " (insufficient funds or wallet locked?)\n", + FormatMoney(kv.second).c_str(), kv.first.c_str()); + std::lock_guard lk(gPayoutLedgerMutex); + for (PendingPayout& pp : gPendingPayouts) + if (pp.id == ppQueued.id) + pp.nextAttempt = GetTime() + 60; + SavePendingPayoutsLocked(); + fAbortBatch = true; + break; + } + + { + std::lock_guard lk(gPayoutLedgerMutex); + for (PendingPayout& pp : gPendingPayouts) { + if (pp.id != ppQueued.id) + continue; + pp.paidTxids[kv.first] = txid; + pp.nextAttempt = 0; + SavePendingPayoutsLocked(); + break; + } + } } + + { + std::lock_guard ledger(gPayoutLedgerMutex); + for (auto it = gPendingPayouts.begin(); it != gPendingPayouts.end(); ) { + if (it->id == ppQueued.id && PayoutFullyPaid(*it)) { + LogPrint("payout", "[payout] payout %s complete -- removing from ledger\n", + it->id.c_str()); + it = gPendingPayouts.erase(it); + SavePendingPayoutsLocked(); + } else { + ++it; + } + } + } + { + std::lock_guard lk(gPayoutExecMutex); + gQueuedPayouts.erase(ppQueued.id); + } + if (fAbortBatch) + continue; } Sleep(500); // check for new work every 500ms @@ -1073,16 +1213,21 @@ static void RefreshStats(int blocksFoundThisSession, uint64 roundShareTotal) : a.lastSeen > b.lastSeen; }); - // gPendingPayouts is event-loop-only data (no lock needed here since - // RefreshStats is always called from the event loop thread). gPayoutViews.clear(); + std::lock_guard ledger(gPayoutLedgerMutex); for (const PendingPayout& pp : gPendingPayouts) { PendingPayoutView pv; pv.matureAtHeight = pp.matureAtHeight; - pv.recipients = (int)pp.amounts.size(); + pv.recipients = 0; pv.totalAmount = 0; - for (auto& kv : pp.amounts) pv.totalAmount += kv.second; - gPayoutViews.push_back(pv); + for (auto& kv : pp.amounts) { + if (pp.paidTxids.count(kv.first)) + continue; + pv.recipients++; + pv.totalAmount += kv.second; + } + if (pv.recipients > 0) + gPayoutViews.push_back(pv); } } -- cgit v1.2.3 From 8c2e375600c0eb9bedb8f85ee4fa860825f23521 Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Fri, 7 Aug 2026 11:40:46 -0300 Subject: test: cover pool Stratum share accounting --- src/Makefile | 1 + src/makefile.mingw | 1 + src/rpc.cpp | 85 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/selftest.cpp | 6 +++- 4 files changed, 92 insertions(+), 1 deletion(-) diff --git a/src/Makefile b/src/Makefile index cc52fd6..bc15ef8 100644 --- a/src/Makefile +++ b/src/Makefile @@ -191,5 +191,6 @@ tests: test_bip32 bitflash-node ./bitflash-node -selftest=wallet-encrypt ./bitflash-node -selftest=net-message ./bitflash-node -selftest=consensus-limits + ./bitflash-node -selftest=pool-stratum .PHONY: tests fuzz-net-message-smoke fuzz-script-smoke diff --git a/src/makefile.mingw b/src/makefile.mingw index e076c49..b946e50 100644 --- a/src/makefile.mingw +++ b/src/makefile.mingw @@ -116,3 +116,4 @@ tests: test_bip32.exe bitflash.exe ./bitflash.exe -selftest=wallet-encrypt ./bitflash.exe -selftest=net-message ./bitflash.exe -selftest=consensus-limits + ./bitflash.exe -selftest=pool-stratum diff --git a/src/rpc.cpp b/src/rpc.cpp index 8f3740d..383769a 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -1180,6 +1180,91 @@ static bool HandleLine(Miner* m, const std::string& rawLine, return true; } +int RunPoolStratumSelfTest() +{ + int nFail = 0; + auto check = [&](bool ok, const char* msg) { + fprintf(stdout, " %s %s\n", ok ? "ok" : "FAIL", msg); + if (!ok) nFail++; + }; + + fprintf(stdout, "pool-stratum self-test\n"); + + StratumJob savedJob = gCurrentJob; + bool savedHaveJob = gHaveJob; + + CTransaction coinbase; + coinbase.vin.resize(1); + coinbase.vin[0].prevout.SetNull(); + coinbase.vin[0].scriptSig << 1 << (CBigNum)1; + coinbase.vout.resize(1); + coinbase.vout[0].nValue = 50 * COIN; + + CBlock block; + block.vtx.push_back(coinbase); + block.nVersion = 1; + block.nBits = 0x1f0fffff; + block.nTime = 1; + block.nNonce = 1; + block.hashMerkleRoot = block.BuildMerkleTree(); + + gCurrentJob.jobId = "selftest"; + gCurrentJob.block = block; + gCurrentJob.target = 0; // accepted share, not a block candidate + gCurrentJob.height = 1; + gHaveJob = true; + + Miner miner(INVALID_SOCKET); + miner.authorised = true; + miner.address = "selftest-address"; + miner.worker = "worker"; + miner.shareTarget = ~uint256(0); // every RandomX hash is a valid share + miner.difficulty = 0.001; + miner.vardiffWindowStart = GetTime(); + + std::map roundShareCount; + uint64 roundShareTotal = 0; + int blocksFound = 0; + + uint256 targetFromNotify; + CBlock minerBlock = BuildMinerBlock(gCurrentJob, miner.extranonce2); + json params = MakeNotifyParams(gCurrentJob, minerBlock, miner.shareTarget, true); + std::vector targetBytes = FromHex(params[3].get()); + if (targetBytes.size() == 32) + memcpy(&targetFromNotify, &targetBytes[0], 32); + check(targetFromNotify == miner.shareTarget, + "mining.notify exposes the same share target the pool validates"); + + json submit = { + {"id", 7}, + {"method", "mining.submit"}, + {"params", json::array({miner.address, "selftest", ToHex(&minerBlock.nNonce, 4)})} + }; + HandleLine(&miner, submit.dump(), roundShareCount, roundShareTotal, blocksFound); + + check(roundShareTotal == 1, "fake low-difficulty share increments the round total"); + check(roundShareCount[miner.address] == 1, "fake share is credited to the submitting address"); + check(miner.sessionShares == 1 && miner.roundShares == 1 && miner.totalShares == 1, + "miner share counters advance together"); + check(blocksFound == 0, "share below block target is not counted as a found block"); + + json stale = { + {"id", 8}, + {"method", "mining.submit"}, + {"params", json::array({miner.address, "stale", ToHex(&minerBlock.nNonce, 4)})} + }; + HandleLine(&miner, stale.dump(), roundShareCount, roundShareTotal, blocksFound); + check(roundShareTotal == 1, "stale submit does not increment shares"); + + gCurrentJob = savedJob; + gHaveJob = savedHaveJob; + + fprintf(stdout, "%s (%d failure%s)\n", nFail == 0 ? "ALL TESTS PASSED" : "TESTS FAILED", + nFail, nFail == 1 ? "" : "s"); + fflush(stdout); + return nFail == 0 ? 0 : 1; +} + // --------------------------------------------------------------------------- // Stats refresh -- called every 2s from event loop // --------------------------------------------------------------------------- diff --git a/src/selftest.cpp b/src/selftest.cpp index ff0289b..fadc959 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -10,6 +10,8 @@ #include "selftest.h" #include "walletcmd.h" +extern int RunPoolStratumSelfTest(); + // Test results go to the terminal, not to debug.log. // // printf in this tree is OutputDebugStringF, which writes into the data @@ -1234,8 +1236,10 @@ int RunSelfTest(const std::string& name) return RunNetMessageSelfTest(); if (name == "consensus-limits") return RunConsensusLimitsSelfTest(); + if (name == "pool-stratum") + return RunPoolStratumSelfTest(); 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\n"); + printf("Known self-tests: wallet-keypool, wallet-hd, wallet-format, wallet-crypto, wallet-encrypt, net-message, consensus-limits, pool-stratum\n"); return 1; } -- cgit v1.2.3 From 5c2e66b70ab3c15603a6e2d7a4ea9e02f3cd0604 Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Fri, 7 Aug 2026 11:30:49 -0300 Subject: pool: write dashboard status JSON --- README.md | 6 ++++ src/main.cpp | 1 + src/main.h | 1 + src/main_gui.cpp | 5 ++++ src/rpc.cpp | 83 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 96 insertions(+) diff --git a/README.md b/README.md index 6746e02..af56081 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,11 @@ Open **Options** from the menu bar. Under Mining Mode: Pool announcements are published on Nostr with live status fields, so external tools can query the latest pool state by `.btf` address. +Pool operators also write a local `pool_status.json` every ten seconds. By +default it is created in the data directory; use `-poolstatusfile=PATH` to write +it somewhere a dashboard or web sync can read. The file is informational only: +nodes still discover pools through Nostr and `.btf`, not through any web domain. + **Participant** — mine to someone else's pool. Enter or select the pool's `.btf` address and enable Start Mining. SRBMiner and XMRig connect to operator pools through the `.btf` path: @@ -236,6 +241,7 @@ xmrig -a rx/0 -o POOL_BTF_ADDRESS -u YOUR_BTF_ADDRESS -p x ./bitflash -nogui # node only ./bitflash -nogui -gen # node + solo mining ./bitflash -nogui -gen -operator # pool operator +./bitflash -nogui -gen -operator -poolstatusfile=/var/www/api/pools.json ./bitflash -nogui -gen -participant=POOL_BTF_ADDRESS # mine to pool ``` diff --git a/src/main.cpp b/src/main.cpp index 2610c45..6cb7353 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -104,6 +104,7 @@ bool fMineModeFromCommandLine = false; string strParticipantPool; // participant mode: pool .btf address string strPoolName = "Bitflash Pool"; string strPoolDashboardUrl; +string strPoolStatusFile; double dPoolFeePercent = 0.0; static std::atomic gParticipantSharesSent{0}; diff --git a/src/main.h b/src/main.h index c458412..3866855 100644 --- a/src/main.h +++ b/src/main.h @@ -86,6 +86,7 @@ extern bool fMineModeFromCommandLine; extern string strParticipantPool; // participant: pool .btf address extern string strPoolName; // operator: announced pool name extern string strPoolDashboardUrl; // operator: optional dashboard URL +extern string strPoolStatusFile; // operator: optional JSON status output path extern double dPoolFeePercent; // operator: announced fee percent extern bool fSoloMineTest; // /solomine: mine without requiring a peer (local test) // Threads to hash with; 0 = decide from the hardware. Set by /genproclimit. diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 875988f..41661b9 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -123,6 +123,7 @@ static void PrintUsage() printf(" /poolname=NAME\n"); printf(" /poolfee=PCT\n"); printf(" /pooldashboard=URL (alias: /pooldash=URL)\n"); + printf(" /poolstatusfile=PATH (write pool status JSON for dashboards)\n"); printf("\n"); printf(".btf and rendezvous:\n"); printf(" /connectbtf=PEER_BTF_ADDRESS\n"); @@ -239,6 +240,10 @@ static void ParseStartupArguments(int argc, char* argv[]) if (!poolFee.empty()) dPoolFeePercent = atof(poolFee.c_str()); + string poolStatusFile = argval2(argc, argv, "/poolstatusfile", "-poolstatusfile"); + if (!poolStatusFile.empty()) + strPoolStatusFile = poolStatusFile; + string btfConnect = argval2(argc, argv, "/connectbtf", "-connectbtf"); if (!btfConnect.empty()) strBtfConnect = btfConnect; diff --git a/src/rpc.cpp b/src/rpc.cpp index 383769a..1aa4f9b 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -623,6 +623,7 @@ static StatsSnapshot gStats; static std::vector gWorkerStats; static std::vector gPayoutViews; static std::mutex gStatsMutex; +static int64 gLastPoolStatusWrite = 0; void GetPoolOperatorStats(int& authorizedMiners, int& blocksFound, uint64& roundShares, double& totalHashRate) { @@ -645,6 +646,82 @@ void GetPendingPayouts(std::vector& out) out = gPayoutViews; } +static std::string PoolStatusFilePath() +{ + if (!strPoolStatusFile.empty()) + return strPoolStatusFile; + return GetAppDir() + "/pool_status.json"; +} + +static void WritePoolStatusJsonLocked() +{ + nlohmann::json j; + j["schema"] = "bitflash-pool-status-1"; + j["network"] = "bitflash"; + j["updatedAt"] = GetTime(); + + j["operator"] = { + {"name", strPoolName}, + {"btfAddress", BtfLocalAddress()}, + {"feePercent", dPoolFeePercent}, + {"dashboardUrl", strPoolDashboardUrl} + }; + + j["node"] = { + {"height", nBestHeight}, + {"poolRunning", (bool)gPoolRunning}, + {"mode", nMineMode} + }; + + j["pool"] = { + {"authorizedMiners", gStats.authorizedMiners}, + {"blocksFoundSession", gStats.blocksFound}, + {"roundShares", gStats.roundShares}, + {"hashRate", gStats.totalHashRate} + }; + + j["workers"] = nlohmann::json::array(); + for (const PoolWorkerStatView& row : gWorkerStats) { + j["workers"].push_back({ + {"address", row.address}, + {"worker", row.worker}, + {"totalShares", row.totalShares}, + {"roundShares", row.roundShares}, + {"lastSeen", row.lastSeen}, + {"hashRate", row.hashRate} + }); + } + + j["pendingPayouts"] = nlohmann::json::array(); + for (const PendingPayoutView& row : gPayoutViews) { + j["pendingPayouts"].push_back({ + {"matureAtHeight", row.matureAtHeight}, + {"recipients", row.recipients}, + {"totalAmountSatoshis", row.totalAmount}, + {"totalAmountBtf", FormatMoney(row.totalAmount)} + }); + } + + std::string path = PoolStatusFilePath(); + std::string tmp = path + ".tmp"; + FILE* f = fopen(tmp.c_str(), "w"); + if (!f) { + LogPrint("pool", "[pool] WARNING: could not write status file %s\n", tmp.c_str()); + return; + } + std::string s = j.dump(2); + fwrite(s.c_str(), 1, s.size(), f); + if (fclose(f) != 0) { + LogPrint("pool", "[pool] WARNING: could not close status file %s\n", tmp.c_str()); + return; + } +#ifdef _WIN32 + remove(path.c_str()); +#endif + if (rename(tmp.c_str(), path.c_str()) != 0) + LogPrint("pool", "[pool] WARNING: could not replace status file %s\n", path.c_str()); +} + // --------------------------------------------------------------------------- // Miner state -- event loop thread only // --------------------------------------------------------------------------- @@ -1314,6 +1391,12 @@ static void RefreshStats(int blocksFoundThisSession, uint64 roundShareTotal) if (pv.recipients > 0) gPayoutViews.push_back(pv); } + + int64 now = GetTime(); + if (now - gLastPoolStatusWrite >= 10) { + WritePoolStatusJsonLocked(); + gLastPoolStatusWrite = now; + } } // --------------------------------------------------------------------------- -- cgit v1.2.3 From 8bf5b8cac5d91165b11d4ce65333b68edcc723b0 Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Fri, 7 Aug 2026 11:34:17 -0300 Subject: pool: add local Stratum bridge --- README.md | 9 ++- src/main.cpp | 186 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.h | 3 + src/main_gui.cpp | 22 +++++++ src/net.cpp | 2 +- src/readme.txt | 5 ++ src/sockcount.h | 1 + 7 files changed, 224 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index af56081..7f4d9b0 100644 --- a/README.md +++ b/README.md @@ -226,11 +226,13 @@ nodes still discover pools through Nostr and `.btf`, not through any web domain. **Participant** — mine to someone else's pool. Enter or select the pool's `.btf` address and enable Start Mining. -SRBMiner and XMRig connect to operator pools through the `.btf` path: +The built-in participant miner connects to operator pools through the `.btf` +path. External miners can use Bitflash as a local Stratum bridge: ```bash -SRBMiner-MULTI --algorithm randomx --pool POOL_BTF_ADDRESS --wallet YOUR_BTF_ADDRESS --password x -xmrig -a rx/0 -o POOL_BTF_ADDRESS -u YOUR_BTF_ADDRESS -p x +./bitflash -nogui -stratumbridge=POOL_BTF_ADDRESS -stratumbridgeport=3333 +SRBMiner-MULTI --algorithm randomx --pool 127.0.0.1:3333 --wallet YOUR_BTF_ADDRESS --password x +xmrig -a rx/0 -o 127.0.0.1:3333 -u YOUR_BTF_ADDRESS -p x ``` --- @@ -243,6 +245,7 @@ xmrig -a rx/0 -o POOL_BTF_ADDRESS -u YOUR_BTF_ADDRESS -p x ./bitflash -nogui -gen -operator # pool operator ./bitflash -nogui -gen -operator -poolstatusfile=/var/www/api/pools.json ./bitflash -nogui -gen -participant=POOL_BTF_ADDRESS # mine to pool +./bitflash -nogui -stratumbridge=POOL_BTF_ADDRESS # local bridge for XMRig/SRBMiner ``` Every option takes `-` or `/`. **Under MSYS2 use the `-` form** — the shell diff --git a/src/main.cpp b/src/main.cpp index 6cb7353..2be9c54 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -106,6 +106,8 @@ string strPoolName = "Bitflash Pool"; string strPoolDashboardUrl; string strPoolStatusFile; double dPoolFeePercent = 0.0; +bool fStratumBridge = false; +int nStratumBridgePort = 3333; static std::atomic gParticipantSharesSent{0}; static std::atomic gParticipantSharesAccepted{0}; @@ -3873,6 +3875,190 @@ static bool PoolParticipantMiner() return true; } +// --------------------------------------------------------------------------- +// Local Stratum bridge -- lets external miners speak plain Stratum to +// 127.0.0.1 while Bitflash opens the .btf tunnel to the selected pool. +// --------------------------------------------------------------------------- + +struct StratumBridgeClientCtx +{ + SOCKET miner; + std::string pool; +}; + +static bool BridgeSendAll(SOCKET s, const char* p, int n) +{ + 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 BridgePump(SOCKET a, SOCKET b) +{ + char buf[8192]; + while (!fShutdown) + { + fd_set rd; + FD_ZERO(&rd); + FD_SET(a, &rd); + FD_SET(b, &rd); + SOCKET maxfd = a > b ? a : b; + struct timeval tv = {1, 0}; + int r = select((int)maxfd + 1, &rd, NULL, NULL, &tv); + if (r <= 0) + continue; + + if (FD_ISSET(a, &rd)) + { + int n = recv(a, buf, sizeof(buf), 0); + if (n <= 0 || !BridgeSendAll(b, buf, n)) + break; + } + if (FD_ISSET(b, &rd)) + { + int n = recv(b, buf, sizeof(buf), 0); + if (n <= 0 || !BridgeSendAll(a, buf, n)) + break; + } + } +} + +static void ThreadStratumBridgeClient(void* arg) +{ + StratumBridgeClientCtx* ctx = (StratumBridgeClientCtx*)arg; + SOCKET miner = ctx->miner; + std::string pool = ctx->pool; + delete ctx; + + unsigned char target_pubkey[32]; + if (!btf::ParseAddress(pool, target_pubkey)) + { + LogPrint("worker", "[bridge] invalid pool .btf address: %s\n", pool.c_str()); + BtfCloseSocket(miner); + return; + } + + std::string meetingHostPort; + unsigned char service_enc_pub[32]; + if (!BtfResolve(pool, meetingHostPort, service_enc_pub)) + { + LogPrint("worker", "[bridge] failed to resolve pool %s\n", pool.c_str()); + BtfCloseSocket(miner); + return; + } + + size_t colon = meetingHostPort.rfind(':'); + if (colon == std::string::npos) + { + LogPrint("worker", "[bridge] bad pool endpoint '%s'\n", meetingHostPort.c_str()); + BtfCloseSocket(miner); + return; + } + std::string host = meetingHostPort.substr(0, colon); + int port = atoi(meetingHostPort.substr(colon + 1).c_str()); + if (port <= 0 || port > 65535) + { + LogPrint("worker", "[bridge] bad pool port in '%s'\n", meetingHostPort.c_str()); + BtfCloseSocket(miner); + return; + } + + SOCKET poolSock = btf::BtfClientTunnel(host.c_str(), (unsigned short)port, + target_pubkey, service_enc_pub); + if (poolSock == INVALID_SOCKET) + { + LogPrint("worker", "[bridge] tunnel connect failed for pool %s via %s\n", + pool.c_str(), meetingHostPort.c_str()); + BtfCloseSocket(miner); + return; + } + + LogPrint("worker", "[bridge] miner connected to pool %s via %s\n", + pool.c_str(), meetingHostPort.c_str()); + BridgePump(miner, poolSock); + BtfCloseSocket(poolSock); + BtfCloseSocket(miner); + LogPrint("worker", "[bridge] miner disconnected from pool %s\n", pool.c_str()); +} + +void ThreadStratumBridge(void*) +{ + if (strParticipantPool.empty()) + { + printf("Stratum bridge: no pool selected. Use /stratumbridge=POOL_BTF_ADDRESS\n"); + return; + } + + SOCKET listener = BtfSocketTag(socket(AF_INET, SOCK_STREAM, IPPROTO_TCP), + SOCK_STRATUM_BRIDGE); + if (listener == INVALID_SOCKET) + { + printf("Stratum bridge: could not create listen socket\n"); + return; + } + + int yes = 1; + setsockopt(listener, SOL_SOCKET, SO_REUSEADDR, (char*)&yes, sizeof(yes)); + + sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(0x7f000001); + addr.sin_port = htons((unsigned short)nStratumBridgePort); + + if (bind(listener, (sockaddr*)&addr, sizeof(addr)) != 0 || + listen(listener, 16) != 0) + { + printf("Stratum bridge: could not listen on 127.0.0.1:%d\n", + nStratumBridgePort); + BtfCloseSocket(listener); + return; + } + + printf("Stratum bridge: listening on 127.0.0.1:%d for pool %s\n", + nStratumBridgePort, strParticipantPool.c_str()); + + while (!fShutdown) + { + fd_set rd; + FD_ZERO(&rd); + FD_SET(listener, &rd); + struct timeval tv = {1, 0}; + if (select((int)listener + 1, &rd, NULL, NULL, &tv) <= 0) + continue; + + sockaddr_in cli; +#ifdef _WIN32 + int len = sizeof(cli); +#else + socklen_t len = sizeof(cli); +#endif + SOCKET miner = BtfSocketTag(accept(listener, (sockaddr*)&cli, &len), + SOCK_STRATUM_BRIDGE); + if (miner == INVALID_SOCKET) + continue; + + StratumBridgeClientCtx* ctx = new StratumBridgeClientCtx(); + ctx->miner = miner; + ctx->pool = strParticipantPool; + if (_beginthread(ThreadStratumBridgeClient, 0, ctx) == (uintptr_t)-1) + { + delete ctx; + BtfCloseSocket(miner); + LogPrint("worker", "[bridge] failed to spawn client thread\n"); + } + } + + BtfCloseSocket(listener); + printf("Stratum bridge: stopped\n"); +} + bool BitcoinMiner(int nThreadId) { // Relay mode: never mines, just relays/syncs. (Shouldn't normally get here diff --git a/src/main.h b/src/main.h index 3866855..2786a4a 100644 --- a/src/main.h +++ b/src/main.h @@ -88,6 +88,8 @@ extern string strPoolName; // operator: announced pool name extern string strPoolDashboardUrl; // operator: optional dashboard URL extern string strPoolStatusFile; // operator: optional JSON status output path extern double dPoolFeePercent; // operator: announced fee percent +extern bool fStratumBridge; // participant helper: local Stratum bridge +extern int nStratumBridgePort; // local bridge listen port, default 3333 extern bool fSoloMineTest; // /solomine: mine without requiring a peer (local test) // Threads to hash with; 0 = decide from the hardware. Set by /genproclimit. extern int nMinerThreads; @@ -272,6 +274,7 @@ void LimitOrphanBlocks(unsigned int nMaxOrphans); // distinguishable, which the dedup filter would otherwise collapse into one. bool BitcoinMiner(int nThreadId = 1); void ThreadRPCServer(void* parg); // rpc.cpp -- .btf pool server +void ThreadStratumBridge(void* parg); // main.cpp -- local Stratum -> .btf bridge void GetParticipantMiningStats(uint64& sharesSent, uint64& sharesAccepted, double& hashRate); void SetParticipantMiningStatus(const std::string& status); std::string GetParticipantMiningStatus(); diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 41661b9..ce6d2b5 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -115,6 +115,8 @@ static void PrintUsage() printf("Mining mode:\n"); printf(" /operator\n"); printf(" /participant=POOL_BTF_ADDRESS\n"); + printf(" /stratumbridge=POOL_BTF_ADDRESS\n"); + printf(" /stratumbridgeport=N (default 3333; listen on 127.0.0.1)\n"); printf(" /solomine\n"); printf(" /genproclimit=N (mining threads; 0 or absent = every core but one)\n"); printf(" /checkblocks=N (blocks re-verified at startup, default 288, 0 = all)\n"); @@ -226,6 +228,22 @@ static void ParseStartupArguments(int argc, char* argv[]) fMineModeFromCommandLine = true; } + if (arg(argc,argv,"/stratumbridge") || arg(argc,argv,"-stratumbridge")) + { + fStratumBridge = true; + string bridgePool = argval2(argc, argv, "/stratumbridge", "-stratumbridge"); + if (!bridgePool.empty()) + strParticipantPool = bridgePool; + } + + string bridgePort = argval2(argc, argv, "/stratumbridgeport", "-stratumbridgeport"); + if (!bridgePort.empty()) + { + int nPort = atoi(bridgePort.c_str()); + if (nPort > 0 && nPort <= 65535) + nStratumBridgePort = nPort; + } + string poolName = argval2(argc, argv, "/poolname", "-poolname"); if (!poolName.empty()) strPoolName = poolName; @@ -623,6 +641,10 @@ int main(int argc, char* argv[]) if (_beginthread(ThreadRPCServer, 0, NULL) == (uintptr_t)-1) printf("Error: _beginthread(ThreadRPCServer) failed\n"); } + if (fStratumBridge) { + if (_beginthread(ThreadStratumBridge, 0, NULL) == (uintptr_t)-1) + printf("Error: _beginthread(ThreadStratumBridge) failed\n"); + } if (fGenerateBitcoins) StartMinerThreads(); diff --git a/src/net.cpp b/src/net.cpp index 449d91c..b9bb95d 100644 --- a/src/net.cpp +++ b/src/net.cpp @@ -153,7 +153,7 @@ static const char* pszSockSite[SOCK_SITES] = { "external-ip probe", "listen socket", "inbound accept", "rendezvous dial", "rendezvous listen", "rendezvous accept", "loopback pair listener", "loopback pair app end", "loopback pair pump end", - "nostr relay" + "nostr relay", "stratum bridge" }; static string SockAccountingText() diff --git a/src/readme.txt b/src/readme.txt index 6af1694..fa58b6e 100644 --- a/src/readme.txt +++ b/src/readme.txt @@ -46,6 +46,11 @@ Command-line options (in addition to the original ones) /operator operator mode (run pool server) /participant=ADDR participant mode; mine to pool .btf address ADDR + /stratumbridge=ADDR + listen on 127.0.0.1:3333 and bridge external Stratum miners + to pool .btf address ADDR + /stratumbridgeport=N + local Stratum bridge port (default 3333) /poolname=NAME operator announcement pool name /poolfee=PCT operator announcement fee percent (example: 0.75) /pooldashboard=URL diff --git a/src/sockcount.h b/src/sockcount.h index 96b6efb..5147f55 100644 --- a/src/sockcount.h +++ b/src/sockcount.h @@ -63,6 +63,7 @@ enum SOCK_PAIR_APP, // loopback pair: the app end handed to CNode SOCK_PAIR_PUMP, // loopback pair: the pump end SOCK_NOSTR, // outbound to a Nostr relay + SOCK_STRATUM_BRIDGE, // local Stratum bridge listener/miner sockets SOCK_SITES }; -- cgit v1.2.3 From a0355492c3bae37ad57ef3e05302a3e5b1a80e3e Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Fri, 7 Aug 2026 11:42:30 -0300 Subject: tools: build a static pool directory --- docs/pool-directory.md | 35 ++++++++++ scripts/build-pool-index.py | 153 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 docs/pool-directory.md create mode 100644 scripts/build-pool-index.py diff --git a/docs/pool-directory.md b/docs/pool-directory.md new file mode 100644 index 0000000..6456084 --- /dev/null +++ b/docs/pool-directory.md @@ -0,0 +1,35 @@ +# Public Pool Directory + +`pool.bitflash.network` should be a public directory, not a network dependency. +Nodes discover pools through Nostr and `.btf`; the website only helps humans +compare operators. + +Operators can publish the `pool_status.json` file produced by: + +```bash +bitflash -nogui -gen -operator -poolstatusfile=/var/www/pool_status.json +``` + +Build a static directory from one or more status files: + +```bash +python3 scripts/build-pool-index.py --out-dir public/pool \ + https://operator-one.example/pool_status.json \ + https://operator-two.example/pool_status.json +``` + +The generated output contains: + +- `index.html`: human-readable ranking page. +- `pools.json`: machine-readable API for `api.bitflash.network/pools.json`. + +Ranking order is intentionally simple: + +1. online pools first; +2. higher reported hashrate; +3. more authorized miners; +4. lower fee; +5. name as a stable tie-breaker. + +An entry is considered online when the source says the pool is running and its +status file is no more than 120 seconds old. diff --git a/scripts/build-pool-index.py b/scripts/build-pool-index.py new file mode 100644 index 0000000..40eb8f4 --- /dev/null +++ b/scripts/build-pool-index.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +# +# Build a static public pool directory from one or more Bitflash pool_status.json +# files. Inputs may be local paths or http(s) URLs. + +import argparse +import html +import json +import os +import time +import urllib.request + + +def load_json(source): + if source.startswith("http://") or source.startswith("https://"): + with urllib.request.urlopen(source, timeout=10) as r: + return json.loads(r.read().decode("utf-8")) + with open(source, "r", encoding="utf-8-sig") as f: + return json.load(f) + + +def normalize_pool(source, obj, now): + operator = obj.get("operator", {}) + pool = obj.get("pool", {}) + node = obj.get("node", {}) + updated = int(obj.get("updatedAt") or 0) + age = max(0, now - updated) if updated else None + online = bool(node.get("poolRunning")) and age is not None and age <= 120 + return { + "source": source, + "name": operator.get("name") or "Bitflash Pool", + "btfAddress": operator.get("btfAddress") or "", + "feePercent": float(operator.get("feePercent") or 0.0), + "dashboardUrl": operator.get("dashboardUrl") or "", + "height": int(node.get("height") or 0), + "online": online, + "ageSeconds": age, + "authorizedMiners": int(pool.get("authorizedMiners") or 0), + "blocksFoundSession": int(pool.get("blocksFoundSession") or 0), + "roundShares": int(pool.get("roundShares") or 0), + "hashRate": float(pool.get("hashRate") or 0.0), + } + + +def rank_key(pool): + return ( + 1 if pool["online"] else 0, + pool["hashRate"], + pool["authorizedMiners"], + -pool["feePercent"], + pool["name"].lower(), + ) + + +def render_html(pools, generated_at): + rows = [] + for p in pools: + dash = p["dashboardUrl"] + dash_html = ( + 'dashboard'.format(html.escape(dash, quote=True)) + if dash else "-" + ) + rows.append( + "" + "{status}" + "{name}
{addr}" + "{fee:.2f}%" + "{miners}" + "{hashrate:.2f}" + "{blocks}" + "{height}" + "{age}" + "{dash}" + "".format( + status="online" if p["online"] else "stale", + name=html.escape(p["name"]), + addr=html.escape(p["btfAddress"]), + fee=p["feePercent"], + miners=p["authorizedMiners"], + hashrate=p["hashRate"], + blocks=p["blocksFoundSession"], + height=p["height"], + age="{}s".format(p["ageSeconds"]) if p["ageSeconds"] is not None else "-", + dash=dash_html, + ) + ) + + return """ + + + + + Bitflash Pools + + + +

Bitflash Pools

+

This directory is informational. Bitflash nodes discover pools through Nostr and .btf; this page only helps humans compare public operators.

+ + + + + + {rows} + +
StatusPoolFeeMinersH/sBlocksHeightAgeLink
+

Generated at {generated_at}. JSON: pools.json.

+ + +""".format(rows="\n ".join(rows), generated_at=generated_at) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--out-dir", default="pool-site") + ap.add_argument("sources", nargs="+", help="pool_status.json paths or URLs") + args = ap.parse_args() + + now = int(time.time()) + pools = [] + errors = [] + for source in args.sources: + try: + pools.append(normalize_pool(source, load_json(source), now)) + except Exception as e: + errors.append({"source": source, "error": str(e)}) + + pools.sort(key=rank_key, reverse=True) + os.makedirs(args.out_dir, exist_ok=True) + + generated_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now)) + with open(os.path.join(args.out_dir, "pools.json"), "w", encoding="utf-8") as f: + json.dump({ + "schema": "bitflash-pool-directory-1", + "generatedAt": generated_at, + "pools": pools, + "errors": errors, + }, f, indent=2, sort_keys=True) + f.write("\n") + + with open(os.path.join(args.out_dir, "index.html"), "w", encoding="utf-8") as f: + f.write(render_html(pools, generated_at)) + + +if __name__ == "__main__": + main() -- cgit v1.2.3 From 78cf0c3cb6752bd36bbc8fe7017d5d60cca13da7 Mon Sep 17 00:00:00 2001 From: Bitflash Date: Fri, 7 Aug 2026 15:07:56 -0300 Subject: tools: reject non-http(s) dashboard URLs in the pool directory A pool's dashboard URL comes from its self-published status file. Escaping it for HTML is not enough: a javascript: or data: scheme would still render as a clickable script-injection on the public directory page. Only emit a link for http(s) schemes; anything else shows as no link. --- scripts/build-pool-index.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/scripts/build-pool-index.py b/scripts/build-pool-index.py index 40eb8f4..382b6c3 100644 --- a/scripts/build-pool-index.py +++ b/scripts/build-pool-index.py @@ -56,10 +56,15 @@ def render_html(pools, generated_at): rows = [] for p in pools: dash = p["dashboardUrl"] - dash_html = ( - 'dashboard'.format(html.escape(dash, quote=True)) - if dash else "-" - ) + # Only linkify http(s). The dashboard URL comes from an operator's + # self-published status file, so a "javascript:" or "data:" scheme + # would become a clickable script-injection on this public page. + dash_lower = dash.lower() + if dash_lower.startswith("http://") or dash_lower.startswith("https://"): + dash_html = 'dashboard'.format( + html.escape(dash, quote=True)) + else: + dash_html = "-" rows.append( "" "{status}" -- cgit v1.2.3 From 060a37de4a1f08ffb5939c8fc47a805c5d0d0024 Mon Sep 17 00:00:00 2001 From: Bitflash Date: Fri, 7 Aug 2026 16:46:15 -0300 Subject: bignum: shift setint64 in unsigned space to avoid signed overflow UB setint64() extracted big-endian bytes with a signed left shift (n <<= 8). When the top byte is set that overflows int64 -- undefined behaviour, which the script fuzzer flagged under UBSan (bignum.h:158). The result was correct on two's-complement hardware, so this is a latent UB inherited from the original 0.1 code, not a behaviour change. Do the shift on a uint64. --- src/bignum.h | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/bignum.h b/src/bignum.h index 9da104b..6a295b2 100644 --- a/src/bignum.h +++ b/src/bignum.h @@ -155,7 +155,9 @@ public: for (int i = 0; i < 8; i++) { unsigned char c = (n >> 56) & 0xff; - n <<= 8; + n = (int64)((uint64)n << 8); // shift in unsigned space; <<= on a + // signed int64 with the top byte set + // is overflow UB (fuzzer/UBSan finding) if (fLeadingZeroes) { if (c == 0) -- cgit v1.2.3 From b1eb8b66b8d7b47c7fb2e85d5d4db86a11823366 Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Fri, 7 Aug 2026 17:14:36 -0300 Subject: pool: publish round proof ledger --- README.md | 14 +- docs/pool-directory.md | 12 +- scripts/build-pool-index.py | 18 ++- src/main.cpp | 1 + src/main.h | 1 + src/main_gui.cpp | 5 + src/readme.txt | 4 + src/rpc.cpp | 316 +++++++++++++++++++++++++++++++++++++++++++- 8 files changed, 359 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 7f4d9b0..ec8e9c4 100644 --- a/README.md +++ b/README.md @@ -219,10 +219,12 @@ Open **Options** from the menu bar. Under Mining Mode: Pool announcements are published on Nostr with live status fields, so external tools can query the latest pool state by `.btf` address. -Pool operators also write a local `pool_status.json` every ten seconds. By -default it is created in the data directory; use `-poolstatusfile=PATH` to write -it somewhere a dashboard or web sync can read. The file is informational only: -nodes still discover pools through Nostr and `.btf`, not through any web domain. +Pool operators also write a local `pool_status.json` every ten seconds and a +`pool_rounds.json` proof ledger whenever they find blocks or pay miners. By +default both are created in the data directory; use `-poolstatusfile=PATH` and +`-poolroundsfile=PATH` to write them somewhere a dashboard or web sync can read. +The files are informational only: nodes still discover pools through Nostr and +`.btf`, not through any web domain. **Participant** — mine to someone else's pool. Enter or select the pool's `.btf` address and enable Start Mining. @@ -243,7 +245,9 @@ xmrig -a rx/0 -o 127.0.0.1:3333 -u YOUR_BTF_ADDRESS -p x ./bitflash -nogui # node only ./bitflash -nogui -gen # node + solo mining ./bitflash -nogui -gen -operator # pool operator -./bitflash -nogui -gen -operator -poolstatusfile=/var/www/api/pools.json +./bitflash -nogui -gen -operator \ + -poolstatusfile=/var/www/pool_status.json \ + -poolroundsfile=/var/www/pool_rounds.json ./bitflash -nogui -gen -participant=POOL_BTF_ADDRESS # mine to pool ./bitflash -nogui -stratumbridge=POOL_BTF_ADDRESS # local bridge for XMRig/SRBMiner ``` diff --git a/docs/pool-directory.md b/docs/pool-directory.md index 6456084..76269b9 100644 --- a/docs/pool-directory.md +++ b/docs/pool-directory.md @@ -4,10 +4,13 @@ Nodes discover pools through Nostr and `.btf`; the website only helps humans compare operators. -Operators can publish the `pool_status.json` file produced by: +Operators can publish the `pool_status.json` and `pool_rounds.json` files +produced by: ```bash -bitflash -nogui -gen -operator -poolstatusfile=/var/www/pool_status.json +bitflash -nogui -gen -operator \ + -poolstatusfile=/var/www/pool_status.json \ + -poolroundsfile=/var/www/pool_rounds.json ``` Build a static directory from one or more status files: @@ -23,6 +26,11 @@ The generated output contains: - `index.html`: human-readable ranking page. - `pools.json`: machine-readable API for `api.bitflash.network/pools.json`. +When a source is an HTTP(S) `pool_status.json`, the directory links to a +`pool_rounds.json` proof file next to it. That file records the block height, +block hash, coinbase txid, coinbase value, pool fee, total shares, per-address +share counts, computed payouts, and payout txids once paid. + Ranking order is intentionally simple: 1. online pools first; diff --git a/scripts/build-pool-index.py b/scripts/build-pool-index.py index 382b6c3..d3eb386 100644 --- a/scripts/build-pool-index.py +++ b/scripts/build-pool-index.py @@ -8,6 +8,7 @@ import html import json import os import time +import urllib.parse import urllib.request @@ -23,6 +24,10 @@ def normalize_pool(source, obj, now): operator = obj.get("operator", {}) pool = obj.get("pool", {}) node = obj.get("node", {}) + rounds_file = operator.get("roundsFile") or "pool_rounds.json" + rounds_url = "" + if source.startswith("http://") or source.startswith("https://"): + rounds_url = urllib.parse.urljoin(source, rounds_file) updated = int(obj.get("updatedAt") or 0) age = max(0, now - updated) if updated else None online = bool(node.get("poolRunning")) and age is not None and age <= 120 @@ -39,6 +44,8 @@ def normalize_pool(source, obj, now): "blocksFoundSession": int(pool.get("blocksFoundSession") or 0), "roundShares": int(pool.get("roundShares") or 0), "hashRate": float(pool.get("hashRate") or 0.0), + "roundsUrl": rounds_url, + "recentRounds": obj.get("recentRounds", []), } @@ -56,6 +63,7 @@ def render_html(pools, generated_at): rows = [] for p in pools: dash = p["dashboardUrl"] + rounds = p.get("roundsUrl", "") # Only linkify http(s). The dashboard URL comes from an operator's # self-published status file, so a "javascript:" or "data:" scheme # would become a clickable script-injection on this public page. @@ -65,6 +73,12 @@ def render_html(pools, generated_at): html.escape(dash, quote=True)) else: dash_html = "-" + rounds_lower = rounds.lower() + if rounds_lower.startswith("http://") or rounds_lower.startswith("https://"): + rounds_html = 'proof'.format( + html.escape(rounds, quote=True)) + else: + rounds_html = "-" rows.append( "" "{status}" @@ -76,6 +90,7 @@ def render_html(pools, generated_at): "{height}" "{age}" "{dash}" + "{rounds}" "".format( status="online" if p["online"] else "stale", name=html.escape(p["name"]), @@ -87,6 +102,7 @@ def render_html(pools, generated_at): height=p["height"], age="{}s".format(p["ageSeconds"]) if p["ageSeconds"] is not None else "-", dash=dash_html, + rounds=rounds_html, ) ) @@ -110,7 +126,7 @@ def render_html(pools, generated_at):

This directory is informational. Bitflash nodes discover pools through Nostr and .btf; this page only helps humans compare public operators.

- + {rows} diff --git a/src/main.cpp b/src/main.cpp index 2be9c54..2eb0e3a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -105,6 +105,7 @@ string strParticipantPool; // participant mode: pool .btf address string strPoolName = "Bitflash Pool"; string strPoolDashboardUrl; string strPoolStatusFile; +string strPoolRoundsFile; double dPoolFeePercent = 0.0; bool fStratumBridge = false; int nStratumBridgePort = 3333; diff --git a/src/main.h b/src/main.h index 2786a4a..eafbf16 100644 --- a/src/main.h +++ b/src/main.h @@ -87,6 +87,7 @@ extern string strParticipantPool; // participant: pool .btf address extern string strPoolName; // operator: announced pool name extern string strPoolDashboardUrl; // operator: optional dashboard URL extern string strPoolStatusFile; // operator: optional JSON status output path +extern string strPoolRoundsFile; // operator: optional public round proof path extern double dPoolFeePercent; // operator: announced fee percent extern bool fStratumBridge; // participant helper: local Stratum bridge extern int nStratumBridgePort; // local bridge listen port, default 3333 diff --git a/src/main_gui.cpp b/src/main_gui.cpp index ce6d2b5..7ad2075 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -126,6 +126,7 @@ static void PrintUsage() printf(" /poolfee=PCT\n"); printf(" /pooldashboard=URL (alias: /pooldash=URL)\n"); printf(" /poolstatusfile=PATH (write pool status JSON for dashboards)\n"); + printf(" /poolroundsfile=PATH (write public pool round proofs JSON)\n"); printf("\n"); printf(".btf and rendezvous:\n"); printf(" /connectbtf=PEER_BTF_ADDRESS\n"); @@ -262,6 +263,10 @@ static void ParseStartupArguments(int argc, char* argv[]) if (!poolStatusFile.empty()) strPoolStatusFile = poolStatusFile; + string poolRoundsFile = argval2(argc, argv, "/poolroundsfile", "-poolroundsfile"); + if (!poolRoundsFile.empty()) + strPoolRoundsFile = poolRoundsFile; + string btfConnect = argval2(argc, argv, "/connectbtf", "-connectbtf"); if (!btfConnect.empty()) strBtfConnect = btfConnect; diff --git a/src/readme.txt b/src/readme.txt index fa58b6e..b8b89c1 100644 --- a/src/readme.txt +++ b/src/readme.txt @@ -55,6 +55,10 @@ Command-line options (in addition to the original ones) /poolfee=PCT operator announcement fee percent (example: 0.75) /pooldashboard=URL operator announcement dashboard URL + /poolstatusfile=PATH + write pool status JSON for dashboards + /poolroundsfile=PATH + write public pool round proofs JSON /connectbtf=ADDR keep an outbound .btf connection to ADDR /rvrelay=HOST:PORT diff --git a/src/rpc.cpp b/src/rpc.cpp index 1aa4f9b..813f1d1 100644 --- a/src/rpc.cpp +++ b/src/rpc.cpp @@ -263,11 +263,47 @@ struct PendingPayout { static std::vector gPendingPayouts; static std::mutex gPayoutLedgerMutex; +struct PoolRoundProof { + std::string id; + int blockHeight; + int matureAtHeight; + int64 createdAt; + std::string blockHash; + std::string coinbaseTxid; + std::string foundBy; + double feePercent; + uint64 totalShares; + int64 coinbaseValue; + int64 subsidy; + int64 minerReward; + int64 operatorReward; + int skippedDust; + int64 forfeitedBadAddress; + std::map shares; + std::map payouts; + std::map paidTxids; + + PoolRoundProof() + : blockHeight(0), matureAtHeight(0), createdAt(0), feePercent(0.0), + totalShares(0), coinbaseValue(0), subsidy(0), minerReward(0), operatorReward(0), + skippedDust(0), forfeitedBadAddress(0) {} +}; +static std::vector gPoolRounds; +static std::mutex gPoolRoundsMutex; +static const size_t MAX_POOL_ROUNDS = 200; + static std::string PayoutFilePath() { return GetAppDir() + "/pending_payouts.json"; } +static std::string PoolRoundsFilePath() +{ + if (!strPoolRoundsFile.empty()) + return strPoolRoundsFile; + return GetAppDir() + "/pool_rounds.json"; +} + static std::string PayoutIdForBlock(int blockHeight) { return strprintf("block-%d", blockHeight); @@ -362,6 +398,167 @@ static void LoadPendingPayouts() } } +static nlohmann::json PoolRoundToJson(const PoolRoundProof& r) +{ + nlohmann::json j; + j["id"] = r.id; + j["blockHeight"] = r.blockHeight; + j["matureAtHeight"] = r.matureAtHeight; + j["createdAt"] = r.createdAt; + j["blockHash"] = r.blockHash; + j["coinbaseTxid"] = r.coinbaseTxid; + j["foundBy"] = r.foundBy; + j["feePercent"] = r.feePercent; + j["totalShares"] = r.totalShares; + j["coinbaseValueSatoshis"] = r.coinbaseValue; + j["coinbaseValueBtf"] = FormatMoney(r.coinbaseValue); + j["subsidySatoshis"] = r.subsidy; + j["subsidyBtf"] = FormatMoney(r.subsidy); + j["minerRewardSatoshis"] = r.minerReward; + j["minerRewardBtf"] = FormatMoney(r.minerReward); + j["operatorRewardSatoshis"] = r.operatorReward; + j["operatorRewardBtf"] = FormatMoney(r.operatorReward); + j["skippedDust"] = r.skippedDust; + j["forfeitedBadAddressSatoshis"] = r.forfeitedBadAddress; + j["forfeitedBadAddressBtf"] = FormatMoney(r.forfeitedBadAddress); + + j["shares"] = nlohmann::json::object(); + for (const auto& kv : r.shares) + j["shares"][kv.first] = kv.second; + + j["payouts"] = nlohmann::json::object(); + for (const auto& kv : r.payouts) { + nlohmann::json p; + p["amountSatoshis"] = kv.second; + p["amountBtf"] = FormatMoney(kv.second); + std::map::const_iterator tx = r.paidTxids.find(kv.first); + if (tx != r.paidTxids.end()) + p["txid"] = tx->second; + else + p["txid"] = nullptr; + j["payouts"][kv.first] = p; + } + return j; +} + +static void SavePoolRoundsLocked() +{ + nlohmann::json root; + root["schema"] = "bitflash-pool-rounds-1"; + root["updatedAt"] = GetTime(); + root["rounds"] = nlohmann::json::array(); + for (const PoolRoundProof& r : gPoolRounds) + root["rounds"].push_back(PoolRoundToJson(r)); + + std::string path = PoolRoundsFilePath(); + std::string tmp = path + ".tmp"; + FILE* f = fopen(tmp.c_str(), "w"); + if (!f) { + LogPrint("payout", "[payout] WARNING: could not write %s\n", tmp.c_str()); + return; + } + std::string s = root.dump(2); + fwrite(s.c_str(), 1, s.size(), f); + if (fclose(f) != 0) { + LogPrint("payout", "[payout] WARNING: could not close %s\n", tmp.c_str()); + return; + } +#ifdef _WIN32 + remove(path.c_str()); +#endif + if (rename(tmp.c_str(), path.c_str()) != 0) + LogPrint("payout", "[payout] WARNING: could not replace %s\n", path.c_str()); +} + +static void LoadPoolRounds() +{ + std::string path = PoolRoundsFilePath(); + FILE* f = fopen(path.c_str(), "r"); + if (!f) return; + fseek(f, 0, SEEK_END); + long sz = ftell(f); + fseek(f, 0, SEEK_SET); + std::string s(sz, '\0'); + fread(&s[0], 1, sz, f); + fclose(f); + + try { + nlohmann::json root = nlohmann::json::parse(s); + nlohmann::json arr = root.is_array() ? root : root["rounds"]; + std::lock_guard lk(gPoolRoundsMutex); + for (const auto& obj : arr) { + PoolRoundProof r; + if (obj.contains("id") && obj["id"].is_string()) r.id = obj["id"].get(); + if (obj.contains("blockHeight")) r.blockHeight = obj["blockHeight"].get(); + if (obj.contains("matureAtHeight")) r.matureAtHeight = obj["matureAtHeight"].get(); + if (obj.contains("createdAt")) r.createdAt = obj["createdAt"].get(); + if (obj.contains("blockHash") && obj["blockHash"].is_string()) r.blockHash = obj["blockHash"].get(); + if (obj.contains("coinbaseTxid") && obj["coinbaseTxid"].is_string()) r.coinbaseTxid = obj["coinbaseTxid"].get(); + if (obj.contains("foundBy") && obj["foundBy"].is_string()) r.foundBy = obj["foundBy"].get(); + if (obj.contains("feePercent")) r.feePercent = obj["feePercent"].get(); + if (obj.contains("totalShares")) r.totalShares = obj["totalShares"].get(); + if (obj.contains("coinbaseValueSatoshis")) r.coinbaseValue = obj["coinbaseValueSatoshis"].get(); + if (obj.contains("subsidySatoshis")) r.subsidy = obj["subsidySatoshis"].get(); + if (obj.contains("minerRewardSatoshis")) r.minerReward = obj["minerRewardSatoshis"].get(); + if (obj.contains("operatorRewardSatoshis")) r.operatorReward = obj["operatorRewardSatoshis"].get(); + if (obj.contains("skippedDust")) r.skippedDust = obj["skippedDust"].get(); + if (obj.contains("forfeitedBadAddressSatoshis")) r.forfeitedBadAddress = obj["forfeitedBadAddressSatoshis"].get(); + if (obj.contains("shares") && obj["shares"].is_object()) + for (auto it = obj["shares"].begin(); it != obj["shares"].end(); ++it) + r.shares[it.key()] = it.value().get(); + if (obj.contains("payouts") && obj["payouts"].is_object()) + for (auto it = obj["payouts"].begin(); it != obj["payouts"].end(); ++it) { + if (it.value().is_object()) { + r.payouts[it.key()] = it.value().value("amountSatoshis", (int64)0); + if (it.value().contains("txid") && it.value()["txid"].is_string()) + r.paidTxids[it.key()] = it.value()["txid"].get(); + } else if (it.value().is_number_integer()) { + r.payouts[it.key()] = it.value().get(); + } + } + if (!r.id.empty()) + gPoolRounds.push_back(r); + } + while (gPoolRounds.size() > MAX_POOL_ROUNDS) + gPoolRounds.erase(gPoolRounds.begin()); + LogPrint("payout", "[payout] loaded %zu pool round proof(s) from disk\n", + gPoolRounds.size()); + } catch (const std::exception& e) { + LogPrint("payout", "[payout] WARNING: failed to parse %s: %s\n", + path.c_str(), e.what()); + } +} + +static void RecordPoolRound(const PoolRoundProof& round) +{ + std::lock_guard lk(gPoolRoundsMutex); + for (PoolRoundProof& existing : gPoolRounds) { + if (existing.id == round.id) { + existing = round; + SavePoolRoundsLocked(); + return; + } + } + gPoolRounds.push_back(round); + while (gPoolRounds.size() > MAX_POOL_ROUNDS) + gPoolRounds.erase(gPoolRounds.begin()); + SavePoolRoundsLocked(); +} + +static void MarkPoolRoundPaid(const std::string& id, + const std::string& address, + const std::string& txid) +{ + std::lock_guard lk(gPoolRoundsMutex); + for (PoolRoundProof& r : gPoolRounds) { + if (r.id != id) + continue; + r.paidTxids[address] = txid; + SavePoolRoundsLocked(); + return; + } +} + // Payouts ready to execute are moved here under gPayoutExecMutex. // A dedicated thread drains this list so SendMoney (which takes cs_main) // never runs on the event loop thread and can't stall miner I/O. @@ -399,10 +596,28 @@ static bool FindRecordedPoolPayout(const std::string& id, } static void QueuePayouts(int blockHeight, + const std::string& blockHash, + const std::string& coinbaseTxid, + int64 coinbaseValue, + const std::string& foundBy, const std::map& shareCount, uint64 total) { + PoolRoundProof round; + round.id = PayoutIdForBlock(blockHeight); + round.blockHeight = blockHeight; + round.matureAtHeight = blockHeight + COINBASE_MATURITY; + round.createdAt = GetTime(); + round.blockHash = blockHash; + round.coinbaseTxid = coinbaseTxid; + round.coinbaseValue = coinbaseValue; + round.foundBy = foundBy; + round.feePercent = dPoolFeePercent; + round.totalShares = total; + round.shares = shareCount; + if (total == 0) { + RecordPoolRound(round); LogPrint("payout", "[payout] block %d: zero shares recorded -- nothing to pay out\n", blockHeight); return; @@ -418,9 +633,13 @@ static void QueuePayouts(int blockHeight, int64 nSubsidy = 50 * COIN; nSubsidy >>= (blockHeight / 210000); // halving schedule int64 minerReward = (int64)((double)nSubsidy * (1.0 - feeFrac)); + int64 operatorCut = nSubsidy - minerReward; + round.subsidy = nSubsidy; + round.minerReward = minerReward; + round.operatorReward = operatorCut; PendingPayout pp; - pp.id = PayoutIdForBlock(blockHeight); + pp.id = round.id; pp.blockHeight = blockHeight; pp.matureAtHeight = blockHeight + COINBASE_MATURITY; @@ -443,15 +662,18 @@ static void QueuePayouts(int blockHeight, continue; } pp.amounts[kv.first] = amount; + round.payouts[kv.first] = amount; } + round.skippedDust = skipped; + round.forfeitedBadAddress = burned; { std::lock_guard lk(gPayoutLedgerMutex); gPendingPayouts.push_back(pp); SavePendingPayoutsLocked(); } + RecordPoolRound(round); - int64 operatorCut = nSubsidy - minerReward; LogPrint("payout", "[payout] block %d: subsidy %s BTF, miners get %s" " (fee %.2f%% = %s to operator)," @@ -548,12 +770,14 @@ static void PayoutThreadFn(void*) LogPrint("payout", "[payout] recovered recorded payout %s to %s" " from wallet tx %s\n", FormatMoney(kv.second).c_str(), kv.first.c_str(), txid.c_str()); + MarkPoolRoundPaid(ppQueued.id, kv.first, txid); } else if (SendMoney(sc, kv.second, wtx)) { txid = wtx.GetHash().GetHex(); LogPrint("payout", "[payout] paid %s to %s\n", FormatMoney(kv.second).c_str(), kv.first.c_str()); + MarkPoolRoundPaid(ppQueued.id, kv.first, txid); } else { @@ -664,7 +888,8 @@ static void WritePoolStatusJsonLocked() {"name", strPoolName}, {"btfAddress", BtfLocalAddress()}, {"feePercent", dPoolFeePercent}, - {"dashboardUrl", strPoolDashboardUrl} + {"dashboardUrl", strPoolDashboardUrl}, + {"roundsFile", "pool_rounds.json"} }; j["node"] = { @@ -702,6 +927,30 @@ static void WritePoolStatusJsonLocked() }); } + j["recentRounds"] = nlohmann::json::array(); + { + std::lock_guard lk(gPoolRoundsMutex); + size_t start = gPoolRounds.size() > 10 ? gPoolRounds.size() - 10 : 0; + for (size_t i = start; i < gPoolRounds.size(); i++) { + const PoolRoundProof& r = gPoolRounds[i]; + int unpaid = 0; + for (const auto& kv : r.payouts) + if (r.paidTxids.count(kv.first) == 0) + unpaid++; + j["recentRounds"].push_back({ + {"id", r.id}, + {"blockHeight", r.blockHeight}, + {"blockHash", r.blockHash}, + {"coinbaseTxid", r.coinbaseTxid}, + {"totalShares", r.totalShares}, + {"payouts", (int)r.payouts.size()}, + {"unpaidPayouts", unpaid}, + {"coinbaseValueBtf", FormatMoney(r.coinbaseValue)}, + {"minerRewardBtf", FormatMoney(r.minerReward)} + }); + } + } + std::string path = PoolStatusFilePath(); std::string tmp = path + ".tmp"; FILE* f = fopen(tmp.c_str(), "w"); @@ -1231,7 +1480,9 @@ static bool HandleLine(Miner* m, const std::string& rawLine, if (accepted) { // Snapshot height before RebuildJob() (which increments it) int foundHeight = gCurrentJob.height; - QueuePayouts(foundHeight, roundShareCount, roundShareTotal); + QueuePayouts(foundHeight, b.GetHash().GetHex(), b.vtx[0].GetHash().GetHex(), + b.vtx[0].vout[0].nValue, m->address, + roundShareCount, roundShareTotal); // Reset round shares roundShareCount.clear(); roundShareTotal = 0; @@ -1333,6 +1584,62 @@ int RunPoolStratumSelfTest() HandleLine(&miner, stale.dump(), roundShareCount, roundShareTotal, blocksFound); check(roundShareTotal == 1, "stale submit does not increment shares"); + std::vector savedRounds = gPoolRounds; + std::string savedRoundsFile = strPoolRoundsFile; + std::string proofPath = "pool_rounds_selftest.json"; + remove(proofPath.c_str()); + remove((proofPath + ".tmp").c_str()); + gPoolRounds.clear(); + strPoolRoundsFile = proofPath; + + PoolRoundProof proof; + proof.id = "selftest-round"; + proof.blockHeight = 42; + proof.matureAtHeight = 42 + COINBASE_MATURITY; + proof.createdAt = GetTime(); + proof.blockHash = block.GetHash().GetHex(); + proof.coinbaseTxid = coinbase.GetHash().GetHex(); + proof.foundBy = miner.address; + proof.feePercent = 1.25; + proof.totalShares = 3; + proof.coinbaseValue = 50 * COIN; + proof.subsidy = 50 * COIN; + proof.minerReward = 49 * COIN; + proof.operatorReward = 1 * COIN; + proof.shares[miner.address] = 3; + proof.payouts[miner.address] = 49 * COIN; + RecordPoolRound(proof); + MarkPoolRoundPaid(proof.id, miner.address, "selftest-txid"); + + bool proofOk = false; + FILE* pf = fopen(proofPath.c_str(), "r"); + if (pf) { + fseek(pf, 0, SEEK_END); + long sz = ftell(pf); + fseek(pf, 0, SEEK_SET); + std::string s(sz, '\0'); + fread(&s[0], 1, sz, pf); + fclose(pf); + try { + json root = json::parse(s); + proofOk = + root.value("schema", "") == "bitflash-pool-rounds-1" && + root["rounds"].is_array() && + root["rounds"].size() == 1 && + root["rounds"][0].value("id", "") == proof.id && + root["rounds"][0]["shares"].value(miner.address, (uint64)0) == 3 && + root["rounds"][0]["payouts"][miner.address].value("txid", "") == "selftest-txid"; + } catch (...) { + proofOk = false; + } + } + check(proofOk, "pool round proof ledger is written and paid txid is recorded"); + + remove(proofPath.c_str()); + remove((proofPath + ".tmp").c_str()); + strPoolRoundsFile = savedRoundsFile; + gPoolRounds = savedRounds; + gCurrentJob = savedJob; gHaveJob = savedHaveJob; @@ -1754,6 +2061,7 @@ void ThreadRPCServer(void*) } LoadPendingPayouts(); + LoadPoolRounds(); _beginthread(AcceptLoopFn, 0, NULL); _beginthread(ThreadBtfPoolAnnouncer, 0, NULL); -- cgit v1.2.3 From 534cdc2675378a1213b4999fbd6b03f6f0b6ffbc Mon Sep 17 00:00:00 2001 From: Bitflash Date: Fri, 7 Aug 2026 18:40:19 -0300 Subject: shutdown: flush wallet.dat on Windows headless stop The headless loop installed SIGINT/SIGTERM handlers only under #ifndef _WIN32, so a headless node on Windows never reached the clean StopNode()+DBFlush() path: Ctrl-C, closing the console window, a service stop and OS shutdown all killed it mid-write, and a Berkeley DB wallet.dat left unflushed will not reopen. Install a SetConsoleCtrlHandler that mirrors the POSIX path: set fShutdown and, because the OS terminates us shortly after the handler returns on CLOSE/LOGOFF/SHUTDOWN, block until the main loop signals the flush is done. A 'taskkill /F' is still an uncatchable hard kill by design. Closes #16. --- src/main_gui.cpp | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/src/main_gui.cpp b/src/main_gui.cpp index 7ad2075..b036ac9 100644 --- a/src/main_gui.cpp +++ b/src/main_gui.cpp @@ -363,6 +363,36 @@ static void FatalStartupError(bool fHeadless, const string& strWhat, const strin #endif } +#ifdef _WIN32 +// Windows equivalent of the SIGINT/SIGTERM handler the headless loop installs on +// POSIX. Without it a headless node on Windows had no way to reach the clean +// StopNode()+DBFlush() path: Ctrl-C, closing the console window, a service stop +// and OS shutdown all killed it mid-write, and a wallet.dat left unflushed by +// Berkeley DB will not reopen. For CLOSE/LOGOFF/SHUTDOWN the OS terminates us +// shortly after the handler returns, so we set fShutdown and then block until the +// main loop signals the flush is done -- returning any sooner throws the flush +// away, which is the whole bug. (A `taskkill /F` is still an uncatchable hard +// kill; nothing can help there, and nothing should pretend to.) +static volatile bool g_fHeadlessShutdownDone = false; +static BOOL WINAPI HeadlessConsoleCtrlHandler(DWORD dwCtrlType) +{ + switch (dwCtrlType) + { + case CTRL_C_EVENT: + case CTRL_BREAK_EVENT: + case CTRL_CLOSE_EVENT: + case CTRL_LOGOFF_EVENT: + case CTRL_SHUTDOWN_EVENT: + fShutdown = true; + while (!g_fHeadlessShutdownDone) + Sleep(50); + return TRUE; + default: + return FALSE; + } +} +#endif + int main(int argc, char* argv[]) { if (arg(argc,argv,"/help") || arg(argc,argv,"-help") || @@ -662,7 +692,9 @@ int main(int argc, char* argv[]) #endif if (fHeadless) { -#ifndef _WIN32 +#ifdef _WIN32 + SetConsoleCtrlHandler(HeadlessConsoleCtrlHandler, TRUE); +#else auto sig=[](int){fShutdown=true;}; signal(SIGINT,sig); signal(SIGTERM,sig); #endif @@ -674,6 +706,11 @@ int main(int argc, char* argv[]) // called here, which is why a wallet.dat copied elsewhere would not // open -- issue #40. Not optional. DBFlush(true); +#ifdef _WIN32 + // wallet.dat is safely flushed and closed now; let a pending console + // control handler (CLOSE/LOGOFF/SHUTDOWN) return so the OS can finish. + g_fHeadlessShutdownDone = true; +#endif return 0; } -- cgit v1.2.3 From dfab2226330ccf5b6529a0e7355e1f574526de94 Mon Sep 17 00:00:00 2001 From: Bitflash Date: Mon, 10 Aug 2026 09:43:27 -0300 Subject: release: bump version to 1.2.16 --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index d0f1b26..1d9965a 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.15 +VERSION := 1.2.16 SECP256K1_REPO := https://github.com/bitcoin-core/secp256k1 SECP256K1_COMMIT := 7fecac74aed8e1fd9078380d67dd04663705c989 -- cgit v1.2.3 From 64317cc5a3416d77c9171351c367ab8c24c4dff1 Mon Sep 17 00:00:00 2001 From: Bitflash Date: Mon, 10 Aug 2026 11:21:39 -0300 Subject: docs: specify the .btf rendezvous protocol --- docs/rendezvous.md | 288 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 288 insertions(+) create mode 100644 docs/rendezvous.md diff --git a/docs/rendezvous.md b/docs/rendezvous.md new file mode 100644 index 0000000..ac8e598 --- /dev/null +++ b/docs/rendezvous.md @@ -0,0 +1,288 @@ +# Bitflash `.btf` Rendezvous Protocol + +This is the specification of how Bitflash nodes reach each other, and how miners +reach pools, without revealing an IP address and without any central directory. +It documents the protocol as implemented in `src/btfaddr.cpp`, `src/btfchan.cpp`, +`src/btfrv.cpp`, `src/btftunnel.cpp` and `src/nostr.cpp`. + +The design is ported from the Itzal hidden service design. A `.btf` address names +a public key, a node proves ownership of that key by signing a service +descriptor, other nodes find the descriptor over Nostr, and the two sides then +meet through an untrusted relay over an end to end encrypted tunnel. The relay +carries ciphertext only and never learns the plaintext. + +## 1. Overview + +A connection to a `.btf` service has three layers: + +1. **Discovery (Nostr).** The service publishes a signed descriptor as a Nostr + event. A caller fetches it by the service public key and verifies the + signature. The descriptor says which rendezvous relay the service is waiting + at and which encryption key to use. +2. **Rendezvous (TCP).** Caller and service both connect out to the same relay + and are paired by public key. Neither needs an inbound port or a public IP. + After pairing the relay is a blind byte pipe. +3. **Encrypted channel (X25519 + XChaCha20-Poly1305).** Over the paired pipe the + caller and service run an ephemeral to static handshake and then exchange + authenticated frames. The relay sees only ciphertext. + +The caller ends up holding an ordinary local socket that behaves like a direct +TCP connection to the peer. The node treats it exactly like any other peer +socket. + +## 2. Identities and keys + +A node holds two independent keypairs. + +- **Signing / identity key**: a secp256k1 x-only key (BIP340 Schnorr). This is + the `.btf` identity and also the node's Nostr identity. Persisted in + `nostr.key`. +- **Encryption key**: an X25519 (Curve25519) keypair. Persisted in + `btf_enc.key`. Its public half is published in the descriptor as `enc` and is + used for the tunnel key agreement. + +The identity key names the service and authenticates the descriptor. The +encryption key secures the tunnel. They are separate so the long term identity +never performs Diffie-Hellman and the encryption key can be rotated in a new +signed descriptor. + +## 3. The `.btf` address + +An address is the identity public key plus a short checksum, Base32 encoded, with +the suffix `.btf`. + +``` +label = Base32( pubkey[32] || checksum[2] ) +address = label + ".btf" +``` + +- `pubkey` is the 32 byte secp256k1 x-only public key. +- `checksum` is the first two bytes of `SHA256(".btf-checksum-v1" || pubkey)`. +- Base32 uses the lowercase alphabet `abcdefghijklmnopqrstuvwxyz234567`, no + padding. The 34 byte payload encodes to 55 characters. +- Parsing is case insensitive, trims surrounding whitespace, and strips a + trailing dot. A wrong checksum is rejected. + +The address is self certifying: it *is* the public key, so no registry has to be +trusted to map a name to a key. + +## 4. Discovery over Nostr + +Nodes publish and read three kinds of parameterized replaceable Nostr events. +Parameterized replaceable means the newest event for a given author and `d` tag +supersedes the older one, so a descriptor is always the latest state. + +| Purpose | kind | `d` tag | +|---|---|---| +| Service descriptor | 38501 | `btf-descriptor-2` | +| Rendezvous relay announcement | 38502 | `btf-relay-2` | +| Pool announcement | 38503 | `btf-pool-2` | + +The Nostr event is signed with the node's identity key, so the event author +equals the `.btf` public key. That binds the descriptor to the address at the +Nostr layer, and the descriptor is *also* signed inside its own content (see +below), so a relay operator cannot forge or swap a descriptor for an address it +does not control. + +**Resolving an address.** To reach `X.btf`, a caller parses the address to the 32 +byte key and sends a Nostr `REQ` filtered by `authors = [X]`, `kinds = [38501]`, +`#d = ["btf-descriptor-2"]`, `limit = 1`. It verifies the returned descriptor and +uses its `meeting_node` and `enc`. + +**Relay auto discovery.** A relay operator runs a rendezvous relay and publishes a +kind 38502 event carrying `host:port`. Every node that reads it adds the relay to +the set it can register at and dial through. No seed list edit or maintainer +approval is needed. A small curated seed list is still shipped for bootstrap: + +``` +92.246.128.180:8434 +31.44.4.249:8434 +90.156.222.107:8434 +``` + +The default rendezvous port is `8434`. + +## 5. The service descriptor + +The descriptor is a JSON object, signed with the identity key over a domain +separated digest of its own fields. + +```json +{ + "v": 2, + "pubkey": "", + "enc": "", + "meeting_node": "host:port", + "created": , + "sig": "" +} +``` + +The signed digest is: + +``` +digest = SHA256( ".btf-descriptor-v2" + || pubkey[32] + || enc (raw bytes of the string) + || meeting_node (raw bytes of the string) + || created (8 bytes, big endian) ) +``` + +The signature is a deterministic BIP340 Schnorr signature (no auxiliary +randomness) over `digest` by the identity key. + +**Verification** (untrusted input, must never throw): parse the JSON, require all +fields with the right types, confirm `pubkey` equals the address being resolved, +recompute the digest, and verify the Schnorr signature. Any failure rejects the +descriptor. + +`created` lets a reader prefer the freshest descriptor when more than one is +seen. A node only publishes a descriptor once it has actually registered at a +`meeting_node`, so it never advertises a relay it cannot be reached at. + +## 6. Rendezvous transport + +The relay is a small TCP server. Every connection opens with a fixed 33 byte +header: + +``` +byte 0 role: 'S' (service) or 'C' (client) +bytes 1..32 target public key (the .btf identity, 32 bytes) +``` + +**Service registration (`S`).** The service connects out to the relay and sends +`'S' || own_pubkey`. The relay stores the connection in a table keyed by the +public key and leaves it open, waiting. There is one slot per key: a new +registration for the same key replaces and closes the old one. The service does +not block waiting to be paired at registration time; it is considered registered +the moment the relay reads the header, which is what lets it publish a descriptor +pointing at this relay. + +**Client dial (`C`).** The caller connects out to the same relay and sends +`'C' || target_pubkey`. The relay looks up the waiting service: + +- If present, it removes it from the table, writes a single `0x01` byte to both + sockets, and then forwards raw bytes in both directions until either side + closes. From this point the relay is a blind pipe. +- If absent, it writes `0x00` to the caller and closes. + +Both sides connect *outbound* to the relay, so neither needs a public IP or an +open inbound port. The relay never sees the identity of the caller (the caller +sends no key of its own, only the target it wants). + +**Liveness.** A registration socket is idle by design and can outlive the network +path that created it (a NAT drops the idle mapping, the relay restarts, a route +changes) with no FIN and nothing readable. TCP keepalive was measured and does +not reliably detect this. The service therefore imposes its own timeout on the +wait for the pairing byte and re-registers on expiry. Expiring is not a failure; +it costs one reconnect per interval and returns the loop to a known state. + +## 7. The encrypted channel + +Once paired through the relay, the caller and service run an end to end handshake +and then exchange authenticated frames. The primitive is libsodium +`crypto_box_curve25519xchacha20poly1305` (X25519 key agreement, XChaCha20-Poly1305 +AEAD), the same construction as Rust's `crypto_box` ChaChaBox. + +**Handshake (ephemeral to static).** + +1. The caller generates a fresh ephemeral X25519 keypair, computes + `shared = X25519(eph_sk, service_enc_pub)` reduced to a box key + (`crypto_box_beforenm`), and sends the 32 byte ephemeral public key over the + paired pipe. +2. The service reads the ephemeral public key and computes the same + `shared = X25519(enc_sk, eph_pub)`. + +Because the caller uses a new ephemeral key per connection, each session has an +independent key and forward secrecy against later compromise of the service +encryption key. + +**Framing.** Every message is length prefixed: + +``` +frame = len[4] (big endian, length of the body that follows) + body = nonce[24] || ciphertext || mac[16] +``` + +The nonce is 24 random bytes per frame (XChaCha20), the MAC is 16 bytes +(Poly1305). A body shorter than `nonce + mac` or larger than 1 MiB is rejected. +Decryption that fails authentication drops the frame and tears the tunnel down. + +The tunnel is transparent: the application is handed a local socket pair end and +reads and writes plaintext. Two pump threads move data between that socket and +the relay socket, encrypting outbound and decrypting inbound. + +## 8. End to end connection flow + +To dial `X.btf`: + +1. Parse `X.btf` to the 32 byte identity key. +2. Fetch and verify the descriptor over Nostr (kind 38501). Obtain + `meeting_node` and `enc`. +3. Connect to `meeting_node`, send `'C' || X`, and wait for `0x01`. +4. Generate an ephemeral X25519 key, derive the shared key against `enc`, send + the ephemeral public key. +5. Exchange XChaCha20-Poly1305 frames. Hand the application a local socket that + speaks the ordinary peer protocol. + +A service, symmetrically: registers at a relay (`'S' || own_pubkey`), publishes a +descriptor pointing at that relay, waits for `0x01`, reads the caller ephemeral +public key, derives the shared key with its encryption secret, and serves the +tunnel. + +## 9. Security properties + +- **Address unforgeability.** The address is the identity key and the descriptor + is Schnorr signed by it, and the Nostr event is signed by it. A relay or Nostr + operator cannot publish a working descriptor for an address it does not own. +- **Relay confidentiality.** The relay forwards ciphertext and never holds the + channel key. It cannot read or modify traffic undetected; a tampered frame + fails the Poly1305 tag and the tunnel closes. +- **Caller anonymity to the service and relay.** The caller presents no identity + key, only the target it wants to reach. It connects outbound, so its address is + not published anywhere. +- **Forward secrecy.** The caller's per connection ephemeral key means a later + compromise of the service encryption key does not decrypt past sessions. +- **IP privacy.** Neither side needs a public IP or an inbound port. The observable + network fact is that each side has a TCP connection to a relay. + +## 10. Threat model and limitations + +- **The relay is trusted for availability, not for confidentiality.** It can + refuse to pair, drop a connection, or go offline. This is why relays are + plural, auto discovered, and re-registration is periodic. It cannot read + traffic. +- **Traffic analysis is not addressed.** A network observer, or the relay, can + see connection timing and byte volumes. There is no padding or mixing. +- **Single slot registration causes churn.** One waiting slot per key per relay + means a race or a stale registration can briefly make a service unreachable at + that relay. Multiple relays and the wait timeout bound the damage, but this is + a known source of connection churn. +- **Descriptor freshness is best effort.** `created` is a self reported + timestamp; readers prefer the newest descriptor they have seen but there is no + global clock. +- **Discovery depends on public Nostr relays.** If the relays a node uses are all + unreachable, discovery stalls until one is reachable again. Relay TLS is + verified, which closes a man in the middle path on discovery, but availability + still rests on the relay set. + +## 11. Wire reference + +| Item | Value | +|---|---| +| `.btf` Base32 alphabet | `abcdefghijklmnopqrstuvwxyz234567`, no padding | +| Address payload | `pubkey[32] || checksum[2]`, 55 Base32 chars | +| Address checksum | `SHA256(".btf-checksum-v1" || pubkey)[0..2]` | +| Descriptor domain sep | `.btf-descriptor-v2` | +| Descriptor version | `2` | +| Descriptor signature | BIP340 Schnorr, deterministic (no aux rand) | +| Nostr descriptor event | kind `38501`, `d = btf-descriptor-2` | +| Nostr relay event | kind `38502`, `d = btf-relay-2` | +| Nostr pool event | kind `38503`, `d = btf-pool-2` | +| Rendezvous port (default) | `8434` | +| Rendezvous header | `role[1] ('S'/'C') || pubkey[32]`, 33 bytes | +| Rendezvous pairing byte | `0x01` paired, `0x00` no service | +| Channel primitive | `crypto_box_curve25519xchacha20poly1305` | +| Nonce / MAC / key sizes | 24 / 16 / 32 bytes | +| Frame | `len[4 big endian] || nonce[24] || ct || mac[16]` | +| Max frame body | 1 MiB | -- cgit v1.2.3 From d2679e2c09c17e4af50535c13324ba2ddb2eb4ad Mon Sep 17 00:00:00 2001 From: Bitflash Date: Mon, 10 Aug 2026 11:50:10 -0300 Subject: tools: static block explorer generator (v0) --- scripts/build-explorer.py | 243 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100755 scripts/build-explorer.py diff --git a/scripts/build-explorer.py b/scripts/build-explorer.py new file mode 100755 index 0000000..7849e8a --- /dev/null +++ b/scripts/build-explorer.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +# +# Build a static Bitflash block explorer (v0) from a node's blk0001.dat. +# Emits into : +# 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() + +B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" +def b58check(payload): + v = bytes([ADDRESS_VERSION]) + payload + v += sha256d(v)[:4] + n = int.from_bytes(v, 'big') + s = "" + while n > 0: + n, r = divmod(n, 58); s = B58[r] + s + for byte in v: + 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 {"type": "p2pkh", "address": b58check(spk[3:23])} + # P2PK: OP_CHECKSIG (65- or 33-byte key) + 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))} + 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(' + + + + +Bitflash Block Explorer + + + +
+

Bitflash Block Explorer

+ + + +
+
+

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.

+
+ +
StatusPoolFeeMinersH/sBlocksHeightAgeLink
StatusPoolFeeMinersH/sBlocksHeightAgeLinkProof
+ + +
HeightTime (UTC)TxsSizeCoinbaseTo
+

+ + + + + + +""" + +if __name__ == "__main__": + main() -- cgit v1.2.3 From 5550481af68add9616a3410bb4db35abfdb70d9b Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Mon, 10 Aug 2026 12:31:38 -0300 Subject: tools: add fair launch verifier --- README.md | 11 + docs/fair-launch.md | 69 +++++++ scripts/verify-fair-launch.py | 452 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 532 insertions(+) create mode 100644 docs/fair-launch.md create mode 100644 scripts/verify-fair-launch.py diff --git a/README.md b/README.md index ec8e9c4..b4c3b8b 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,17 @@ scripts/verify-release.sh latest See [release verification](docs/release-verification.md) for the full release audit flow and the maintainer signing step. +Bitflash also ships a deterministic fair-launch verifier: + +```bash +python3 scripts/verify-fair-launch.py --max-blocks 1000 --out bitflash-fair-launch-report.json --json +``` + +The report reads the local chain directly, checks the genesis launch baseline, +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). + **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 new file mode 100644 index 0000000..79eca8e --- /dev/null +++ b/docs/fair-launch.md @@ -0,0 +1,69 @@ +# Bitflash fair-launch verifier + +Bitflash ships a deterministic fair-launch audit script: + +```bash +python3 scripts/verify-fair-launch.py --max-blocks 1000 --out bitflash-fair-launch-report.json --json +``` + +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. + +The report proves the launch baseline that can be checked from the chain: + +- the genesis block hash matches the source constant; +- the genesis merkle root matches the source constant; +- the genesis transaction is a single coinbase transaction; +- the genesis output total is exactly `50.00000000 BTF`; +- the scanned block range and mined coinbase total are reproducible. + +For machines that do not have a synced data directory yet, the source constants +can be checked without disk blocks: + +```bash +python3 scripts/verify-fair-launch.py --reconstruct-genesis +``` + +## Deterministic output + +Use `--json` or `--out` for canonical JSON. The JSON is written with sorted +keys, fixed separators, ASCII escaping, and one trailing newline. It deliberately +does not include local paths or wall-clock generation time, so two auditors +scanning the same block range produce the same bytes. + +That makes the report shareable: + +```bash +python3 scripts/verify-fair-launch.py --max-blocks 1000 --out report.json --json +sha256sum report.json +``` + +If another auditor runs the same command against the same chain prefix, the +`sha256sum` should match. + +## OpenTimestamps + +OpenTimestamps is optional and external. It anchors the report in Bitcoin +without adding any bridge or dependency to Bitflash consensus. + +After generating a canonical JSON report: + +```bash +ots stamp report.json +ots verify report.json.ots +``` + +The `.ots` file proves that the exact report bytes existed no later than the +Bitcoin block that confirms the timestamp. + +## What this does not claim + +This is not a retroactive genesis commitment. The Bitflash genesis block already +exists, so adding a new on-chain commitment to genesis would create a different +chain. This verifier instead audits the launch state that is already on-chain +and makes the result reproducible and timestampable. + +It also does not prove real-world miner identity. It proves the genesis state, +the absence of non-coinbase premine in genesis, and the deterministic coinbase +totals over the scanned prefix. diff --git a/scripts/verify-fair-launch.py b/scripts/verify-fair-launch.py new file mode 100644 index 0000000..ce396fb --- /dev/null +++ b/scripts/verify-fair-launch.py @@ -0,0 +1,452 @@ +#!/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. +""" + +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("") + + +def main(argv): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--datadir", default=default_datadir(), help="Bitflash data directory") + ap.add_argument("--max-blocks", type=int, default=100, help="number of blocks to scan from blk*.dat") + ap.add_argument("--json", action="store_true", help="print canonical JSON") + ap.add_argument("--out", help="write canonical JSON to this path") + ap.add_argument("--reconstruct-genesis", action="store_true", + help="do not read disk; reconstruct only the genesis block from source constants") + args = ap.parse_args(argv) + + if args.reconstruct_genesis: + blocks = [reconstructed_genesis()] + source_mode = "reconstructed-genesis" + datadir = "" + else: + datadir = Path(args.datadir) + blocks = read_blocks(datadir, args.max_blocks) + if not blocks: + raise SystemExit("no Bitflash blocks found in %s; use --reconstruct-genesis for a source-only check" % datadir) + source_mode = "local-block-files" + + report = build_report(blocks, source_mode) + data = canonical_bytes(report) + + if args.out: + Path(args.out).write_bytes(data) + if args.json: + sys.stdout.buffer.write(data) + else: + print_text(report) + return 0 if report["verdict"] == "FAIR_LAUNCH_BASELINE_VERIFIED" else 1 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) -- cgit v1.2.3 From ccf4489dbdf9e9d691e02e867562518c92168007 Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Mon, 10 Aug 2026 14:49:21 -0300 Subject: tools: add deterministic UTXO commitment verifier --- README.md | 11 ++ docs/utxo-commitment.md | 93 +++++++++ scripts/verify-utxo-set.py | 464 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 568 insertions(+) create mode 100644 docs/utxo-commitment.md create mode 100644 scripts/verify-utxo-set.py diff --git a/README.md b/README.md index ec8e9c4..0827ac1 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,17 @@ scripts/verify-release.sh latest See [release verification](docs/release-verification.md) for the full release audit flow and the maintainer signing step. +Bitflash also ships a deterministic UTXO-set commitment tool: + +```bash +python3 scripts/verify-utxo-set.py --out utxo-report.json --json +``` + +It reconstructs the best chain from local block files, computes the current +UTXO root and supply, and writes canonical JSON that independent auditors can +compare or timestamp externally with OpenTimestamps. See +[UTXO-set commitments](docs/utxo-commitment.md). + **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/utxo-commitment.md b/docs/utxo-commitment.md new file mode 100644 index 0000000..ddf5450 --- /dev/null +++ b/docs/utxo-commitment.md @@ -0,0 +1,93 @@ +# Bitflash UTXO-set commitment + +`scripts/verify-utxo-set.py` reconstructs a deterministic commitment to the +current UTXO set from local `blk*.dat` files. + +This is intentionally not a consensus rule yet. It is an audit tool: independent +operators can run it against the same chain prefix and compare the resulting +root and canonical JSON hash. + +```bash +python3 scripts/verify-utxo-set.py --max-blocks 1000 --out utxo-report.json --json +``` + +Use `--max-blocks 0` or omit the option to scan every available block file: + +```bash +python3 scripts/verify-utxo-set.py --out utxo-report.json --json +``` + +## What It Verifies + +The verifier: + +- parses Bitflash block files directly; +- 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; +- applies spends and creates outputs in deterministic order; +- reports current UTXO count, current supply, mined coinbase total, fees, and + historical duplicate-output overwrites; +- computes a deterministic UTXO root. + +## Commitment Algorithm + +The UTXO map is keyed by: + +```text +txid:vout +``` + +Keys are sorted lexicographically. Each leaf is: + +```text +SHA256("BTFUTXO1|" || outpoint || "|" || value_satoshis || "|" || + script_pub_key_hex || "|" || created_height || "|" || created_txid) +``` + +The tree is then reduced pairwise. If a level has an odd number of hashes, the +last hash is duplicated. Each parent is: + +```text +SHA256("BTFNODE1|" || left_hash || right_hash) +``` + +The final hash is the UTXO-set root. + +## Deterministic Output + +`--json` and `--out` write canonical JSON with sorted keys, fixed separators, +ASCII escaping, and one trailing newline. It contains no local path and no +generation timestamp, so two auditors scanning the same chain prefix should +produce byte-identical reports. + +```bash +python3 scripts/verify-utxo-set.py --out utxo-report.json --json +sha256sum utxo-report.json +``` + +## OpenTimestamps + +The canonical JSON can be timestamped in Bitcoin without adding any dependency +to Bitflash consensus: + +```bash +ots stamp utxo-report.json +ots verify utxo-report.json.ots +``` + +## Historical Duplicates + +Bitflash inherited early Bitcoin behavior around duplicate transaction IDs. +The verifier reports duplicate-output overwrites as warnings and uses +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. + +## Future Consensus Path + +This tool is the low-risk first step toward consensus commitments. A future +hard-fork design can commit this root, or a Utreexo-style accumulator root, in +coinbase or block headers after a fixed activation height. Until then this +script provides reproducible supply and state commitments without changing what +nodes accept. diff --git a/scripts/verify-utxo-set.py b/scripts/verify-utxo-set.py new file mode 100644 index 0000000..12b024f --- /dev/null +++ b/scripts/verify-utxo-set.py @@ -0,0 +1,464 @@ +#!/usr/bin/env python3 +"""Build a deterministic Bitflash UTXO-set commitment from local blk*.dat. + +This is deliberately outside consensus. It lets auditors reconstruct the chain +state from disk blocks and compare the same canonical UTXO root independently. +""" + +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" + + +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 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") + + +def build_report(raw_blocks, include_utxos=False): + blocks = select_main_chain(raw_blocks) + state = apply_blocks(blocks) + utxos = state["utxos"] + stats = state["stats"] + tip = blocks[-1] + root = utxo_root(utxos) + checks = { + "genesis_hash_matches_source": blocks[0]["hash"] == GENESIS_HASH, + "genesis_merkle_matches_source": blocks[0]["merkle_root"] == 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, + } + warnings = { + "legacy_duplicate_output_overwrites": len(stats["duplicate_outputs"]), + } + report = { + "schema": "bitflash-utxo-set-commitment-1", + "deterministic": True, + "chain": { + "name": "Bitflash", + "ticker": "BTF", + "message_start_hex": MAGIC.hex(), + "coin": COIN, + }, + "commitment": { + "algorithm": "sorted-outpoint-merkle-sha256", + "leaf_domain": "BTFUTXO1", + "node_domain": "BTFNODE1", + "root": root, + }, + "scanned": { + "raw_block_count": len(raw_blocks), + "side_blocks_ignored": len(raw_blocks) - len(blocks), + "main_chain_block_count": len(blocks), + "first_height": 0, + "last_height": len(blocks) - 1, + "tip_hash": tip["hash"], + "tip_time": tip["time"], + "tip_time_utc": tip["time_utc"], + }, + "stats": stats, + "checks": checks, + "warnings": warnings, + "verdict": "UTXO_COMMITMENT_VERIFIED" if all(checks.values()) else "CHECKS_FAILED", + } + if include_utxos: + report["utxos"] = {k: utxos[k] for k in sorted(utxos)} + return report + + +def print_text(report): + data_hash = hashlib.sha256(canonical_bytes(report)).hexdigest() + print("Bitflash UTXO-set commitment verifier") + print("schema: %s" % report["schema"]) + print("canonical report sha256: %s" % data_hash) + print() + print("Commitment") + print(" root: %s" % report["commitment"]["root"]) + print(" algo: %s" % report["commitment"]["algorithm"]) + print() + print("Scanned") + print(" main chain blocks: %d" % report["scanned"]["main_chain_block_count"]) + print(" raw disk blocks: %d" % report["scanned"]["raw_block_count"]) + print(" side ignored: %d" % report["scanned"]["side_blocks_ignored"]) + print(" height: %d..%d" % (report["scanned"]["first_height"], report["scanned"]["last_height"])) + print(" tip: %s" % report["scanned"]["tip_hash"]) + print() + print("Supply") + print(" utxos: %d" % report["stats"]["utxo_count"]) + print(" supply: %s BTF" % report["stats"]["total_unspent_btf"]) + print(" mined: %s BTF" % report["stats"]["total_coinbase_btf"]) + print(" delta: %s BTF" % report["stats"]["coinbase_minus_unspent_btf"]) + print(" fees: %s BTF" % report["stats"]["total_fees_btf"]) + if report["warnings"]["legacy_duplicate_output_overwrites"]: + print(" legacy duplicate overwrites: %d" % report["warnings"]["legacy_duplicate_output_overwrites"]) + print() + print("Checks") + for key in sorted(report["checks"]): + print(" %s %s" % ("OK " if report["checks"][key] else "FAIL", key)) + print() + print("Verdict: %s" % report["verdict"]) + print() + print("OpenTimestamps") + print(" canonical JSON can be stamped later with: ots stamp ") + + +def main(argv): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--datadir", default=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("--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) + 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) + if args.out: + Path(args.out).write_bytes(data) + if args.json: + sys.stdout.buffer.write(data) + else: + print_text(report) + return 0 if report["verdict"] == "UTXO_COMMITMENT_VERIFIED" else 1 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) -- cgit v1.2.3 From 7265a381405125aad5d91c3a11e9b619c9518c10 Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Mon, 10 Aug 2026 16:47:44 -0300 Subject: tools: add Bitflash mining calculator --- README.md | 13 +++ docs/mining-calculator.md | 86 ++++++++++++++ scripts/bitflash-mining-calculator.py | 206 ++++++++++++++++++++++++++++++++++ 3 files changed, 305 insertions(+) create mode 100644 docs/mining-calculator.md create mode 100644 scripts/bitflash-mining-calculator.py diff --git a/README.md b/README.md index ec8e9c4..d2e969a 100644 --- a/README.md +++ b/README.md @@ -237,6 +237,19 @@ SRBMiner-MULTI --algorithm randomx --pool 127.0.0.1:3333 --wallet YOUR_BTF_ADDRE xmrig -a rx/0 -o 127.0.0.1:3333 -u YOUR_BTF_ADDRESS -p x ``` +Estimate rewards and electricity cost locally: + +```bash +python3 scripts/bitflash-mining-calculator.py \ + --hashrate 21 --hashrate-unit kh/s \ + --network-hashrate 1.56 --network-hashrate-unit mh/s \ + --watts 140 --kwh-cost 0.10 \ + --price 0.001 --pool-fee 1 +``` + +See [mining calculator](docs/mining-calculator.md). Price is manual until +Bitflash has a reliable public market. + --- ## Headless / server mode diff --git a/docs/mining-calculator.md b/docs/mining-calculator.md new file mode 100644 index 0000000..352eea1 --- /dev/null +++ b/docs/mining-calculator.md @@ -0,0 +1,86 @@ +# Bitflash mining calculator + +`scripts/bitflash-mining-calculator.py` is a local WhatToMine-style calculator +for Bitflash. + +It does not fetch market prices, does not talk to exchanges, and does not +change consensus. Price is a manual input until Bitflash has a reliable public +market. + +## Examples + +Estimate from network hashrate: + +```bash +python3 scripts/bitflash-mining-calculator.py \ + --hashrate 21 --hashrate-unit kh/s \ + --network-hashrate 1.56 --network-hashrate-unit mh/s \ + --watts 140 --kwh-cost 0.10 \ + --price 0.001 --pool-fee 1 +``` + +Estimate from Bitflash difficulty: + +```bash +python3 scripts/bitflash-mining-calculator.py \ + --hashrate 21 --hashrate-unit kh/s \ + --difficulty 45600 \ + --watts 140 --kwh-cost 0.10 +``` + +Machine-readable output: + +```bash +python3 scripts/bitflash-mining-calculator.py \ + --hashrate 21 --hashrate-unit kh/s \ + --network-hashrate 1.56 --network-hashrate-unit mh/s \ + --json +``` + +## Bitflash Difficulty Scale + +Bitflash does not use Bitcoin's SHA256 difficulty-1 constant for pool shares. +The chain's easiest RandomX target is `bnProofOfWorkLimit = ~uint256(0) >> 12`, +so difficulty 1 expects about `4096` hashes. + +The calculator uses: + +```text +network_hashrate = difficulty * 4096 / block_time_seconds +``` + +At the default two-minute block target: + +```text +network_hashrate = difficulty * 4096 / 120 +``` + +If you already know network hashrate, pass `--network-hashrate` directly and +the script derives the equivalent Bitflash difficulty for display. + +## Reward Estimate + +The expected daily reward is: + +```text +blocks_per_day = 86400 / block_time_seconds +miner_share = miner_hashrate / network_hashrate +gross_btf_per_day = blocks_per_day * block_reward * miner_share +net_btf_per_day = gross_btf_per_day * (1 - pool_fee_percent / 100) +``` + +Electricity is: + +```text +electricity_per_day = watts / 1000 * 24 * kwh_cost +``` + +This is an expectation, not a promise. Solo mining variance can be very large, +especially when the miner has a small share of the network. + +## Future Website Use + +The script is intentionally standalone so the same math can later back a +website widget or status dashboard. Until a public price exists, a website +calculator should keep price as a manual field and label fiat profit as +speculative. diff --git a/scripts/bitflash-mining-calculator.py b/scripts/bitflash-mining-calculator.py new file mode 100644 index 0000000..1fcc1a9 --- /dev/null +++ b/scripts/bitflash-mining-calculator.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +"""Estimate Bitflash mining rewards and electricity cost. + +This is a local WhatToMine-style calculator for Bitflash. It does not fetch +prices, does not talk to the node, and does not affect consensus. +""" + +import argparse +import json +import math +import sys + + +HASHES_AT_DIFF_1 = 4096.0 +DEFAULT_BLOCK_TIME = 120.0 +DEFAULT_BLOCK_REWARD = 50.0 + + +UNITS = { + "h": 1.0, + "h/s": 1.0, + "hs": 1.0, + "kh": 1_000.0, + "kh/s": 1_000.0, + "khs": 1_000.0, + "mh": 1_000_000.0, + "mh/s": 1_000_000.0, + "mhs": 1_000_000.0, + "gh": 1_000_000_000.0, + "gh/s": 1_000_000_000.0, + "ghs": 1_000_000_000.0, +} + + +def parse_hashrate(value, unit): + try: + n = float(value) + except ValueError: + raise SystemExit("invalid hashrate: %s" % value) + mul = UNITS.get(unit.lower()) + if mul is None: + raise SystemExit("unknown hashrate unit: %s" % unit) + if n < 0: + raise SystemExit("hashrate must not be negative") + return n * mul + + +def fmt_hashrate(hps): + units = [("GH/s", 1_000_000_000.0), ("MH/s", 1_000_000.0), ("kH/s", 1_000.0), ("H/s", 1.0)] + for name, div in units: + if hps >= div: + return "%.4g %s" % (hps / div, name) + return "%.4g H/s" % hps + + +def fmt_money(x): + return "%.8f" % x + + +def estimate(args): + miner_hps = parse_hashrate(args.hashrate, args.hashrate_unit) + if args.network_hashrate is not None: + network_hps = parse_hashrate(args.network_hashrate, args.network_hashrate_unit) + difficulty = network_hps * args.block_time / HASHES_AT_DIFF_1 + network_source = "network_hashrate" + else: + if args.difficulty is None: + raise SystemExit("provide either --network-hashrate or --difficulty") + difficulty = float(args.difficulty) + if difficulty <= 0: + raise SystemExit("difficulty must be positive") + network_hps = difficulty * HASHES_AT_DIFF_1 / args.block_time + network_source = "difficulty" + + if network_hps <= 0: + raise SystemExit("network hashrate must be positive") + + block_reward = float(args.block_reward) + block_time = float(args.block_time) + pool_fee_percent = float(args.pool_fee) + price = float(args.price) + watts = float(args.watts) + kwh_cost = float(args.kwh_cost) + if block_reward < 0 or block_time <= 0 or pool_fee_percent < 0 or price < 0 or watts < 0 or kwh_cost < 0: + raise SystemExit("numeric inputs must be non-negative; block time must be positive") + + share = miner_hps / network_hps + blocks_per_day = 86400.0 / block_time + gross_btf_day = blocks_per_day * block_reward * share + net_btf_day = gross_btf_day * max(0.0, 1.0 - pool_fee_percent / 100.0) + revenue_day = net_btf_day * price + cost_day = watts / 1000.0 * 24.0 * kwh_cost + profit_day = revenue_day - cost_day + expected_seconds_per_block = math.inf if miner_hps <= 0 else difficulty * HASHES_AT_DIFF_1 / miner_hps + + periods = { + "hour": 1.0 / 24.0, + "day": 1.0, + "week": 7.0, + "month_30d": 30.0, + } + rows = {} + for name, days in periods.items(): + rows[name] = { + "btf": net_btf_day * days, + "revenue": revenue_day * days, + "electricity_cost": cost_day * days, + "profit": profit_day * days, + } + + return { + "schema": "bitflash-mining-calculator-1", + "network_source": network_source, + "inputs": { + "miner_hashrate_hps": miner_hps, + "miner_hashrate": fmt_hashrate(miner_hps), + "network_hashrate_hps": network_hps, + "network_hashrate": fmt_hashrate(network_hps), + "difficulty": difficulty, + "block_time_seconds": block_time, + "block_reward_btf": block_reward, + "pool_fee_percent": pool_fee_percent, + "price_per_btf": price, + "power_watts": watts, + "electricity_per_kwh": kwh_cost, + }, + "estimates": { + "miner_network_share": share, + "blocks_per_day": blocks_per_day, + "expected_time_to_solo_block_seconds": expected_seconds_per_block, + "expected_time_to_solo_block_hours": expected_seconds_per_block / 3600.0, + "gross_btf_per_day": gross_btf_day, + "net_btf_per_day": net_btf_day, + "periods": rows, + }, + } + + +def print_text(result): + i = result["inputs"] + e = result["estimates"] + print("Bitflash mining calculator") + print("schema: %s" % result["schema"]) + print() + print("Network") + print(" source: %s" % result["network_source"]) + print(" hashrate: %s" % i["network_hashrate"]) + print(" difficulty: %.8f" % i["difficulty"]) + print(" block time: %.0fs" % i["block_time_seconds"]) + print(" reward: %s BTF" % fmt_money(i["block_reward_btf"])) + print() + print("Miner") + print(" hashrate: %s" % i["miner_hashrate"]) + print(" share: %.8f%%" % (e["miner_network_share"] * 100.0)) + if math.isinf(e["expected_time_to_solo_block_seconds"]): + print(" solo block: never") + else: + print(" solo block: %.2f hours expected average" % e["expected_time_to_solo_block_hours"]) + print() + print("Economics") + print(" price: $%.8f / BTF" % i["price_per_btf"]) + print(" power: %.2f W" % i["power_watts"]) + print(" kWh cost: $%.4f" % i["electricity_per_kwh"]) + print(" pool fee: %.2f%%" % i["pool_fee_percent"]) + print() + print("Estimated rewards") + print(" %-9s %16s %12s %12s %12s" % ("period", "BTF", "revenue", "power", "profit")) + for name in ["hour", "day", "week", "month_30d"]: + row = e["periods"][name] + print(" %-9s %16s $%11.4f $%11.4f $%11.4f" % ( + name, + fmt_money(row["btf"]), + row["revenue"], + row["electricity_cost"], + row["profit"], + )) + print() + print("Note: this is an expectation, not a promise. Solo mining variance can be huge.") + + +def main(argv): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--hashrate", required=True, help="miner hashrate number") + ap.add_argument("--hashrate-unit", default="kh/s", choices=sorted(UNITS), help="miner hashrate unit") + ap.add_argument("--network-hashrate", help="network hashrate number") + ap.add_argument("--network-hashrate-unit", default="kh/s", choices=sorted(UNITS), help="network hashrate unit") + ap.add_argument("--difficulty", type=float, help="Bitflash difficulty; diff 1 expects 4096 hashes") + ap.add_argument("--block-time", type=float, default=DEFAULT_BLOCK_TIME, help="target block time in seconds") + ap.add_argument("--block-reward", type=float, default=DEFAULT_BLOCK_REWARD, help="block reward in BTF") + ap.add_argument("--pool-fee", type=float, default=0.0, help="pool fee percent") + ap.add_argument("--price", type=float, default=0.0, help="price per BTF in USD or your chosen fiat unit") + ap.add_argument("--watts", type=float, default=0.0, help="miner or whole-system power in watts") + ap.add_argument("--kwh-cost", type=float, default=0.0, help="electricity cost per kWh") + ap.add_argument("--json", action="store_true", help="print JSON") + args = ap.parse_args(argv) + + result = estimate(args) + if args.json: + print(json.dumps(result, sort_keys=True, indent=2)) + else: + print_text(result) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) -- cgit v1.2.3 From 034bc9a9497a336239aadb2e52d620661e90e536 Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Tue, 11 Aug 2026 09:31:58 -0300 Subject: tools: add UTXO inclusion proofs --- README.md | 10 +++ docs/utxo-commitment.md | 14 +++ docs/utxo-proofs.md | 61 +++++++++++++ scripts/prove-utxo.py | 202 +++++++++++++++++++++++++++++++++++++++++++ scripts/verify-utxo-proof.py | 163 ++++++++++++++++++++++++++++++++++ 5 files changed, 450 insertions(+) create mode 100644 docs/utxo-proofs.md create mode 100644 scripts/prove-utxo.py create mode 100644 scripts/verify-utxo-proof.py diff --git a/README.md b/README.md index d549337..c28b0f1 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,16 @@ It reconstructs the best chain from local block files, computes the current UTXO root and supply, and writes canonical JSON that independent auditors can compare or timestamp externally with OpenTimestamps. See [UTXO-set commitments](docs/utxo-commitment.md). + +The same root can prove one unspent output without sharing the full UTXO set: + +```bash +python3 scripts/prove-utxo.py TXID:VOUT --out utxo-proof.json --json +python3 scripts/verify-utxo-proof.py utxo-proof.json +``` + +See [UTXO inclusion proofs](docs/utxo-proofs.md). + Bitflash also ships a deterministic fair-launch verifier: ```bash diff --git a/docs/utxo-commitment.md b/docs/utxo-commitment.md index ddf5450..991e5d9 100644 --- a/docs/utxo-commitment.md +++ b/docs/utxo-commitment.md @@ -54,6 +54,20 @@ SHA256("BTFNODE1|" || left_hash || right_hash) The final hash is the UTXO-set root. +## Inclusion Proofs + +After a root has been computed, `scripts/prove-utxo.py` can create a compact +inclusion proof for one unspent output: + +```bash +python3 scripts/prove-utxo.py TXID:VOUT --out utxo-proof.json --json +python3 scripts/verify-utxo-proof.py utxo-proof.json +``` + +The verifier does not need local block files. It recomputes the target leaf, +walks the sibling path, and compares the result with the expected root. See +[UTXO inclusion proofs](utxo-proofs.md). + ## Deterministic Output `--json` and `--out` write canonical JSON with sorted keys, fixed separators, diff --git a/docs/utxo-proofs.md b/docs/utxo-proofs.md new file mode 100644 index 0000000..4947c3c --- /dev/null +++ b/docs/utxo-proofs.md @@ -0,0 +1,61 @@ +# Bitflash UTXO inclusion proofs + +`scripts/prove-utxo.py` creates a deterministic inclusion proof for one +currently unspent output. `scripts/verify-utxo-proof.py` verifies that proof +against the UTXO root produced by `scripts/verify-utxo-set.py`. + +This is intentionally outside consensus. It turns the audit root into something +useful for a specific coin without changing what nodes accept. + +## Create A Proof + +Use an unspent output in `txid:vout` form: + +```bash +python3 scripts/prove-utxo.py TXID:VOUT --out utxo-proof.json --json +``` + +The proof contains: + +- the target UTXO; +- its sorted leaf index; +- the leaf hash; +- the Merkle sibling path; +- the UTXO root and chain tip used to create it. + +It does not contain the full UTXO set. + +## Verify A Proof + +Verify against the root embedded in the proof: + +```bash +python3 scripts/verify-utxo-proof.py utxo-proof.json +``` + +Or verify against a root copied from another machine, dashboard, block explorer, +or future node export: + +```bash +python3 scripts/verify-utxo-proof.py utxo-proof.json \ + --root ROOT_FROM_REPORT_OR_EXPORT +``` + +The verifier does not need local `blk*.dat` files. It only needs the proof and +the expected root. + +## Scope + +This first proof format supports inclusion only. Absence and range proofs are +left for a later design because they require a more careful commitment scheme +and are easier to get subtly wrong. + +The proof uses the exact same leaf and node domains as the UTXO commitment: + +```text +BTFUTXO1 +BTFNODE1 +``` + +That keeps the verifier language-neutral and gives future implementations a +small, stable target. diff --git a/scripts/prove-utxo.py b/scripts/prove-utxo.py new file mode 100644 index 0000000..7a10d2a --- /dev/null +++ b/scripts/prove-utxo.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +"""Create a Bitflash UTXO inclusion proof from local blk*.dat files. + +The proof is deliberately outside consensus. It proves that one txid:vout is +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 + + +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") + + +def split_outpoint(outpoint): + parts = outpoint.split(":") + if len(parts) != 2 or len(parts[0]) != 64: + raise SystemExit("outpoint must be txid:vout") + try: + vout = int(parts[1], 10) + except ValueError: + raise SystemExit("vout must be a decimal integer") + if vout < 0: + raise SystemExit("vout must not be negative") + return parts[0].lower(), vout + + +def build_merkle_proof(keys, leaf_hashes, index): + current_index = index + layer = list(leaf_hashes) + proof = [] + level = 0 + while len(layer) > 1: + if current_index % 2 == 0: + sibling_index = current_index + 1 + duplicated = sibling_index >= len(layer) + sibling = layer[current_index] if duplicated else layer[sibling_index] + side = "right" + else: + sibling_index = current_index - 1 + duplicated = False + sibling = layer[sibling_index] + side = "left" + proof.append({ + "level": level, + "side": side, + "hash": sibling.hex(), + "duplicated": duplicated, + }) + + next_layer = [] + 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()) + layer = next_layer + current_index //= 2 + level += 1 + return proof, layer[0].hex() + + +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) + 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) + 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] + index = keys.index(outpoint) + proof_path, root = build_merkle_proof(keys, leaf_hashes, index) + direct_root = tool.utxo_root(utxos) + if root != direct_root: + raise SystemExit("internal proof root mismatch") + + tip = blocks[-1] + utxo = utxos[outpoint] + report = { + "schema": SCHEMA, + "deterministic": True, + "chain": { + "name": "Bitflash", + "ticker": "BTF", + "message_start_hex": tool.MAGIC.hex(), + "coin": tool.COIN, + }, + "commitment": { + "algorithm": "sorted-outpoint-merkle-sha256", + "leaf_domain": "BTFUTXO1", + "node_domain": "BTFNODE1", + "root": root, + "leaf_count": len(keys), + }, + "target": { + "outpoint": outpoint, + "txid": txid, + "vout": vout, + "leaf_index": index, + "leaf_hash": leaf_hashes[index].hex(), + "utxo": utxo, + }, + "proof": proof_path, + "scanned": { + "raw_block_count": len(raw_blocks), + "side_blocks_ignored": len(raw_blocks) - len(blocks), + "main_chain_block_count": len(blocks), + "first_height": 0, + "last_height": len(blocks) - 1, + "tip_hash": tip["hash"], + "tip_time": tip["time"], + "tip_time_utc": tip["time_utc"], + }, + "warnings": { + "legacy_duplicate_output_overwrites": len(stats["duplicate_outputs"]), + }, + "verdict": "UTXO_INCLUSION_PROOF_CREATED", + } + return report + + +def print_text(proof): + data_hash = hashlib.sha256(canonical_bytes(proof)).hexdigest() + target = proof["target"] + utxo = target["utxo"] + print("Bitflash UTXO inclusion proof") + print("schema: %s" % proof["schema"]) + print("canonical proof sha256: %s" % data_hash) + print() + print("Target") + print(" outpoint: %s" % target["outpoint"]) + print(" value: %s BTF" % utxo["value_btf"]) + print(" height: %d" % utxo["created_height"]) + print(" index: %d of %d" % (target["leaf_index"], proof["commitment"]["leaf_count"])) + print() + print("Commitment") + print(" root: %s" % proof["commitment"]["root"]) + print(" path: %d sibling hashes" % len(proof["proof"])) + print(" tip: %s" % proof["scanned"]["tip_hash"]) + print() + 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("--max-blocks", type=int, default=0, + help="number of blocks to scan; 0 means all block files") + 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) + + proof = build_proof(args) + data = canonical_bytes(proof) + if args.out: + Path(args.out).write_bytes(data) + if args.json: + sys.stdout.buffer.write(data) + else: + print_text(proof) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/verify-utxo-proof.py b/scripts/verify-utxo-proof.py new file mode 100644 index 0000000..31099f7 --- /dev/null +++ b/scripts/verify-utxo-proof.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Verify a Bitflash UTXO inclusion proof against a UTXO root.""" + +import argparse +import hashlib +import json +import sys +from pathlib import Path + + +SCHEMA = "bitflash-utxo-inclusion-proof-1" +LEAF_DOMAIN = "BTFUTXO1" +NODE_DOMAIN = b"BTFNODE1|" + + +def canonical_bytes(obj): + return (json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + "\n").encode("ascii") + + +def sha256(data): + return hashlib.sha256(data).digest() + + +def utxo_leaf_hash(outpoint, utxo): + payload = "|".join([ + LEAF_DOMAIN, + outpoint, + str(utxo["value_satoshis"]), + utxo["script_pub_key_hex"], + str(utxo["created_height"]), + utxo["created_txid"], + ]).encode("ascii") + return sha256(payload) + + +def load_proof(path): + try: + return json.loads(Path(path).read_text(encoding="ascii")) + except Exception as e: + raise SystemExit("could not read proof: %s" % e) + + +def verify(proof, expected_root=None): + failures = [] + if proof.get("schema") != SCHEMA: + failures.append("unexpected schema") + commitment = proof.get("commitment", {}) + if commitment.get("algorithm") != "sorted-outpoint-merkle-sha256": + failures.append("unexpected commitment algorithm") + if commitment.get("leaf_domain") != LEAF_DOMAIN: + failures.append("unexpected leaf domain") + if commitment.get("node_domain") != "BTFNODE1": + failures.append("unexpected node domain") + + target = proof.get("target", {}) + outpoint = target.get("outpoint", "") + utxo = target.get("utxo", {}) + if outpoint != "%s:%s" % (target.get("txid"), target.get("vout")): + failures.append("target outpoint does not match txid:vout") + + try: + current = utxo_leaf_hash(outpoint, utxo) + leaf_ok = target.get("leaf_hash") == current.hex() + except Exception as e: + failures.append("could not hash target leaf: %s" % e) + current = b"\x00" * 32 + leaf_ok = False + + if not leaf_ok: + failures.append("target leaf hash mismatch") + + for i, step in enumerate(proof.get("proof", [])): + try: + sibling = bytes.fromhex(step["hash"]) + except Exception: + failures.append("bad sibling hash at proof level %d" % i) + sibling = b"\x00" * 32 + if len(sibling) != 32: + failures.append("bad sibling hash length at proof level %d" % i) + side = step.get("side") + if side == "right": + current = sha256(NODE_DOMAIN + current + sibling) + elif side == "left": + current = sha256(NODE_DOMAIN + sibling + current) + else: + failures.append("bad sibling side at proof level %d" % i) + + computed_root = current.hex() + root = expected_root or commitment.get("root") + if computed_root != root: + failures.append("computed root does not match expected root") + + return { + "schema": "bitflash-utxo-proof-verification-1", + "deterministic": True, + "target": { + "outpoint": outpoint, + "value_satoshis": utxo.get("value_satoshis"), + "value_btf": utxo.get("value_btf"), + "created_height": utxo.get("created_height"), + }, + "commitment": { + "expected_root": root, + "computed_root": computed_root, + "leaf_hash": target.get("leaf_hash"), + "path_length": len(proof.get("proof", [])), + }, + "checks": { + "schema_is_supported": proof.get("schema") == SCHEMA, + "leaf_hash_matches_target": leaf_ok, + "root_matches": computed_root == root, + }, + "failures": failures, + "verdict": "UTXO_PROOF_VERIFIED" if not failures else "UTXO_PROOF_FAILED", + } + + +def print_text(result): + data_hash = hashlib.sha256(canonical_bytes(result)).hexdigest() + print("Bitflash UTXO proof verifier") + print("schema: %s" % result["schema"]) + print("canonical verification sha256: %s" % data_hash) + print() + print("Target") + print(" outpoint: %s" % result["target"]["outpoint"]) + print(" value: %s BTF" % result["target"]["value_btf"]) + print(" height: %s" % result["target"]["created_height"]) + print() + print("Commitment") + print(" expected: %s" % result["commitment"]["expected_root"]) + print(" computed: %s" % result["commitment"]["computed_root"]) + print(" path: %d sibling hashes" % result["commitment"]["path_length"]) + print() + print("Checks") + for key in sorted(result["checks"]): + print(" %s %s" % ("OK " if result["checks"][key] else "FAIL", key)) + if result["failures"]: + print() + print("Failures") + for failure in result["failures"]: + print(" - %s" % failure) + print() + print("Verdict: %s" % result["verdict"]) + + +def main(argv): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("proof", help="canonical JSON proof from prove-utxo.py") + ap.add_argument("--root", help="expected UTXO root; defaults to root embedded in proof") + ap.add_argument("--json", action="store_true", help="print canonical JSON") + args = ap.parse_args(argv) + + result = verify(load_proof(args.proof), args.root) + data = canonical_bytes(result) + if args.json: + sys.stdout.buffer.write(data) + else: + print_text(result) + return 0 if result["verdict"] == "UTXO_PROOF_VERIFIED" else 1 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) -- cgit v1.2.3 From dfbf7a91caa4a85da7ce10fa296cfa6d2ead5d0c Mon Sep 17 00:00:00 2001 From: Bitflash Date: Tue, 11 Aug 2026 09:57:29 -0300 Subject: wallet: SLIP-0044 4346950 is official, drop 'provisional' naming The coin type 4346950 (0x425446 = BTF) was merged into the SLIP-0044 registry on 2026-08-07. Rename HD_BIP44_COIN_TYPE_BITFLASH_PROVISIONAL to HD_BIP44_COIN_TYPE_BITFLASH and update comments, logs and UI strings. Value is unchanged, so derivation paths and addresses are identical. --- src/db.cpp | 6 +++--- src/gui.cpp | 2 +- src/main.cpp | 6 +++--- src/main.h | 8 ++++---- src/selftest.cpp | 12 ++++++------ src/walletcmd.cpp | 2 +- 6 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/db.cpp b/src/db.cpp index 244d5a7..9f62964 100644 --- a/src/db.cpp +++ b/src/db.cpp @@ -880,9 +880,9 @@ bool CWalletDB::LoadWallet(vector& vchDefaultKeyRet) } if (!fHaveStoredHDCoinType) { - nHDCoinType = HD_BIP44_COIN_TYPE_BITFLASH_PROVISIONAL; + nHDCoinType = HD_BIP44_COIN_TYPE_BITFLASH; printf("LoadWallet: deterministic seed has no BIP44 coin type metadata; " - "using provisional Bitflash coin type %u\n", nHDCoinType); + "using Bitflash coin type %u\n", nHDCoinType); } } else @@ -891,7 +891,7 @@ bool CWalletDB::LoadWallet(vector& vchDefaultKeyRet) nHDNext = 0; nHDReceiveNext = 0; nHDChangeNext = 0; - nHDCoinType = HD_BIP44_COIN_TYPE_BITFLASH_PROVISIONAL; + nHDCoinType = HD_BIP44_COIN_TYPE_BITFLASH; } // fGenerateBitcoins and nMineMode only mean anything together, and a diff --git a/src/gui.cpp b/src/gui.cpp index 8f1d204..8dcb854 100644 --- a/src/gui.cpp +++ b/src/gui.cpp @@ -1250,7 +1250,7 @@ static void DrawWalletSafetyDialog() if (g_recoveryAudit.fHaveSeed) { ImGui::Text("Derivation schema: %s", HDKeySchemaName(g_recoveryAudit.nSchema).c_str()); - ImGui::Text("BIP44 coin type: %u (provisional BITFLASH)", g_recoveryAudit.nCoinType); + ImGui::Text("BIP44 coin type: %u (SLIP-0044 BITFLASH)", g_recoveryAudit.nCoinType); } ImGui::Text("Phrase-backed spendable balance: %s BTF", FmtMoney(g_recoveryAudit.nRecoverableCredit).c_str()); diff --git a/src/main.cpp b/src/main.cpp index 2eb0e3a..f83164f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -642,7 +642,7 @@ unsigned int nHDNext = 0; int nHDKeySchema = HD_SCHEMA_NONE; unsigned int nHDReceiveNext = 0; unsigned int nHDChangeNext = 0; -unsigned int nHDCoinType = HD_BIP44_COIN_TYPE_BITFLASH_PROVISIONAL; +unsigned int nHDCoinType = HD_BIP44_COIN_TYPE_BITFLASH; string HDKeySchemaName(int nSchema) { @@ -798,7 +798,7 @@ bool SetHDSeedFromMnemonic(const string& strMnemonic, string& strErrorRet) if (!CWalletDB().WriteHDMaster(master.privateKey, master.chainCode) || !CWalletDB().WriteHDSchema(HD_SCHEMA_BIP44) || - !CWalletDB().WriteHDCoinType(HD_BIP44_COIN_TYPE_BITFLASH_PROVISIONAL) || + !CWalletDB().WriteHDCoinType(HD_BIP44_COIN_TYPE_BITFLASH) || !CWalletDB().WriteHDNext(0) || !CWalletDB().WriteHDReceiveNext(1) || !CWalletDB().WriteHDChangeNext(0)) @@ -811,7 +811,7 @@ bool SetHDSeedFromMnemonic(const string& strMnemonic, string& strErrorRet) nHDKeySchema = HD_SCHEMA_BIP44; nHDReceiveNext = 1; nHDChangeNext = 0; - nHDCoinType = HD_BIP44_COIN_TYPE_BITFLASH_PROVISIONAL; + nHDCoinType = HD_BIP44_COIN_TYPE_BITFLASH; // Index 0 is spoken for before it is derived, so a failure below cannot // leave the pool free to hand it out as an ordinary key. The cost of diff --git a/src/main.h b/src/main.h index eafbf16..b389f1d 100644 --- a/src/main.h +++ b/src/main.h @@ -216,9 +216,9 @@ static const unsigned int HD_BIP44_PURPOSE = 44; static const unsigned int HD_BIP44_ACCOUNT = 0; static const unsigned int HD_BIP44_CHAIN_RECEIVE = 0; static const unsigned int HD_BIP44_CHAIN_CHANGE = 1; -// Provisional until Bitflash has an official SLIP-0044 assignment. -// Proposed registry row: 4346950 | BITFLASH | Bitflash. -static const unsigned int HD_BIP44_COIN_TYPE_BITFLASH_PROVISIONAL = 4346950; +// Official SLIP-0044 assignment (registered 2026-08-07): +// 4346950 | BITFLASH | Bitflash (4346950 == 0x425446 == "BTF") +static const unsigned int HD_BIP44_COIN_TYPE_BITFLASH = 4346950; extern int nHDKeySchema; extern unsigned int nHDReceiveNext; // BIP44 external chain extern unsigned int nHDChangeNext; // BIP44 internal chain @@ -316,7 +316,7 @@ struct WalletRecoveryAudit nDerivedKnown = 0; nReceiveNext = 0; nChangeNext = 0; - nCoinType = HD_BIP44_COIN_TYPE_BITFLASH_PROVISIONAL; + nCoinType = HD_BIP44_COIN_TYPE_BITFLASH; nRecoverableTx = 0; nLegacyTx = 0; nRecoverableImmatureTx = 0; diff --git a/src/selftest.cpp b/src/selftest.cpp index fadc959..69a3596 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -350,17 +350,17 @@ static int RunWalletHDSelfTest() nFail += Check(HaveHDSeed(), "the wallet reports having a seed") ? 0 : 1; nFail += Check(nHDKeySchema == HD_SCHEMA_BIP44, "a new recovery phrase records the BIP44 HD schema") ? 0 : 1; - nFail += Check(nHDCoinType == HD_BIP44_COIN_TYPE_BITFLASH_PROVISIONAL, - "a new recovery phrase records the provisional BIP44 coin type") ? 0 : 1; + nFail += Check(nHDCoinType == HD_BIP44_COIN_TYPE_BITFLASH, + "a new recovery phrase records the SLIP-0044 BIP44 coin type") ? 0 : 1; nFail += Check(nHDReceiveNext == 1 && nHDChangeNext == 0, "BIP44 receive/change counters reserve the default receive key") ? 0 : 1; - std::vector vBIP44Path = HDBIP44Path(HD_BIP44_COIN_TYPE_BITFLASH_PROVISIONAL, + std::vector vBIP44Path = HDBIP44Path(HD_BIP44_COIN_TYPE_BITFLASH, HD_BIP44_ACCOUNT, HD_BIP44_CHAIN_RECEIVE, 0); nFail += Check(vBIP44Path.size() == 5 && vBIP44Path[0] == (HD_BIP44_PURPOSE | bitflash::BIP32_HARDENED) && - vBIP44Path[1] == (HD_BIP44_COIN_TYPE_BITFLASH_PROVISIONAL | bitflash::BIP32_HARDENED) && + vBIP44Path[1] == (HD_BIP44_COIN_TYPE_BITFLASH | bitflash::BIP32_HARDENED) && vBIP44Path[2] == (HD_BIP44_ACCOUNT | bitflash::BIP32_HARDENED) && vBIP44Path[3] == HD_BIP44_CHAIN_RECEIVE && vBIP44Path[4] == 0, @@ -382,7 +382,7 @@ static int RunWalletHDSelfTest() hdParent.chainCode = vchHDChainCode; bitflash::BIP32PrivateNode hdBIP44ReceiveChild; if (!bitflash::BIP32DerivePath(hdParent, - HDBIP44Path(HD_BIP44_COIN_TYPE_BITFLASH_PROVISIONAL, + HDBIP44Path(HD_BIP44_COIN_TYPE_BITFLASH, HD_BIP44_ACCOUNT, HD_BIP44_CHAIN_RECEIVE, 0), @@ -570,7 +570,7 @@ static int RunWalletHDSelfTest() nFail += Check(audit.fHaveSeed, "the recovery audit reports the phrase") ? 0 : 1; nFail += Check(audit.nSchema == HD_SCHEMA_BIP44, "the recovery audit reports the derivation schema") ? 0 : 1; - nFail += Check(audit.nCoinType == HD_BIP44_COIN_TYPE_BITFLASH_PROVISIONAL, + nFail += Check(audit.nCoinType == HD_BIP44_COIN_TYPE_BITFLASH, "the recovery audit reports the BIP44 coin type") ? 0 : 1; nFail += Check(audit.nReceiveNext == nHDReceiveNext && audit.nChangeNext == 1, "the recovery audit reports receive/change counters") ? 0 : 1; diff --git a/src/walletcmd.cpp b/src/walletcmd.cpp index 06f35a5..0ea2251 100644 --- a/src/walletcmd.cpp +++ b/src/walletcmd.cpp @@ -354,7 +354,7 @@ int CmdRecoveryAudit() if (audit.fHaveSeed) { printf(" derivation schema: %s\n", HDKeySchemaName(audit.nSchema).c_str()); - printf(" BIP44 coin type: %u (provisional BITFLASH)\n", audit.nCoinType); + printf(" BIP44 coin type: %u (SLIP-0044 BITFLASH)\n", audit.nCoinType); printf(" derived keys known to this wallet: %u\n", audit.nDerivedKnown); printf(" receive/change counters: %u/%u\n", audit.nReceiveNext, audit.nChangeNext); -- cgit v1.2.3 From faf6c4d24304b770c2d43515da04832b2124c92a Mon Sep 17 00:00:00 2001 From: Bitflash Date: Tue, 11 Aug 2026 10:13:04 -0300 Subject: money: ParseMoney accepts full 8 decimals (0.5, 0.00000001) The legacy parser only accepted two decimal places (cents) and rejected 0.5, leaving six of the eight coin decimals unreachable by -sendto and the send window. Parse up to 8 fractional digits Bitcoin-style, reject >8 to avoid silent truncation. Adds the parse-money selftest (12 cases). --- src/Makefile | 1 + src/selftest.cpp | 37 +++++++++++++++++++++++++++++++++++++ src/util.cpp | 23 +++++++++++++---------- 3 files changed, 51 insertions(+), 10 deletions(-) diff --git a/src/Makefile b/src/Makefile index bc15ef8..00a8182 100644 --- a/src/Makefile +++ b/src/Makefile @@ -192,5 +192,6 @@ tests: test_bip32 bitflash-node ./bitflash-node -selftest=net-message ./bitflash-node -selftest=consensus-limits ./bitflash-node -selftest=pool-stratum + ./bitflash-node -selftest=parse-money .PHONY: tests fuzz-net-message-smoke fuzz-script-smoke diff --git a/src/selftest.cpp b/src/selftest.cpp index 69a3596..dcb9192 100644 --- a/src/selftest.cpp +++ b/src/selftest.cpp @@ -1218,6 +1218,41 @@ static int RunConsensusLimitsSelfTest() return nFail == 0 ? 0 : 1; } +static int RunParseMoneySelfTest() +{ + printf("parse-money self-test\n"); + int nFail = 0; + struct Case { const char* in; bool ok; int64 val; }; + Case cases[] = { + {"0.5", true, 50000000LL}, + {"1", true, 100000000LL}, + {"1.23456789", true, 123456789LL}, + {"0.00000001", true, 1LL}, + {"10.00000000", true, 1000000000LL}, + {"1,000", true, 100000000000LL}, + {"1.5 ", true, 150000000LL}, + {"0.123456789", false, 0LL}, + {"1.2.3", false, 0LL}, + {"abc", false, 0LL}, + {"-1", false, 0LL}, + {"1e5", false, 0LL}, + }; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) + { + int64 v = -1; + bool r = ParseMoney(cases[i].in, v); + bool pass = (r == cases[i].ok) && (!r || v == cases[i].val); + char desc[160]; + snprintf(desc, sizeof(desc), "ParseMoney(\"%s\") %s and value matches", + cases[i].in, cases[i].ok ? "accepts" : "rejects"); + nFail += Check(pass, desc) ? 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(); @@ -1238,6 +1273,8 @@ int RunSelfTest(const std::string& name) return RunConsensusLimitsSelfTest(); if (name == "pool-stratum") return RunPoolStratumSelfTest(); + if (name == "parse-money") + return RunParseMoneySelfTest(); 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"); diff --git a/src/util.cpp b/src/util.cpp index d52ca88..0cd0133 100644 --- a/src/util.cpp +++ b/src/util.cpp @@ -337,7 +337,7 @@ string FormatMoney(int64 n, bool fPlus) bool ParseMoney(const char* pszIn, int64& nRet) { string strWhole; - int64 nCents = 0; + int64 nUnits = 0; // fractional part, scaled to COIN (8 decimals) const char* p = pszIn; while (isspace(*p)) p++; @@ -348,12 +348,16 @@ bool ParseMoney(const char* pszIn, int64& nRet) if (*p == '.') { p++; - if (!isdigit(p[0]) || !isdigit(p[1])) + int64 nMult = COIN / 10; // weight of the first decimal digit + while (isdigit(*p) && nMult > 0) + { + nUnits += nMult * (*p - '0'); + nMult /= 10; + p++; + } + // reject more than 8 fractional digits rather than silently truncate + if (isdigit(*p)) return false; - nCents = atoi64(p); - if (nCents < 0 || nCents > 99) - return false; - p += 2; break; } if (isspace(*p)) @@ -365,13 +369,12 @@ bool ParseMoney(const char* pszIn, int64& nRet) for (; *p; p++) if (!isspace(*p)) return false; - if (strWhole.size() > 17) + if (strWhole.size() > 10) // 10 whole digits * COIN stays within int64 return false; int64 nWhole = atoi64(strWhole); - int64 nValue = nWhole * 100 + nCents; - if (nValue / 100 != nWhole) + int64 nValue = nWhole * COIN + nUnits; + if (nValue < 0 || (nValue - nUnits) / COIN != nWhole) return false; - nValue *= CENT; nRet = nValue; return true; } -- cgit v1.2.3 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 c5f6b52c0d1d65f7bf8cd745a7a212bc6b2fe318 Mon Sep 17 00:00:00 2001 From: Nsable-sh Date: Tue, 11 Aug 2026 16:38:49 -0300 Subject: net: replace peer select loop with poll --- README.md | 2 +- src/makefile.mingw | 7 +-- src/net.cpp | 152 ++++++++++++++++++++++++++++++----------------------- src/net.h | 15 +++--- 4 files changed, 93 insertions(+), 83 deletions(-) diff --git a/README.md b/README.md index c28b0f1..f7d9468 100644 --- a/README.md +++ b/README.md @@ -321,7 +321,7 @@ the same one will refuse to start. ### When something looks wrong **Menu > Diagnostics**, and the same report in `debug.log` every ten minutes: -peers held against peers `select()` is actually watching, blocks received and how +peers held against peers the socket loop is actually watching, blocks received and how many arrived without a parent, the proof-of-work mode with the live miner thread count, sockets by the part of the program that opened them, and per peer how long since the last message each way. There is a copy button — if you open an issue, diff --git a/src/makefile.mingw b/src/makefile.mingw index b946e50..678d2e8 100644 --- a/src/makefile.mingw +++ b/src/makefile.mingw @@ -14,13 +14,8 @@ WARNFLAGS = -Wformat -Werror=format \ -Wno-deprecated-declarations -Wno-write-strings \ -Wno-narrowing -Wno-unused-result -# FD_SETSIZE has to be defined before winsock2.h and it has to be the same -# number in every translation unit -- fd_set carries an array of that length, -# so disagreeing about it means disagreeing about sizeof(fd_set). That is why -# it lives here as a -D and not as a #define in a header. Windows defaults to -# 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 \ + -DWIN32 -DNOPCH \ -DSECP256K1_STATIC \ -I$(DEPS)/include -I$(RANDOMX)/src \ -I$(DEPS)/include/nlohmann \ diff --git a/src/net.cpp b/src/net.cpp index b9bb95d..b6b990a 100644 --- a/src/net.cpp +++ b/src/net.cpp @@ -13,6 +13,8 @@ #include // hardware_concurrency, for the miner thread count #ifdef _WIN32 #include +#else +#include #endif #include #include "btfaddr.h" @@ -22,6 +24,20 @@ void ThreadReconnectCachedBtfPeers(void* parg); void ThreadMessageHandler2(void* parg); void ThreadSocketHandler2(void* parg); +#ifdef _WIN32 +typedef WSAPOLLFD BtfPollFd; +static int BtfPoll(BtfPollFd* pfd, unsigned int nfd, int nTimeoutMs) +{ + return WSAPoll(pfd, nfd, nTimeoutMs); +} +#else +typedef struct pollfd BtfPollFd; +static int BtfPoll(BtfPollFd* pfd, unsigned int nfd, int nTimeoutMs) +{ + return poll(pfd, (nfds_t)nfd, nTimeoutMs); +} +#endif + @@ -274,8 +290,8 @@ string GetDiagnosticsText() // The number that would have made the deafness obvious. Anything held but // not watched is a socket this node will never read again. - str += strprintf(" peers watched %d of %d that select() can hold%s\n", - nPeersWatched, (int)FD_SETSIZE - 1, + str += strprintf(" peers watched %d of %d held by poll%s\n", + nPeersWatched, nHeld, nHeld > nPeersWatched ? " <-- NOT ALL PEERS ARE BEING READ" : ""); if (nBlocksReceived > 0) @@ -1198,7 +1214,7 @@ void CNode::Disconnect() // Invalidate before anything else can look at it. This used to close the // socket and leave the handle number sitting in the object, so ~CNode - // closed it a second time and the select loop kept arming it in the + // closed it a second time and the socket loop kept arming it in the // meantime. See the note on ~CNode in net.h. BtfCloseSocket(hSocket); hSocket = INVALID_SOCKET; @@ -1359,52 +1375,49 @@ void ThreadSocketHandler2(void* parg) // // Find which sockets have data to receive // - struct timeval timeout; - timeout.tv_sec = 0; - timeout.tv_usec = 50000; // frequency to poll pnode->vSend - - fd_set fdsetRecv; - fd_set fdsetSend; - FD_ZERO(&fdsetRecv); - FD_ZERO(&fdsetSend); - SOCKET hSocketMax = 0; - FD_SET(hListenSocket, &fdsetRecv); - hSocketMax = max(hSocketMax, hListenSocket); - // FD_SET drops silently once the set is full, so count what goes in - // and say so. A peer that never makes it into the set is never read, - // and that is indistinguishable from a peer with nothing to say -- - // the failure this whole path used to hide. - unsigned int nWatched = 1; // the listen socket + static const int nPollTimeoutMs = 50; // frequency to poll pnode->vSend + + struct NodePollResult + { + CNode* pnode; + SOCKET hSocket; + bool fRecv; + bool fSend; + bool fInvalid; + NodePollResult(CNode* pnodeIn, SOCKET hSocketIn) + : pnode(pnodeIn), hSocket(hSocketIn), fRecv(false), fSend(false), fInvalid(false) {} + }; + + vector vPoll; + vector vPollNodes; + + BtfPollFd listenPoll; + memset(&listenPoll, 0, sizeof(listenPoll)); + listenPoll.fd = hListenSocket; + listenPoll.events = POLLIN; + vPoll.push_back(listenPoll); + CRITICAL_BLOCK(cs_vNodes) { foreach(CNode* pnode, vNodes) { - if (nWatched >= FD_SETSIZE) - break; // A disconnected node stays in vNodes until its references go. - // Arming its closed handle asks select() to watch a number the - // operating system may already have given to somebody else. + // Do not watch a closed handle number: the OS may already have + // handed it to a different connection. if (pnode->hSocket == INVALID_SOCKET) continue; - FD_SET(pnode->hSocket, &fdsetRecv); - nWatched++; - hSocketMax = max(hSocketMax, pnode->hSocket); + + BtfPollFd pfd; + memset(&pfd, 0, sizeof(pfd)); + pfd.fd = pnode->hSocket; + pfd.events = POLLIN; TRY_CRITICAL_BLOCK(pnode->cs_vSend) if (!pnode->vSend.empty()) - FD_SET(pnode->hSocket, &fdsetSend); - } - if (vNodes.size() + 1 > (size_t)FD_SETSIZE) - { - static int64 nLastWarned = 0; - if (GetTime() - nLastWarned > 60) - { - nLastWarned = GetTime(); - printf("WARNING: %d peers but select() can only watch %d -- %d are not being read\n", - (int)vNodes.size(), (int)FD_SETSIZE - 1, - (int)vNodes.size() - (int)FD_SETSIZE + 1); - } + pfd.events |= POLLOUT; + vPoll.push_back(pfd); + vPollNodes.push_back(NodePollResult(pnode, pnode->hSocket)); } - nPeersWatched = (int)nWatched - 1; // less the listen socket + nPeersWatched = (int)vPollNodes.size(); } // Say the state of the node out loud now and then, so a log pulled off @@ -1425,32 +1438,17 @@ void ThreadSocketHandler2(void* parg) } vfThreadRunning[0] = false; - int nSelect = select(hSocketMax + 1, &fdsetRecv, &fdsetSend, NULL, &timeout); + int nPoll = BtfPoll(&vPoll[0], (unsigned int)vPoll.size(), nPollTimeoutMs); vfThreadRunning[0] = true; CheckForShutdown(0); - if (nSelect == SOCKET_ERROR) + if (nPoll == SOCKET_ERROR) { - // This used to answer a failed select() by marking every - // descriptor from 0 to hSocketMax ready, which is code written for - // POSIX, where a descriptor is a small integer. A Windows SOCKET is - // a kernel handle in the thousands, so the loop iterated over - // numbers that are not sockets and told the rest of the function - // that every peer had data. One bad handle in the set makes - // select() fail on every pass, so a single closed socket put the - // whole thread into that state permanently. - // - // Find the bad handle instead and let it be dropped. int nErr = WSAGetLastError(); - LogPrint("net", "select failed: %d\n", nErr); - FD_ZERO(&fdsetRecv); - FD_ZERO(&fdsetSend); + LogPrint("net", "poll failed: %d\n", nErr); CRITICAL_BLOCK(cs_vNodes) { foreach(CNode* pnode, vNodes) { - // Already disconnected: its handle is gone on purpose, and - // reporting it here would name the wrong node for a select() - // failure it did not cause. if (pnode->hSocket == INVALID_SOCKET) continue; int nType = 0; @@ -1467,7 +1465,17 @@ void ThreadSocketHandler2(void* parg) } } } - Sleep(timeout.tv_usec/1000); + Sleep(nPollTimeoutMs); + } + else + { + for (size_t i = 0; i < vPollNodes.size(); i++) + { + short revents = vPoll[i + 1].revents; // poll[0] is the listener + vPollNodes[i].fRecv = (revents & (POLLIN | POLLERR | POLLHUP)) != 0; + vPollNodes[i].fSend = (revents & POLLOUT) != 0; + vPollNodes[i].fInvalid = (revents & POLLNVAL) != 0; + } } RandAddSeed(); @@ -1483,7 +1491,7 @@ void ThreadSocketHandler2(void* parg) // // Accept new connections // - if (FD_ISSET(hListenSocket, &fdsetRecv)) + if (nPoll != SOCKET_ERROR && (vPoll[0].revents & POLLIN)) { struct sockaddr_in sockaddr; #ifdef _WIN32 @@ -1502,7 +1510,7 @@ void ThreadSocketHandler2(void* parg) { // Refuse rather than accept a connection this node cannot // watch. Without this the listen socket kept taking peers -- - // it is always in the select set -- while everything past the + // it is always watched -- while everything past the // limit sat open and unread, which fed on itself: the deafer // the node got, the more connections it collected. unsigned int nNodes = 0; @@ -1529,18 +1537,28 @@ void ThreadSocketHandler2(void* parg) // // Service each socket // - vector vNodesCopy; - CRITICAL_BLOCK(cs_vNodes) - vNodesCopy = vNodes; - foreach(CNode* pnode, vNodesCopy) + for (size_t i = 0; i < vPollNodes.size(); i++) { CheckForShutdown(0); - SOCKET hSocket = pnode->hSocket; + NodePollResult& poll = vPollNodes[i]; + CNode* pnode = poll.pnode; + SOCKET hSocket = poll.hSocket; + + if (hSocket == INVALID_SOCKET || pnode->hSocket != hSocket) + continue; + + if (poll.fInvalid) + { + LogPrint("net", "poll reported invalid socket for %s\n", + pnode->addr.ToString().c_str()); + pnode->fDisconnect = true; + continue; + } // // Receive // - if (FD_ISSET(hSocket, &fdsetRecv)) + if (poll.fRecv) { TRY_CRITICAL_BLOCK(pnode->cs_vRecv) { @@ -1580,7 +1598,7 @@ void ThreadSocketHandler2(void* parg) // // Send // - if (FD_ISSET(hSocket, &fdsetSend)) + if (poll.fSend) { TRY_CRITICAL_BLOCK(pnode->cs_vSend) { diff --git a/src/net.h b/src/net.h index bcd0b2a..feecf96 100644 --- a/src/net.h +++ b/src/net.h @@ -98,19 +98,16 @@ static const int BTF_INCOMPLETE_MESSAGE_TIMEOUT_SECS = 2 * 60; // Ceiling on simultaneous connections. // -// ThreadSocketHandler watches every peer through one select(), and select() -// cannot watch more descriptors than FD_SETSIZE. On Windows FD_SET simply -// stops adding once the set is full -- no error, no log -- so every socket -// past the limit stays open and is never read again. Nothing capped vNodes, -// so a node accepted connections it had no way to service, and because new -// entries go on the end of vNodes it was always the newest connection that -// starved, including the ones dialed to fix connectivity. +// ThreadSocketHandler watches every peer in one socket loop. This used to be a +// select() loop, which made FD_SETSIZE the real limit and silently starved +// sockets past that bitmap. It is now poll()/WSAPoll(), so the ceiling below is +// a deliberate resource bound rather than a descriptor-set accident. // // Measured on two nodes before this ceiling existed: 277 and 90 CNode objects // against 93 and 90 live sockets, and a status bar reporting hundreds of // peers for a node that was really talking to thirty. // -// 125 is Bitcoin's number and sits far below FD_SETSIZE on both platforms. +// 125 is Bitcoin's number and remains a conservative public-node default. static const unsigned int MAX_CONNECTIONS = 125; // A node marked for disconnect is normally held until its buffers drain, so @@ -142,7 +139,7 @@ static const int BTF_BEHIND_WARN_INTERVAL_SECS = 5 * 60; // // These counters exist so it can. Cheap to keep, and they name the failures // that actually happened rather than the ones that sound impressive. -extern int nPeersWatched; // peers in the last select() set +extern int nPeersWatched; // peers in the last poll() set extern int64 nBlocksReceived; // blocks handed to ProcessBlock extern int64 nBlocksWithoutParent; // ...of those, how many arrived orphaned -- 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 From 351f836652907db6e3db76c880284003f0de94b8 Mon Sep 17 00:00:00 2001 From: bitflash Date: Wed, 12 Aug 2026 10:32:27 -0300 Subject: build: fix leftover merge markers in makefile.mingw (drop FD_SETSIZE, keep BITFLASH_NODE_BUILD) --- src/makefile.mingw | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/makefile.mingw b/src/makefile.mingw index 4e36a5c..f82247a 100644 --- a/src/makefile.mingw +++ b/src/makefile.mingw @@ -15,13 +15,8 @@ WARNFLAGS = -Wformat -Werror=format \ -Wno-narrowing -Wno-unused-result CXXFLAGS = -std=gnu++14 -O2 $(WARNFLAGS) -fpermissive -pthread \ -<<<<<<< HEAD -DWIN32 -DNOPCH \ - -DSECP256K1_STATIC \ -======= - -DWIN32 -DNOPCH -DFD_SETSIZE=1024 \ -DSECP256K1_STATIC -DBITFLASH_NODE_BUILD \ ->>>>>>> origin/main -I$(DEPS)/include -I$(RANDOMX)/src \ -I$(DEPS)/include/nlohmann \ -IC:/msys64/ucrt64/include \ -- cgit v1.2.3 From bc3de301d9ed0e5fb5e2034be2e066d77cf23804 Mon Sep 17 00:00:00 2001 From: bitflash Date: Wed, 12 Aug 2026 10:36:49 -0300 Subject: release: v1.2.18 (Tor/SOCKS5 outbound privacy, poll() socket handler, new app icon) --- Makefile | 2 +- src/rc/bitcoin.ico | Bin 372526 -> 150897 bytes 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 6cea1a8..b4d0cf7 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.17 +VERSION := 1.2.18 SECP256K1_REPO := https://github.com/bitcoin-core/secp256k1 SECP256K1_COMMIT := 7fecac74aed8e1fd9078380d67dd04663705c989 diff --git a/src/rc/bitcoin.ico b/src/rc/bitcoin.ico index b768077..33323de 100644 Binary files a/src/rc/bitcoin.ico and b/src/rc/bitcoin.ico differ -- cgit v1.2.3