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 --- scripts/verify-release.sh | 108 +++++++++++++++++++++++++--------------------- 1 file changed, 58 insertions(+), 50 deletions(-) (limited to 'scripts') 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 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 (limited to 'scripts') 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(-) (limited to 'scripts') 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 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(-) (limited to 'scripts') 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 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 (limited to 'scripts') 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 (limited to 'scripts') 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 (limited to 'scripts') 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 (limited to 'scripts') 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 (limited to 'scripts') 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 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 (limited to 'scripts') 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(-) (limited to 'scripts') 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