upleb.uk

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

summaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
authorbitflash-maintainer <noreply@bitflash.invalid>2026-08-23 05:21:31 +0000
committerbitflash-maintainer <noreply@bitflash.invalid>2026-08-23 05:21:31 +0000
commita9b4f8940907c2bf51e431172b628de89734a908 (patch)
treeff0a0b19d7d7754b809e35c6eae43dae7a32e1e4 /scripts
parent6d4c3f153cf44cefead39b21191dfb33491bfa5a (diff)
parentbc3de301d9ed0e5fb5e2034be2e066d77cf23804 (diff)
Merge upstream v1.2.18 into the fork
Merges upstream tag v1.2.18 (bc3de30) into our 24 commits. The merge base is a259215, upstream's own commit, whose src/ tree is byte-identical to 68de575 -- our commits were replayed onto a259215 first so that git had a real base to work from. That produced 28 conflict hunks in 9 files instead of the 21 conflict events / 51 hunks a direct cherry-pick onto the tag would have forced, most of which were artifacts of lifting our commits away from their own predecessors. No consensus change. main.cpp's GetBlockValue, GetNextWorkRequired, ConnectInputs, ConnectBlock, CheckBlock, AcceptBlock and AcceptTransaction are untouched by this merge; script.cpp, serialize.h and randomx_pow.h are identical on both sides. The frozen-chain oracle reproduces the pinned UTXO root 116a2b6b... byte for byte, and the merged binary loads the same 12,586-block chain to the same tip aac622f0a3b2f8... with all blocks re-verified and none rejected. Which side won, per file and per hunk. src/rpc.cpp -- 15 hunks. 1. RebuildJob transaction selection. THEIRS for the multi-pass loop and the nFees fix, OURS for the cap. Their vfAlreadyAdded/fFoundSomething loop lands dependent transactions that our single pass dropped deterministically, and their nFees is accumulated across the scan instead of being redeclared inside it -- ours passed a hardcoded 0 to GetBlockValue, so every pool block built here discarded its fees. But their bound is MAX_SIZE/2 = 16 MB, and MAX_BLOCK_SIZE is 1 MB, so their template can exceed what their own CheckBlock accepts. Cap kept at MAX_BLOCK_SIZE/2, with our inner break so a single pass cannot overshoot by more than one transaction. Our RebuildJob sync gating and deaf-node detection are kept; upstream has none. 2. struct PendingPayout. OURS for the key, THEIRS for paidTxids. Their id is the string "block-<height>", which is height keying -- the exact bug 44849e6 fixed after a 280-block reorg left ~14.4k BTF owed against blocks that no longer existed. Height keying also lets the crash-recovery probe match the previous round's transaction after a reorg and silently skip a miner, so their id is dropped entirely and blockHash stays the key. paidTxids is taken because it is what makes an entry survivable. Their nextAttempt and our retryAfter are the same concept under two names; ours is kept and theirs dropped. matureAtHeight gained an explicit = 0, which neither side had in our style of initialiser. 3./4. Ledger writer and loader. Mechanical follow-ons of 2. The loader keeps ours and takes their tolerance for an entry with no usable height, guarded so it cannot fire on a well-formed entry. One redundancy was removed rather than merged: their unguarded `if (obj.contains("blockHeight")) get<int>()` sat two lines above our is_number-guarded version, and an unguarded get<int>() on a malformed value throws out of the whole loop, losing every entry in the file. Verified against the live production pending_payouts.json (2 entries, keys amounts/blockHash/blockHeight/matureAtHeight): loads both. A file in the new shape, with retryAfter and paidTxids, also loads. 5. Insertion point after the loader. BOTH. Our PayingRecord/gPaying in-flight ledger and their PoolRoundProof/PoolRoundToJson are disjoint features that landed at the same offset. Both kept, in full. 6. Writer tail. THEIRS, copied into our writer. Checking fclose() matters here because fclose is where the buffered write actually reaches the OS: renaming a short .tmp over a good file is precisely the truncation the atomic write exists to prevent. The Windows remove()-before-rename() is required because rename() there will not replace an existing file. 7. Loader bodies. BOTH -- LoadPaying and LoadPoolRounds are different functions that happened to share a common tail. 8. Function block, 111 vs 36 lines. BOTH, with one deliberate deletion. PayoutBlockStillOnChain, PayoutComment, RecordPoolRound, MarkPoolRoundPaid all kept. WalletAlreadyPaid is DELETED and ReconcilePayouts rewritten, because our reconciliation and their FindRecordedPoolPayout are two answers to the same crash-recovery question and running both would give the same debt two authorities. Their wallet probe wins: it matches structured metadata rather than a comment string, so it can tell two rounds paying the same address the same amount apart. ReconcilePayouts keeps only the job that is still real -- dropping in-flight records the ledger already shows paid, and arming each restored record for its one wallet probe. It no longer touches gPendingPayouts at all; under the new model dropping ledger entries there would reintroduce the loss this merge adopts their model to prevent. Their gPayoutExecMutex/gPayoutThreadRunning re-declarations were dropped as duplicates of ours, and gPayoutExecQueue was not taken: gPaying is the exec queue in this tree, at finer granularity. 9. QueuePayouts signature. THEIRS for the round-proof arguments, OURS for the type of blockHash. It stays a uint256 and is converted to hex only where the round proof needs text -- a ledger key that has been through ToString() is a key that can be compared wrong. 10. Maturity policy. OURS. matureAtHeight = blockHeight + COINBASE_SPENDABLE_DEPTH (120), not + COINBASE_MATURITY (100). At depth 100 the block's own coinbase is still 20 blocks short of spendable, so the payment must come from unrelated mature coin and the pool runs a permanent float. Our own history contradicts itself here -- d6972a0 argued for COINBASE_MATURITY citing "96% of 934 payments", 44849e6 reversed it on the grounds that the measurement was taken while the node was alone on a private fork paying itself. HEAD's position is kept. This is a considered disagreement with upstream, not an oversight. The live production ledger confirms it: both entries have matureAtHeight - blockHeight = 120. 11. Comment above FlushMaturePayouts. Rewritten to describe the merged design. 12. FlushMaturePayouts, the central difference. THEIRS for the model. The ledger entry now survives on disk until every recipient has a txid, instead of being erased as soon as its payments were handed off -- the old ordering left a window where the debt was described by nothing at all, and a container restart on 2026-07-30 silently dropped ~1,156 BTF through it. Our gPaying is kept on top as the in-flight working set, because it carries the per-recipient retry and deferral state a per-round nextAttempt cannot express. It is explicitly a cache derived from the ledger, not a second record of debt: if it were lost the next flush would rebuild it. 13. PayoutThreadFn, 79 vs 70 lines. OURS for the loop and the reorg re-check, THEIRS for the per-recipient dedupe. The re-check on every attempt is load-bearing and upstream has no equivalent -- it decides once, at maturity, and a payment can sit through hours of retries during which the reorg that kills its funding block can land. Their paidTxids check is taken, against the ledger rather than against the batch. Our deferral policy is kept: waiting for coin is not a failure and must not consume the retry budget. Two fixes upstream does not have are folded in here rather than deferred to a follow-up commit: - FindRecordedPoolPayout runs once per record, on its first attempt, not on every attempt. It is a linear scan of mapWallet under cs_mapWallet, per recipient; upstream repeats it for every retry, which with 2000 rotating payout addresses and a budget of 1440 retries is a full wallet scan per address per minute for a day. The question it answers can only change across a restart, and every restart re-arms the flag. - The ledger file write no longer happens under gPayoutLedgerMutex in the per-recipient loop. SavePendingPayoutsLocked was split into a serialise step that needs the lock and a write step that does not, so the mutex is held only long enough to read the structure. The orphan scan in FlushMaturePayouts was likewise restructured so the ledger mutex is not held across PayoutBlockStillOnChain's cs_main acquisition. One case the merged model created and neither side handled: under survives-until-fully-paid, our GIVING UP path dropped the in-flight record but left the recipient owed in the ledger forever, so every later flush would explode it again -- a forfeit that silently becomes an infinite retry. ForfeitPayoutRecipient writes the amount off so the entry can retire. 14. QueuePayouts call site. Follow-on of 9. 15. ThreadRPCServer startup. BOTH. LoadPoolRounds added; LoadPaying and ReconcilePayouts stay, and stay before the payout thread starts. Crash safety, stated as the property that must hold: after kill -9 at any point, restart pays each recipient exactly once. Before SendMoney, the ledger has no txid and the payment is retried. Between SendMoney and the ledger write, the wallet holds the transaction and the first-attempt probe finds it. After the ledger write, paidTxids suppresses it. If the in-flight file is lost entirely, the ledger rebuilds it and the probe still covers the middle window. src/nostr.cpp -- 3 hunks. THEIRS as the base, with two things restored from ours. Theirs loads the Windows ROOT certificate store, which ours lacks and genuinely needs, since OpenSSL's default verify paths are usually empty there and our version would fail closed. But the plan's claim that theirs is a superset of our 06bceb4 is FALSE: upstream's nostr.cpp contains no min_proto_version, no TLS1_2, no SSL_OP_NO_*, and no set_options anywhere, so taking it wholesale would have removed the TLS-version floor outright. Restored. Second, their SSL_get_verify_result check sits after a successful handshake, where with SSL_VERIFY_PEER set it is nearly dead code -- a bad certificate aborts inside SSL_connect. The reporting belongs in the failure branch, which is where the merge put it; their post-handshake duplicate was dropped. Our relay-list and socket-pooling hunks auto-merged and were verified intact. src/net.cpp -- 1 hunk. THEIRS. Their guard, hSocket == INVALID_SOCKET || pnode->hSocket != hSocket, subsumes ours and additionally catches descriptor recycling, which ours did not; our version also referenced a pnode that only existed in the select() loop. Our other db34011 hunk, nulling pnode->hSocket in the poll-failure branch, auto-merged and was NOT re-applied -- plan section 7 Batch D says to re-apply it, and that instruction is stale. Two pieces of residue the merge left behind were cleaned up: a comment still describing select() semantics, and a pnode->hSocket != INVALID_SOCKET guard made redundant by a continue two lines above it. src/main.cpp -- 2 hunks. h1 BOTH, pure add/add adjacency between our ParticipantHashThreadCount and their SecureClear forward declarations. h2 OURS, and their handler discarded: 44004e3 no-ops checkorder/submitorder but keeps market.cpp and market.h, whereas 11766be deletes the marketplace outright including CRequestTracker, subscribe/publish and mapProducts. Ours is strictly more. src/main_gui.cpp -- 2 hunks. BOTH, additive on both sides. Our /payoutaddr, /payoutfile and /hashthreads alongside their /stratumbridge and /stratumbridgeport. src/btfrv.cpp -- 1 hunk, not predicted by the plan. False adjacency: our INVALID_SOCKET check and their #endif landed at the same offset. Ordering is load-bearing -- our check goes inside the #else arm, before the #endif, or the non-proxy build silently loses it. The BITFLASH_NODE_BUILD arm already returns early, so both paths now have the check. Makefile -- 2 hunks plus a hazard with no conflict marker. THEIRS for both dependency-fetch recipes: checkout --detach FETCH_HEAD followed by test "$(git rev-parse HEAD)" = "$(..._COMMIT)" asserts the pin actually landed, where ours only checked it out. Everything of ours that they lack is kept: the appimagetool 1.9.1 release pin with a sha256 check, where upstream fetches the mutable "continuous" tag and executes it undigested, and the move of both the tool and the AppDir out of /tmp, where any local user could pre-place an executable and have it run as whoever runs make. The hazard: both sides added a pin block in a different place, so git merged both, outside any conflict region. SECP256K1_COMMIT and RANDOMX_COMMIT ended up assigned twice, near L16 and near L44, and GNU Make takes the last := -- a naive resolution would have silently built against our old secp256k1 pin while appearing to adopt upstream's. The duplicate block was deleted by hand. Each of the four variables is now assigned exactly once, and make -n resolves SECP256K1_COMMIT to 7fecac74 with 6e2c8bc4 appearing nowhere. Also applied here, from plan phase 4: SECP256K1_COMMIT moved to upstream's 7fecac74aed8e1fd9078380d67dd04663705c989 so signature validation matches the binaries the rest of the network runs. secp256k1 was rebuilt from scratch against the new pin on the build host and the checked-out dependency commit verified. RANDOMX_COMMIT already matched at 1e9d4b2d and is unchanged. src/Makefile, src/makefile.mingw -- 1 hunk each, both the HEADERS list. Ours drops market.h, theirs adds proxy.h; the resolution does both. Taking theirs wholesale would have left the Makefiles referencing a header git had already deleted in our favour, silently, without a conflict. Files the plan predicted would conflict and did not: net.h, main.h and db.cpp auto-merged. Verified rather than assumed. net.h carries both our BTF_MSG_WAIT_MAX_SECS work, including the constructor fix from 2d84982 that starts nMsgWaitSince and nMsgWaitSize at 0, and their comment rewrite with extern int nPeersWatched. The dead members that fix removed are gone from every file. Deviations from MERGE-1.2.18-PLAN.md. - Section 6's mechanism was not used. Replayed onto the real merge base and merged, rather than cherry-picking onto the tag. Same result, 28 hunks instead of 51. - Section 2 calls 09ff575 a superset of our 06bceb4. It is not; upstream has no TLS-version floor at all. Ours restored on top of theirs. - Section 7 Batch D says to re-apply our db34011 line into their poll-failure branch. It auto-merged; re-applying would have duplicated it. - Section 2 says do not take the stratum bridge. It is taken, and left off. fStratumBridge defaults false in main.cpp, the thread is only spawned when /stratumbridge is passed, and the listener binds 127.0.0.1 only. Excising it would cost real divergence and future conflict for no gain while it is disabled. Its defects -- select()/FD_SET on a node that can hold more than 1024 descriptors, and an uncapped thread per connection -- are real only when it is enabled and are being reported upstream separately. - Section 7's staged batches were not run as separate gated commits. The conflict surface is interleaved within single functions, chiefly the payout ledger, and splitting it would have meant committing states that do not build. The merge is one commit, as briefed. - Batch F's two fixes are folded into hunk 13 rather than deferred, because they change the same lines the resolution rewrites. - The plan did not anticipate the btfrv.cpp conflict, the duplicate pin block, the market.h trap in the two Makefiles, or the give-up/infinite-retry interaction created by adopting upstream's ledger model.
Diffstat (limited to 'scripts')
-rw-r--r--scripts/bitflash-mining-calculator.py206
-rw-r--r--scripts/bitflash_chain.py450
-rwxr-xr-xscripts/build-explorer.py336
-rw-r--r--scripts/build-pool-index.py174
-rw-r--r--scripts/prove-utxo.py186
-rw-r--r--scripts/verify-fair-launch.py166
-rwxr-xr-xscripts/verify-release.sh108
-rw-r--r--scripts/verify-utxo-proof.py146
-rw-r--r--scripts/verify-utxo-set.py131
9 files changed, 1853 insertions, 50 deletions
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 @@
1#!/usr/bin/env python3
2"""Estimate Bitflash mining rewards and electricity cost.
3
4This is a local WhatToMine-style calculator for Bitflash. It does not fetch
5prices, does not talk to the node, and does not affect consensus.
6"""
7
8import argparse
9import json
10import math
11import sys
12
13
14HASHES_AT_DIFF_1 = 4096.0
15DEFAULT_BLOCK_TIME = 120.0
16DEFAULT_BLOCK_REWARD = 50.0
17
18
19UNITS = {
20 "h": 1.0,
21 "h/s": 1.0,
22 "hs": 1.0,
23 "kh": 1_000.0,
24 "kh/s": 1_000.0,
25 "khs": 1_000.0,
26 "mh": 1_000_000.0,
27 "mh/s": 1_000_000.0,
28 "mhs": 1_000_000.0,
29 "gh": 1_000_000_000.0,
30 "gh/s": 1_000_000_000.0,
31 "ghs": 1_000_000_000.0,
32}
33
34
35def parse_hashrate(value, unit):
36 try:
37 n = float(value)
38 except ValueError:
39 raise SystemExit("invalid hashrate: %s" % value)
40 mul = UNITS.get(unit.lower())
41 if mul is None:
42 raise SystemExit("unknown hashrate unit: %s" % unit)
43 if n < 0:
44 raise SystemExit("hashrate must not be negative")
45 return n * mul
46
47
48def fmt_hashrate(hps):
49 units = [("GH/s", 1_000_000_000.0), ("MH/s", 1_000_000.0), ("kH/s", 1_000.0), ("H/s", 1.0)]
50 for name, div in units:
51 if hps >= div:
52 return "%.4g %s" % (hps / div, name)
53 return "%.4g H/s" % hps
54
55
56def fmt_money(x):
57 return "%.8f" % x
58
59
60def estimate(args):
61 miner_hps = parse_hashrate(args.hashrate, args.hashrate_unit)
62 if args.network_hashrate is not None:
63 network_hps = parse_hashrate(args.network_hashrate, args.network_hashrate_unit)
64 difficulty = network_hps * args.block_time / HASHES_AT_DIFF_1
65 network_source = "network_hashrate"
66 else:
67 if args.difficulty is None:
68 raise SystemExit("provide either --network-hashrate or --difficulty")
69 difficulty = float(args.difficulty)
70 if difficulty <= 0:
71 raise SystemExit("difficulty must be positive")
72 network_hps = difficulty * HASHES_AT_DIFF_1 / args.block_time
73 network_source = "difficulty"
74
75 if network_hps <= 0:
76 raise SystemExit("network hashrate must be positive")
77
78 block_reward = float(args.block_reward)
79 block_time = float(args.block_time)
80 pool_fee_percent = float(args.pool_fee)
81 price = float(args.price)
82 watts = float(args.watts)
83 kwh_cost = float(args.kwh_cost)
84 if block_reward < 0 or block_time <= 0 or pool_fee_percent < 0 or price < 0 or watts < 0 or kwh_cost < 0:
85 raise SystemExit("numeric inputs must be non-negative; block time must be positive")
86
87 share = miner_hps / network_hps
88 blocks_per_day = 86400.0 / block_time
89 gross_btf_day = blocks_per_day * block_reward * share
90 net_btf_day = gross_btf_day * max(0.0, 1.0 - pool_fee_percent / 100.0)
91 revenue_day = net_btf_day * price
92 cost_day = watts / 1000.0 * 24.0 * kwh_cost
93 profit_day = revenue_day - cost_day
94 expected_seconds_per_block = math.inf if miner_hps <= 0 else difficulty * HASHES_AT_DIFF_1 / miner_hps
95
96 periods = {
97 "hour": 1.0 / 24.0,
98 "day": 1.0,
99 "week": 7.0,
100 "month_30d": 30.0,
101 }
102 rows = {}
103 for name, days in periods.items():
104 rows[name] = {
105 "btf": net_btf_day * days,
106 "revenue": revenue_day * days,
107 "electricity_cost": cost_day * days,
108 "profit": profit_day * days,
109 }
110
111 return {
112 "schema": "bitflash-mining-calculator-1",
113 "network_source": network_source,
114 "inputs": {
115 "miner_hashrate_hps": miner_hps,
116 "miner_hashrate": fmt_hashrate(miner_hps),
117 "network_hashrate_hps": network_hps,
118 "network_hashrate": fmt_hashrate(network_hps),
119 "difficulty": difficulty,
120 "block_time_seconds": block_time,
121 "block_reward_btf": block_reward,
122 "pool_fee_percent": pool_fee_percent,
123 "price_per_btf": price,
124 "power_watts": watts,
125 "electricity_per_kwh": kwh_cost,
126 },
127 "estimates": {
128 "miner_network_share": share,
129 "blocks_per_day": blocks_per_day,
130 "expected_time_to_solo_block_seconds": expected_seconds_per_block,
131 "expected_time_to_solo_block_hours": expected_seconds_per_block / 3600.0,
132 "gross_btf_per_day": gross_btf_day,
133 "net_btf_per_day": net_btf_day,
134 "periods": rows,
135 },
136 }
137
138
139def print_text(result):
140 i = result["inputs"]
141 e = result["estimates"]
142 print("Bitflash mining calculator")
143 print("schema: %s" % result["schema"])
144 print()
145 print("Network")
146 print(" source: %s" % result["network_source"])
147 print(" hashrate: %s" % i["network_hashrate"])
148 print(" difficulty: %.8f" % i["difficulty"])
149 print(" block time: %.0fs" % i["block_time_seconds"])
150 print(" reward: %s BTF" % fmt_money(i["block_reward_btf"]))
151 print()
152 print("Miner")
153 print(" hashrate: %s" % i["miner_hashrate"])
154 print(" share: %.8f%%" % (e["miner_network_share"] * 100.0))
155 if math.isinf(e["expected_time_to_solo_block_seconds"]):
156 print(" solo block: never")
157 else:
158 print(" solo block: %.2f hours expected average" % e["expected_time_to_solo_block_hours"])
159 print()
160 print("Economics")
161 print(" price: $%.8f / BTF" % i["price_per_btf"])
162 print(" power: %.2f W" % i["power_watts"])
163 print(" kWh cost: $%.4f" % i["electricity_per_kwh"])
164 print(" pool fee: %.2f%%" % i["pool_fee_percent"])
165 print()
166 print("Estimated rewards")
167 print(" %-9s %16s %12s %12s %12s" % ("period", "BTF", "revenue", "power", "profit"))
168 for name in ["hour", "day", "week", "month_30d"]:
169 row = e["periods"][name]
170 print(" %-9s %16s $%11.4f $%11.4f $%11.4f" % (
171 name,
172 fmt_money(row["btf"]),
173 row["revenue"],
174 row["electricity_cost"],
175 row["profit"],
176 ))
177 print()
178 print("Note: this is an expectation, not a promise. Solo mining variance can be huge.")
179
180
181def main(argv):
182 ap = argparse.ArgumentParser(description=__doc__)
183 ap.add_argument("--hashrate", required=True, help="miner hashrate number")
184 ap.add_argument("--hashrate-unit", default="kh/s", choices=sorted(UNITS), help="miner hashrate unit")
185 ap.add_argument("--network-hashrate", help="network hashrate number")
186 ap.add_argument("--network-hashrate-unit", default="kh/s", choices=sorted(UNITS), help="network hashrate unit")
187 ap.add_argument("--difficulty", type=float, help="Bitflash difficulty; diff 1 expects 4096 hashes")
188 ap.add_argument("--block-time", type=float, default=DEFAULT_BLOCK_TIME, help="target block time in seconds")
189 ap.add_argument("--block-reward", type=float, default=DEFAULT_BLOCK_REWARD, help="block reward in BTF")
190 ap.add_argument("--pool-fee", type=float, default=0.0, help="pool fee percent")
191 ap.add_argument("--price", type=float, default=0.0, help="price per BTF in USD or your chosen fiat unit")
192 ap.add_argument("--watts", type=float, default=0.0, help="miner or whole-system power in watts")
193 ap.add_argument("--kwh-cost", type=float, default=0.0, help="electricity cost per kWh")
194 ap.add_argument("--json", action="store_true", help="print JSON")
195 args = ap.parse_args(argv)
196
197 result = estimate(args)
198 if args.json:
199 print(json.dumps(result, sort_keys=True, indent=2))
200 else:
201 print_text(result)
202 return 0
203
204
205if __name__ == "__main__":
206 raise SystemExit(main(sys.argv[1:]))
diff --git a/scripts/bitflash_chain.py b/scripts/bitflash_chain.py
new file mode 100644
index 0000000..4a3231c
--- /dev/null
+++ b/scripts/bitflash_chain.py
@@ -0,0 +1,450 @@
1#!/usr/bin/env python3
2"""Shared Bitflash block/chain parser for deterministic audit tools."""
3
4import datetime as _dt
5import hashlib
6import json
7import mmap
8import os
9import struct
10from pathlib import Path
11
12
13MAGIC = bytes([0xBF, 0x20, 0x5C, 0xFD])
14MAX_BLOCK_PAYLOAD = 32 * 1024 * 1024
15UTXO_LEAF_DOMAIN = "BTFUTXO1"
16UTXO_NODE_DOMAIN_TAG = "BTFNODE1"
17UTXO_NODE_DOMAIN = (UTXO_NODE_DOMAIN_TAG + "|").encode("ascii")
18COIN = 100_000_000
19GENESIS_HASH = "5bd7cb255d814e48cebcdfb72da4dc87b34bd774227f8ceb546c5640f4bdc169"
20GENESIS_MERKLE = "1a45b4482532abb29b10e234d3f13132230525a339ecea91658ffa675a5b1325"
21GENESIS_TIME = 1_753_315_200
22GENESIS_BITS = 0x1F0FFFFF
23GENESIS_NONCE = 3141
24GENESIS_TEXT = "Bitflash 24/Jul/2026 Fair launch: one CPU one vote, no premine"
25
26
27class ParseError(Exception):
28 pass
29
30
31class Reader:
32 def __init__(self, data):
33 self.data = data
34 self.pos = 0
35
36 def take(self, n):
37 if self.pos + n > len(self.data):
38 raise ParseError("unexpected end of data")
39 out = self.data[self.pos:self.pos + n]
40 self.pos += n
41 return out
42
43 def u8(self):
44 return self.take(1)[0]
45
46 def u32(self):
47 return struct.unpack("<I", self.take(4))[0]
48
49 def i32(self):
50 return struct.unpack("<i", self.take(4))[0]
51
52 def i64(self):
53 return struct.unpack("<q", self.take(8))[0]
54
55
56def sha256(data):
57 return hashlib.sha256(data).digest()
58
59
60def sha256d(data):
61 return hashlib.sha256(hashlib.sha256(data).digest()).digest()
62
63
64def hash_hex(serialized):
65 return sha256d(serialized)[::-1].hex()
66
67
68def compact_size(r):
69 first = r.u8()
70 if first < 253:
71 return first
72 if first == 253:
73 return struct.unpack("<H", r.take(2))[0]
74 if first == 254:
75 return r.u32()
76 return struct.unpack("<Q", r.take(8))[0]
77
78
79def read_varbytes(r):
80 return r.take(compact_size(r))
81
82
83def ser_compact_size(n):
84 if n < 253:
85 return bytes([n])
86 if n <= 0xFFFF:
87 return b"\xfd" + struct.pack("<H", n)
88 if n <= 0xFFFFFFFF:
89 return b"\xfe" + struct.pack("<I", n)
90 return b"\xff" + struct.pack("<Q", n)
91
92
93def ser_varbytes(b):
94 return ser_compact_size(len(b)) + b
95
96
97def ser_tx(tx):
98 out = bytearray()
99 out += struct.pack("<i", tx["version"])
100 out += ser_compact_size(len(tx["vin"]))
101 for txin in tx["vin"]:
102 out += bytes.fromhex(txin["prevout_hash"])[::-1]
103 out += struct.pack("<I", txin["prevout_n"])
104 out += ser_varbytes(bytes.fromhex(txin["script_sig_hex"]))
105 out += struct.pack("<I", txin["sequence"])
106 out += ser_compact_size(len(tx["vout"]))
107 for txout in tx["vout"]:
108 out += struct.pack("<q", txout["value_satoshis"])
109 out += ser_varbytes(bytes.fromhex(txout["script_pub_key_hex"]))
110 out += struct.pack("<I", tx["lock_time"])
111 return bytes(out)
112
113
114def parse_tx(r):
115 start = r.pos
116 tx = {"version": r.i32(), "vin": [], "vout": [], "lock_time": 0}
117 for _ in range(compact_size(r)):
118 tx["vin"].append({
119 "prevout_hash": r.take(32)[::-1].hex(),
120 "prevout_n": r.u32(),
121 "script_sig_hex": read_varbytes(r).hex(),
122 "sequence": r.u32(),
123 })
124 for _ in range(compact_size(r)):
125 value = r.i64()
126 tx["vout"].append({
127 "value_satoshis": value,
128 "value_btf": format_money(value),
129 "script_pub_key_hex": read_varbytes(r).hex(),
130 })
131 tx["lock_time"] = r.u32()
132 raw = r.data[start:r.pos]
133 tx["txid"] = hash_hex(raw)
134 return tx
135
136
137def merkle_root(txids):
138 if not txids:
139 return "0" * 64
140 layer = [bytes.fromhex(x)[::-1] for x in txids]
141 while len(layer) > 1:
142 nxt = []
143 for i in range(0, len(layer), 2):
144 left = layer[i]
145 right = layer[i + 1] if i + 1 < len(layer) else left
146 nxt.append(sha256d(left + right))
147 layer = nxt
148 return layer[0][::-1].hex()
149
150
151def parse_block(payload, file_name="", offset=0):
152 r = Reader(payload)
153 header = r.take(80)
154 version, = struct.unpack("<i", header[0:4])
155 prev = header[4:36][::-1].hex()
156 merkle = header[36:68][::-1].hex()
157 ntime, bits, nonce = struct.unpack("<III", header[68:80])
158 txs = [parse_tx(r) for _ in range(compact_size(r))]
159 if r.pos != len(payload):
160 raise ParseError("trailing bytes inside block")
161 txids = [tx["txid"] for tx in txs]
162 return {
163 "hash": hash_hex(header),
164 "version": version,
165 "previous_hash": prev,
166 "merkle_root": merkle,
167 "computed_merkle_root": merkle_root(txids),
168 "time": ntime,
169 "time_utc": utc(ntime),
170 "bits": "0x%08x" % bits,
171 "nonce": nonce,
172 "tx_count": len(txs),
173 "transactions": txs,
174 "source_file": file_name,
175 "source_offset": offset,
176 "size": len(payload),
177 }
178
179
180def iter_block_files(datadir):
181 for path in sorted(Path(datadir).glob("blk*.dat")):
182 yield path
183
184
185def iter_blocks_from_file(path):
186 path = Path(path)
187 with path.open("rb") as f:
188 f.seek(0, os.SEEK_END)
189 file_size = f.tell()
190 if file_size == 0:
191 return
192 f.seek(0)
193 mm = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
194 try:
195 pos = 0
196 while True:
197 offset = mm.find(MAGIC, pos)
198 if offset < 0 or offset + 8 > file_size:
199 break
200 size = struct.unpack("<I", mm[offset + 4:offset + 8])[0]
201 payload_offset = offset + 8
202 end = payload_offset + size
203 if size == 0 or size > MAX_BLOCK_PAYLOAD or end > file_size:
204 pos = offset + 1
205 continue
206 payload = mm[payload_offset:end]
207 try:
208 yield parse_block(payload, path.name, payload_offset)
209 except ParseError:
210 pos = offset + 1
211 continue
212 pos = end
213 finally:
214 mm.close()
215
216
217def read_blocks_from_files(paths, max_blocks=0):
218 blocks = []
219 for path in paths:
220 for block in iter_blocks_from_file(path):
221 blocks.append(block)
222 if max_blocks and len(blocks) >= max_blocks:
223 return blocks
224 return blocks
225
226
227def read_blocks(datadir, max_blocks=0):
228 return read_blocks_from_files(iter_block_files(datadir), max_blocks)
229
230
231def select_main_chain(blocks):
232 by_hash = {}
233 for block in blocks:
234 by_hash.setdefault(block["hash"], block)
235 if GENESIS_HASH not in by_hash:
236 raise ParseError("genesis block not found in block files")
237
238 children = {}
239 for block in by_hash.values():
240 children.setdefault(block["previous_hash"], []).append(block)
241 for v in children.values():
242 v.sort(key=lambda b: (b["hash"], b["source_file"], b["source_offset"]))
243
244 best = []
245 stack = [(by_hash[GENESIS_HASH], [by_hash[GENESIS_HASH]])]
246 while stack:
247 block, path = stack.pop()
248 if len(path) > len(best) or (len(path) == len(best) and block["hash"] < best[-1]["hash"]):
249 best = path
250 for child in children.get(block["hash"], []):
251 stack.append((child, path + [child]))
252 return best
253
254
255def is_coinbase(tx):
256 return (
257 len(tx["vin"]) == 1
258 and tx["vin"][0]["prevout_hash"] == "0" * 64
259 and tx["vin"][0]["prevout_n"] == 0xFFFFFFFF
260 )
261
262
263def utxo_leaf_hash(outpoint, utxo):
264 payload = "|".join([
265 UTXO_LEAF_DOMAIN,
266 outpoint,
267 str(utxo["value_satoshis"]),
268 utxo["script_pub_key_hex"],
269 str(utxo["created_height"]),
270 utxo["created_txid"],
271 ]).encode("ascii")
272 return sha256(payload)
273
274
275def utxo_root(utxos):
276 leaves = [utxo_leaf_hash(k, utxos[k]) for k in sorted(utxos)]
277 if not leaves:
278 return sha256(b"BTFUTXO1|empty").hex()
279 layer = leaves
280 while len(layer) > 1:
281 nxt = []
282 for i in range(0, len(layer), 2):
283 left = layer[i]
284 right = layer[i + 1] if i + 1 < len(layer) else left
285 nxt.append(sha256(UTXO_NODE_DOMAIN + left + right))
286 layer = nxt
287 return layer[0].hex()
288
289
290def apply_blocks(blocks, strict_duplicates=False):
291 utxos = {}
292 spent = 0
293 missing_spends = []
294 duplicate_outputs = []
295 total_coinbase = 0
296 total_fees = 0
297 normal_txs = 0
298
299 for height, block in enumerate(blocks):
300 if block["computed_merkle_root"] != block["merkle_root"]:
301 raise ParseError("bad merkle root at height %d" % height)
302 for tx in block["transactions"]:
303 coinbase = is_coinbase(tx)
304 input_value = 0
305 output_value = sum(out["value_satoshis"] for out in tx["vout"])
306 if coinbase:
307 total_coinbase += output_value
308 else:
309 normal_txs += 1
310 for txin in tx["vin"]:
311 prev = "%s:%d" % (txin["prevout_hash"], txin["prevout_n"])
312 prev_utxo = utxos.pop(prev, None)
313 if prev_utxo is None:
314 missing_spends.append({
315 "height": height,
316 "txid": tx["txid"],
317 "missing_outpoint": prev,
318 })
319 else:
320 spent += prev_utxo["value_satoshis"]
321 input_value += prev_utxo["value_satoshis"]
322 if input_value >= output_value:
323 total_fees += input_value - output_value
324 for n, out in enumerate(tx["vout"]):
325 key = "%s:%d" % (tx["txid"], n)
326 if key in utxos:
327 if strict_duplicates:
328 raise ParseError("duplicate output at height %d: %s" % (height, key))
329 duplicate_outputs.append({"height": height, "outpoint": key})
330 utxos[key] = {
331 "txid": tx["txid"],
332 "vout": n,
333 "value_satoshis": out["value_satoshis"],
334 "value_btf": out["value_btf"],
335 "script_pub_key_hex": out["script_pub_key_hex"],
336 "created_height": height,
337 "created_block_hash": block["hash"],
338 "created_txid": tx["txid"],
339 "coinbase": coinbase,
340 }
341
342 total_unspent = sum(u["value_satoshis"] for u in utxos.values())
343 return {
344 "utxos": utxos,
345 "stats": {
346 "utxo_count": len(utxos),
347 "spent_outputs": spent,
348 "total_unspent_satoshis": total_unspent,
349 "total_unspent_btf": format_money(total_unspent),
350 "total_coinbase_satoshis": total_coinbase,
351 "total_coinbase_btf": format_money(total_coinbase),
352 "coinbase_minus_unspent_satoshis": total_coinbase - total_unspent,
353 "coinbase_minus_unspent_btf": format_money(total_coinbase - total_unspent),
354 "total_fees_satoshis": total_fees,
355 "total_fees_btf": format_money(total_fees),
356 "normal_transactions": normal_txs,
357 "missing_spends": missing_spends,
358 "duplicate_outputs": duplicate_outputs,
359 },
360 }
361
362
363def genesis_transaction():
364 script_sig = (
365 bytes.fromhex("04ffff001d")
366 + b"\x01\x04"
367 + bytes([len(GENESIS_TEXT)])
368 + GENESIS_TEXT.encode("ascii")
369 )
370 pubkey_num = bytes.fromhex(
371 "5f1df16b2b704c8a578d0bbaf74d385cde12c11ee50455f3c438ef4c3fbcf649"
372 "b6de611feae06279a60939e028a8d65c10b73071a6f16719274855feb0fd8a6704"
373 )
374 script_pub_key = bytes([65]) + pubkey_num[::-1] + b"\xac"
375 tx = {
376 "version": 1,
377 "vin": [{
378 "prevout_hash": "0" * 64,
379 "prevout_n": 0xFFFFFFFF,
380 "script_sig_hex": script_sig.hex(),
381 "sequence": 0xFFFFFFFF,
382 }],
383 "vout": [{
384 "value_satoshis": 50 * COIN,
385 "value_btf": format_money(50 * COIN),
386 "script_pub_key_hex": script_pub_key.hex(),
387 }],
388 "lock_time": 0,
389 }
390 tx["txid"] = hash_hex(ser_tx(tx))
391 return tx
392
393
394def reconstructed_genesis():
395 tx = genesis_transaction()
396 header = (
397 struct.pack("<i", 1)
398 + bytes(32)
399 + bytes.fromhex(tx["txid"])[::-1]
400 + struct.pack("<III", GENESIS_TIME, GENESIS_BITS, GENESIS_NONCE)
401 )
402 return {
403 "hash": hash_hex(header),
404 "version": 1,
405 "previous_hash": "0" * 64,
406 "merkle_root": tx["txid"],
407 "computed_merkle_root": tx["txid"],
408 "time": GENESIS_TIME,
409 "time_utc": utc(GENESIS_TIME),
410 "bits": "0x%08x" % GENESIS_BITS,
411 "nonce": GENESIS_NONCE,
412 "tx_count": 1,
413 "transactions": [tx],
414 "source_file": "reconstructed-from-source-constants",
415 "source_offset": 0,
416 "size": 80 + len(ser_compact_size(1)) + len(ser_tx(tx)),
417 }
418
419
420def ser_block(block):
421 header = (
422 struct.pack("<i", block["version"])
423 + bytes.fromhex(block["previous_hash"])[::-1]
424 + bytes.fromhex(block["merkle_root"])[::-1]
425 + struct.pack("<III", block["time"], int(block["bits"], 16), block["nonce"])
426 )
427 txs = block["transactions"]
428 return header + ser_compact_size(len(txs)) + b"".join(ser_tx(tx) for tx in txs)
429
430
431def format_money(value):
432 sign = "-" if value < 0 else ""
433 value = abs(value)
434 return "%s%d.%08d" % (sign, value // COIN, value % COIN)
435
436
437def utc(ts):
438 return _dt.datetime.fromtimestamp(ts, _dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
439
440
441def default_datadir():
442 if os.name == "nt" and os.environ.get("APPDATA"):
443 return os.path.join(os.environ["APPDATA"], "Bitflash")
444 if os.environ.get("HOME"):
445 return os.path.join(os.environ["HOME"], ".bitflash")
446 return "."
447
448
449def canonical_bytes(report):
450 return (json.dumps(report, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + "\n").encode("ascii")
diff --git a/scripts/build-explorer.py b/scripts/build-explorer.py
new file mode 100755
index 0000000..97a7f4e
--- /dev/null
+++ b/scripts/build-explorer.py
@@ -0,0 +1,336 @@
1#!/usr/bin/env python3
2"""Build a static Bitflash block explorer from local blk*.dat files."""
3
4import argparse
5import hashlib
6import json
7import sys
8from pathlib import Path
9
10import bitflash_chain as chain
11
12
13ADDRESS_VERSION = 25
14B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
15
16
17def b58check(payload):
18 v = bytes([ADDRESS_VERSION]) + payload
19 v += chain.sha256d(v)[:4]
20 n = int.from_bytes(v, "big")
21 s = ""
22 while n > 0:
23 n, r = divmod(n, 58)
24 s = B58[r] + s
25 for byte in v:
26 if byte == 0:
27 s = "1" + s
28 else:
29 break
30 return s
31
32
33def hash160(b):
34 return hashlib.new("ripemd160", hashlib.sha256(b).digest()).digest()
35
36
37def spk_info(script_hex):
38 try:
39 spk = bytes.fromhex(script_hex)
40 except ValueError:
41 return {"type": "malformed", "hex": script_hex[:80]}
42 if (
43 len(spk) == 25 and spk[0] == 0x76 and spk[1] == 0xA9
44 and spk[2] == 0x14 and spk[23] == 0x88 and spk[24] == 0xAC
45 ):
46 return {"type": "p2pkh", "address": b58check(spk[3:23])}
47 if len(spk) >= 35 and spk[-1] == 0xAC:
48 if spk[0] == 0x41 and len(spk) == 67:
49 pk = spk[1:66]
50 return {"type": "p2pk", "pubkey": pk.hex(), "address": b58check(hash160(pk))}
51 if spk[0] == 0x21 and len(spk) == 35:
52 pk = spk[1:34]
53 return {"type": "p2pk", "pubkey": pk.hex(), "address": b58check(hash160(pk))}
54 return {"type": "other", "hex": script_hex[:80]}
55
56
57def explorer_tx(tx):
58 vin = []
59 for txin in tx["vin"]:
60 coinbase = (
61 txin["prevout_hash"] == "0" * 64
62 and txin["prevout_n"] == 0xFFFFFFFF
63 )
64 vin.append({
65 "coinbase": coinbase,
66 "prev": None if coinbase else txin["prevout_hash"],
67 "vout": None if coinbase else txin["prevout_n"],
68 })
69 vout = []
70 for out in tx["vout"]:
71 vout.append(dict({"value": out["value_satoshis"]}, **spk_info(out["script_pub_key_hex"])))
72 return {"txid": tx["txid"], "vin": vin, "vout": vout, "size": len(chain.ser_tx(tx))}
73
74
75def parse_args(argv):
76 ap = argparse.ArgumentParser(description=__doc__)
77 ap.add_argument("--datadir", help="Bitflash data directory containing blk*.dat")
78 ap.add_argument("--max-blocks", type=int, default=0,
79 help="number of blocks to scan; 0 means all block files")
80 ap.add_argument("paths", nargs="+",
81 help="with --datadir: <out-dir>; otherwise: <blk*.dat>... <out-dir>")
82 args = ap.parse_args(argv)
83 if args.datadir:
84 if len(args.paths) != 1:
85 raise SystemExit("usage with --datadir: build-explorer.py --datadir DATADIR <out-dir>")
86 args.block_files = None
87 args.out_dir = args.paths[0]
88 else:
89 if len(args.paths) < 2:
90 raise SystemExit("usage: build-explorer.py <blk*.dat>... <out-dir>")
91 args.block_files = args.paths[:-1]
92 args.out_dir = args.paths[-1]
93 return args
94
95
96def load_blocks(args):
97 if args.datadir:
98 return chain.read_blocks(args.datadir, args.max_blocks)
99 return chain.read_blocks_from_files(args.block_files, args.max_blocks)
100
101
102def build_explorer(raw_blocks, out_dir):
103 try:
104 blocks = chain.select_main_chain(raw_blocks)
105 except chain.ParseError as e:
106 raise SystemExit(str(e))
107 out = Path(out_dir)
108 (out / "block").mkdir(parents=True, exist_ok=True)
109
110 summaries = []
111 for height, block in enumerate(blocks):
112 txs = [explorer_tx(tx) for tx in block["transactions"]]
113 detail = {
114 "height": height,
115 "hash": block["hash"],
116 "prev": block["previous_hash"],
117 "merkleRoot": block["merkle_root"],
118 "version": block["version"],
119 "time": block["time"],
120 "bits": int(block["bits"], 16),
121 "nonce": block["nonce"],
122 "size": block["size"],
123 "txs": txs,
124 }
125 cb = txs[0]["vout"][0] if txs and txs[0]["vout"] else {}
126 summaries.append({
127 "height": height,
128 "hash": block["hash"],
129 "time": block["time"],
130 "nTx": len(txs),
131 "size": block["size"],
132 "cbValue": cb.get("value", 0),
133 "cbAddr": cb.get("address", ""),
134 })
135 (out / "block" / ("%d.json" % height)).write_text(
136 json.dumps(detail, sort_keys=True, separators=(",", ":")) + "\n",
137 encoding="ascii",
138 )
139
140 summaries.sort(key=lambda x: -x["height"])
141 (out / "blocks.json").write_text(
142 json.dumps({
143 "network": "bitflash",
144 "tipHeight": len(blocks) - 1,
145 "count": len(blocks),
146 "blocks": summaries,
147 }, sort_keys=True, separators=(",", ":")) + "\n",
148 encoding="ascii",
149 )
150 (out / "index.html").write_text(INDEX_HTML, encoding="utf-8")
151 return len(blocks), len(blocks) - 1
152
153
154def main(argv):
155 args = parse_args(argv)
156 raw_blocks = load_blocks(args)
157 if not raw_blocks:
158 raise SystemExit("no Bitflash blocks parsed")
159 count, tip = build_explorer(raw_blocks, args.out_dir)
160 print("explorer built: %d main-chain blocks, tip %d, into %s" % (count, tip, args.out_dir))
161 return 0
162
163
164INDEX_HTML = r"""<!doctype html>
165<html lang="en">
166<head>
167<meta charset="utf-8">
168<meta name="viewport" content="width=device-width, initial-scale=1">
169<title>Bitflash Block Explorer</title>
170<style>
171 :root { color-scheme: light dark; --bg:#fff; --fg:#111; --mut:#666; --line:#e2e2e2; --card:#fafafa; --acc:#2b6cb0; }
172 @media (prefers-color-scheme: dark){ :root{ --bg:#0e0f11; --fg:#e6e6e6; --mut:#9aa0a6; --line:#26282c; --card:#16181b; --acc:#63b3ed; } }
173 * { box-sizing: border-box; }
174 body { font: 15px/1.5 system-ui, sans-serif; margin:0; background:var(--bg); color:var(--fg); }
175 header { padding:20px 24px; border-bottom:1px solid var(--line); display:flex; gap:16px; align-items:baseline; flex-wrap:wrap; }
176 h1 { font-size:18px; margin:0; }
177 h2 { font-size:16px; }
178 .mut { color:var(--mut); }
179 .wrap { padding:16px 24px; max-width:1100px; margin:0 auto; }
180 input { width:100%; max-width:420px; padding:9px 12px; border:1px solid var(--line); border-radius:8px; background:var(--card); color:var(--fg); font-size:14px; }
181 table { width:100%; border-collapse:collapse; margin-top:12px; }
182 th,td { text-align:left; padding:9px 10px; border-bottom:1px solid var(--line); font-variant-numeric:tabular-nums; }
183 th { font-size:12px; text-transform:uppercase; letter-spacing:.05em; color:var(--mut); }
184 tr.blk { cursor:pointer; }
185 tr.blk:hover td { background:var(--card); }
186 code { font-size:12px; word-break:break-all; }
187 .scroll { overflow-x:auto; }
188 .card { border:1px solid var(--line); border-radius:10px; background:var(--card); padding:14px 16px; margin-top:12px; }
189 .kv { display:grid; grid-template-columns:150px 1fr; gap:4px 12px; font-size:13px; }
190 .kv div:nth-child(odd){ color:var(--mut); }
191 .tx { border-top:1px solid var(--line); padding:10px 0; }
192 .io { display:grid; grid-template-columns:1fr 1fr; gap:16px; }
193 @media (max-width:640px){ .io{ grid-template-columns:1fr; } .kv{ grid-template-columns:110px 1fr; } }
194 .pill { display:inline-block; font-size:11px; padding:1px 7px; border-radius:99px; border:1px solid var(--line); color:var(--mut); }
195 button.link { background:none; border:none; color:var(--acc); cursor:pointer; font:inherit; padding:0; }
196 .note { color:var(--mut); font-size:13px; max-width:820px; }
197</style>
198</head>
199<body>
200<header>
201 <h1>Bitflash Block Explorer</h1>
202 <span class="mut" id="tip"></span>
203 <span style="flex:1"></span>
204 <button class="link" id="home" style="display:none">back to blocks</button>
205</header>
206<div class="wrap">
207 <p class="note">Read-only view of the main chain: blocks, their transactions, and the coinbase payout. There is no mempool and no per-address history here.</p>
208 <div id="listview">
209 <input id="q" placeholder="Search by height or block hash">
210 <div class="scroll"><table>
211 <thead><tr><th>Height</th><th>Time (UTC)</th><th>Txs</th><th>Size</th><th>Coinbase</th><th>To</th></tr></thead>
212 <tbody id="rows"></tbody>
213 </table></div>
214 <p class="mut" id="more"></p>
215 </div>
216 <div id="detail" style="display:none"></div>
217</div>
218<script>
219var SUM = [], SHOWN = 200;
220function btf(sat){ return (sat/1e8).toFixed(8).replace(/0+$/,'').replace(/\.$/,'')+" BTF"; }
221function ts(t){ return new Date(t*1000).toISOString().replace('T',' ').replace('.000Z',' UTC'); }
222function short(h){ return h ? h.slice(0,10)+"..."+h.slice(-6) : "-"; }
223function E(tag, cls, text){
224 var e = document.createElement(tag);
225 if(cls) e.className = cls;
226 if(text !== undefined) e.textContent = text;
227 return e;
228}
229function code(text){ return E('code', '', text); }
230function clear(e){ while(e.firstChild) e.removeChild(e.firstChild); }
231function append(parent){
232 for(var i=1;i<arguments.length;i++) parent.appendChild(arguments[i]);
233 return parent;
234}
235function row(cells, cls){
236 var tr = E('tr', cls || '');
237 cells.forEach(function(c){ append(tr, E('td', '', c)); });
238 return tr;
239}
240function renderList(filter){
241 var q=(filter||"").trim().toLowerCase(), rows=document.getElementById('rows');
242 clear(rows);
243 var list = q ? SUM.filter(function(b){ return String(b.height)===q || b.hash.indexOf(q)===0; }) : SUM.slice(0,SHOWN);
244 if(!list.length){
245 var empty = E('tr');
246 var td = E('td', 'mut', 'no match');
247 td.colSpan = 6;
248 append(empty, td); append(rows, empty);
249 }
250 list.forEach(function(b){
251 var tr = row([String(b.height), ts(b.time), String(b.nTx), b.size+' B', btf(b.cbValue), ''], 'blk');
252 tr.dataset.h = b.height;
253 clear(tr.children[5]);
254 append(tr.children[5], code(b.cbAddr || '-'));
255 tr.onclick = function(){ openBlock(tr.dataset.h); };
256 append(rows, tr);
257 });
258 document.getElementById('more').textContent = (!q && SUM.length>SHOWN) ? ("showing latest "+SHOWN+" of "+SUM.length+" blocks; search to find any") : "";
259}
260function kv(parent, key, value, isCode){
261 append(parent, E('div', '', key));
262 append(parent, isCode ? append(E('div'), code(value)) : E('div', '', value));
263}
264function ioSide(title, items, render){
265 var side = E('div');
266 append(side, E('div', 'mut', title));
267 items.forEach(function(item){ append(side, render(item)); });
268 return side;
269}
270function openBlock(h){
271 fetch('block/'+h+'.json').then(function(r){return r.json();}).then(function(b){
272 var detail = document.getElementById('detail');
273 clear(detail);
274 append(detail, E('h2', '', 'Block '+b.height));
275 var card = E('div', 'card'), grid = E('div', 'kv');
276 kv(grid, 'Height', String(b.height));
277 kv(grid, 'Hash', b.hash, true);
278 var prev = E('div');
279 if(b.height > 0){
280 var btn = E('button', 'link');
281 append(btn, code(short(b.prev)));
282 btn.onclick = function(){ openBlock(b.height - 1); };
283 append(prev, btn);
284 } else {
285 append(prev, code(short(b.prev)));
286 }
287 append(grid, E('div', '', 'Previous'), prev);
288 kv(grid, 'Merkle root', b.merkleRoot, true);
289 kv(grid, 'Time (UTC)', ts(b.time));
290 kv(grid, 'Bits / Nonce', b.bits.toString(16)+' / '+b.nonce);
291 kv(grid, 'Size / Txs', b.size+' B / '+b.txs.length);
292 append(card, grid); append(detail, card);
293 b.txs.forEach(function(t, i){
294 var tx = E('div', 'tx');
295 var head = E('div');
296 append(head, code(t.txid));
297 if(i === 0) append(head, E('span', 'pill', 'coinbase'));
298 var io = E('div', 'io');
299 append(io, ioSide('Inputs', t.vin, function(v){
300 var d = E('div');
301 if(v.coinbase) append(d, E('span', 'pill', 'coinbase'));
302 else append(d, code(short(v.prev)+':'+v.vout));
303 return d;
304 }));
305 append(io, ioSide('Outputs', t.vout, function(o){
306 var d = E('div');
307 var to = o.address ? o.address : (o.pubkey ? 'pubkey '+o.pubkey.slice(0,16)+'...' : o.type);
308 append(d, document.createTextNode(btf(o.value)+' -> '), code(to));
309 return d;
310 }));
311 append(tx, head, io); append(detail, tx);
312 });
313 document.getElementById('listview').style.display='none';
314 detail.style.display='block';
315 document.getElementById('home').style.display='inline';
316 window.scrollTo(0,0);
317 });
318}
319document.getElementById('home').onclick=function(){
320 document.getElementById('detail').style.display='none';
321 document.getElementById('listview').style.display='block';
322 this.style.display='none';
323};
324document.getElementById('q').oninput=function(){ renderList(this.value); };
325fetch('blocks.json').then(function(r){return r.json();}).then(function(d){
326 SUM=d.blocks; document.getElementById('tip').textContent='tip height '+d.tipHeight+' - '+d.count+' blocks';
327 renderList('');
328});
329</script>
330</body>
331</html>
332"""
333
334
335if __name__ == "__main__":
336 raise SystemExit(main(sys.argv[1:]))
diff --git a/scripts/build-pool-index.py b/scripts/build-pool-index.py
new file mode 100644
index 0000000..d3eb386
--- /dev/null
+++ b/scripts/build-pool-index.py
@@ -0,0 +1,174 @@
1#!/usr/bin/env python3
2#
3# Build a static public pool directory from one or more Bitflash pool_status.json
4# files. Inputs may be local paths or http(s) URLs.
5
6import argparse
7import html
8import json
9import os
10import time
11import urllib.parse
12import urllib.request
13
14
15def load_json(source):
16 if source.startswith("http://") or source.startswith("https://"):
17 with urllib.request.urlopen(source, timeout=10) as r:
18 return json.loads(r.read().decode("utf-8"))
19 with open(source, "r", encoding="utf-8-sig") as f:
20 return json.load(f)
21
22
23def normalize_pool(source, obj, now):
24 operator = obj.get("operator", {})
25 pool = obj.get("pool", {})
26 node = obj.get("node", {})
27 rounds_file = operator.get("roundsFile") or "pool_rounds.json"
28 rounds_url = ""
29 if source.startswith("http://") or source.startswith("https://"):
30 rounds_url = urllib.parse.urljoin(source, rounds_file)
31 updated = int(obj.get("updatedAt") or 0)
32 age = max(0, now - updated) if updated else None
33 online = bool(node.get("poolRunning")) and age is not None and age <= 120
34 return {
35 "source": source,
36 "name": operator.get("name") or "Bitflash Pool",
37 "btfAddress": operator.get("btfAddress") or "",
38 "feePercent": float(operator.get("feePercent") or 0.0),
39 "dashboardUrl": operator.get("dashboardUrl") or "",
40 "height": int(node.get("height") or 0),
41 "online": online,
42 "ageSeconds": age,
43 "authorizedMiners": int(pool.get("authorizedMiners") or 0),
44 "blocksFoundSession": int(pool.get("blocksFoundSession") or 0),
45 "roundShares": int(pool.get("roundShares") or 0),
46 "hashRate": float(pool.get("hashRate") or 0.0),
47 "roundsUrl": rounds_url,
48 "recentRounds": obj.get("recentRounds", []),
49 }
50
51
52def rank_key(pool):
53 return (
54 1 if pool["online"] else 0,
55 pool["hashRate"],
56 pool["authorizedMiners"],
57 -pool["feePercent"],
58 pool["name"].lower(),
59 )
60
61
62def render_html(pools, generated_at):
63 rows = []
64 for p in pools:
65 dash = p["dashboardUrl"]
66 rounds = p.get("roundsUrl", "")
67 # Only linkify http(s). The dashboard URL comes from an operator's
68 # self-published status file, so a "javascript:" or "data:" scheme
69 # would become a clickable script-injection on this public page.
70 dash_lower = dash.lower()
71 if dash_lower.startswith("http://") or dash_lower.startswith("https://"):
72 dash_html = '<a href="{0}" rel="noopener noreferrer">dashboard</a>'.format(
73 html.escape(dash, quote=True))
74 else:
75 dash_html = "-"
76 rounds_lower = rounds.lower()
77 if rounds_lower.startswith("http://") or rounds_lower.startswith("https://"):
78 rounds_html = '<a href="{0}" rel="noopener noreferrer">proof</a>'.format(
79 html.escape(rounds, quote=True))
80 else:
81 rounds_html = "-"
82 rows.append(
83 "<tr>"
84 "<td>{status}</td>"
85 "<td><strong>{name}</strong><br><code>{addr}</code></td>"
86 "<td>{fee:.2f}%</td>"
87 "<td>{miners}</td>"
88 "<td>{hashrate:.2f}</td>"
89 "<td>{blocks}</td>"
90 "<td>{height}</td>"
91 "<td>{age}</td>"
92 "<td>{dash}</td>"
93 "<td>{rounds}</td>"
94 "</tr>".format(
95 status="online" if p["online"] else "stale",
96 name=html.escape(p["name"]),
97 addr=html.escape(p["btfAddress"]),
98 fee=p["feePercent"],
99 miners=p["authorizedMiners"],
100 hashrate=p["hashRate"],
101 blocks=p["blocksFoundSession"],
102 height=p["height"],
103 age="{}s".format(p["ageSeconds"]) if p["ageSeconds"] is not None else "-",
104 dash=dash_html,
105 rounds=rounds_html,
106 )
107 )
108
109 return """<!doctype html>
110<html lang="en">
111<head>
112 <meta charset="utf-8">
113 <meta name="viewport" content="width=device-width, initial-scale=1">
114 <title>Bitflash Pools</title>
115 <style>
116 body {{ font: 15px/1.45 system-ui, sans-serif; margin: 32px; color: #111; }}
117 table {{ border-collapse: collapse; width: 100%; }}
118 th, td {{ border-bottom: 1px solid #ddd; padding: 10px; text-align: left; vertical-align: top; }}
119 th {{ font-size: 12px; text-transform: uppercase; letter-spacing: .08em; }}
120 code {{ font-size: 12px; word-break: break-all; }}
121 .note {{ color: #555; max-width: 780px; }}
122 </style>
123</head>
124<body>
125 <h1>Bitflash Pools</h1>
126 <p class="note">This directory is informational. Bitflash nodes discover pools through Nostr and .btf; this page only helps humans compare public operators.</p>
127 <table>
128 <thead>
129 <tr><th>Status</th><th>Pool</th><th>Fee</th><th>Miners</th><th>H/s</th><th>Blocks</th><th>Height</th><th>Age</th><th>Link</th><th>Proof</th></tr>
130 </thead>
131 <tbody>
132 {rows}
133 </tbody>
134 </table>
135 <p class="note">Generated at {generated_at}. JSON: <a href="pools.json">pools.json</a>.</p>
136</body>
137</html>
138""".format(rows="\n ".join(rows), generated_at=generated_at)
139
140
141def main():
142 ap = argparse.ArgumentParser()
143 ap.add_argument("--out-dir", default="pool-site")
144 ap.add_argument("sources", nargs="+", help="pool_status.json paths or URLs")
145 args = ap.parse_args()
146
147 now = int(time.time())
148 pools = []
149 errors = []
150 for source in args.sources:
151 try:
152 pools.append(normalize_pool(source, load_json(source), now))
153 except Exception as e:
154 errors.append({"source": source, "error": str(e)})
155
156 pools.sort(key=rank_key, reverse=True)
157 os.makedirs(args.out_dir, exist_ok=True)
158
159 generated_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now))
160 with open(os.path.join(args.out_dir, "pools.json"), "w", encoding="utf-8") as f:
161 json.dump({
162 "schema": "bitflash-pool-directory-1",
163 "generatedAt": generated_at,
164 "pools": pools,
165 "errors": errors,
166 }, f, indent=2, sort_keys=True)
167 f.write("\n")
168
169 with open(os.path.join(args.out_dir, "index.html"), "w", encoding="utf-8") as f:
170 f.write(render_html(pools, generated_at))
171
172
173if __name__ == "__main__":
174 main()
diff --git a/scripts/prove-utxo.py b/scripts/prove-utxo.py
new file mode 100644
index 0000000..105e677
--- /dev/null
+++ b/scripts/prove-utxo.py
@@ -0,0 +1,186 @@
1#!/usr/bin/env python3
2"""Create a Bitflash UTXO inclusion proof from local blk*.dat files.
3
4The proof is deliberately outside consensus. It proves that one txid:vout is
5included in the deterministic UTXO root produced by verify-utxo-set.py.
6"""
7
8import argparse
9import hashlib
10import json
11import sys
12from pathlib import Path
13
14import bitflash_chain as chain
15
16SCHEMA = "bitflash-utxo-inclusion-proof-1"
17
18
19def canonical_bytes(obj):
20 return (json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + "\n").encode("ascii")
21
22
23def split_outpoint(outpoint):
24 parts = outpoint.split(":")
25 if len(parts) != 2 or len(parts[0]) != 64:
26 raise SystemExit("outpoint must be txid:vout")
27 try:
28 vout = int(parts[1], 10)
29 except ValueError:
30 raise SystemExit("vout must be a decimal integer")
31 if vout < 0:
32 raise SystemExit("vout must not be negative")
33 return parts[0].lower(), vout
34
35
36def build_merkle_proof(keys, leaf_hashes, index):
37 current_index = index
38 layer = list(leaf_hashes)
39 proof = []
40 level = 0
41 while len(layer) > 1:
42 if current_index % 2 == 0:
43 sibling_index = current_index + 1
44 duplicated = sibling_index >= len(layer)
45 sibling = layer[current_index] if duplicated else layer[sibling_index]
46 side = "right"
47 else:
48 sibling_index = current_index - 1
49 duplicated = False
50 sibling = layer[sibling_index]
51 side = "left"
52 proof.append({
53 "level": level,
54 "side": side,
55 "hash": sibling.hex(),
56 "duplicated": duplicated,
57 })
58
59 next_layer = []
60 for i in range(0, len(layer), 2):
61 left = layer[i]
62 right = layer[i + 1] if i + 1 < len(layer) else left
63 next_layer.append(hashlib.sha256(chain.UTXO_NODE_DOMAIN + left + right).digest())
64 layer = next_layer
65 current_index //= 2
66 level += 1
67 return proof, layer[0].hex()
68
69
70def build_proof(args):
71 txid, vout = split_outpoint(args.outpoint)
72 outpoint = "%s:%d" % (txid, vout)
73
74 raw_blocks = chain.read_blocks(args.datadir, args.max_blocks)
75 if not raw_blocks:
76 raise SystemExit("no Bitflash blocks found in %s" % args.datadir)
77 try:
78 blocks = chain.select_main_chain(raw_blocks)
79 state = chain.apply_blocks(blocks, strict_duplicates=args.strict_duplicates)
80 except chain.ParseError as e:
81 raise SystemExit(str(e))
82 utxos = state["utxos"]
83 stats = state["stats"]
84 if outpoint not in utxos:
85 raise SystemExit("outpoint is not unspent in the scanned chain: %s" % outpoint)
86
87 keys = sorted(utxos)
88 leaf_hashes = [chain.utxo_leaf_hash(k, utxos[k]) for k in keys]
89 index = keys.index(outpoint)
90 proof_path, root = build_merkle_proof(keys, leaf_hashes, index)
91 direct_root = chain.utxo_root(utxos)
92 if root != direct_root:
93 raise SystemExit("internal proof root mismatch")
94
95 tip = blocks[-1]
96 utxo = utxos[outpoint]
97 report = {
98 "schema": SCHEMA,
99 "deterministic": True,
100 "chain": {
101 "name": "Bitflash",
102 "ticker": "BTF",
103 "message_start_hex": chain.MAGIC.hex(),
104 "coin": chain.COIN,
105 },
106 "commitment": {
107 "algorithm": "sorted-outpoint-merkle-sha256",
108 "leaf_domain": chain.UTXO_LEAF_DOMAIN,
109 "node_domain": chain.UTXO_NODE_DOMAIN_TAG,
110 "root": root,
111 "leaf_count": len(keys),
112 },
113 "target": {
114 "outpoint": outpoint,
115 "txid": txid,
116 "vout": vout,
117 "leaf_index": index,
118 "leaf_hash": leaf_hashes[index].hex(),
119 "utxo": utxo,
120 },
121 "proof": proof_path,
122 "scanned": {
123 "raw_block_count": len(raw_blocks),
124 "side_blocks_ignored": len(raw_blocks) - len(blocks),
125 "main_chain_block_count": len(blocks),
126 "first_height": 0,
127 "last_height": len(blocks) - 1,
128 "tip_hash": tip["hash"],
129 "tip_time": tip["time"],
130 "tip_time_utc": tip["time_utc"],
131 },
132 "warnings": {
133 "legacy_duplicate_output_overwrites": len(stats["duplicate_outputs"]),
134 },
135 "verdict": "UTXO_INCLUSION_PROOF_CREATED",
136 }
137 return report
138
139
140def print_text(proof):
141 data_hash = hashlib.sha256(canonical_bytes(proof)).hexdigest()
142 target = proof["target"]
143 utxo = target["utxo"]
144 print("Bitflash UTXO inclusion proof")
145 print("schema: %s" % proof["schema"])
146 print("canonical proof sha256: %s" % data_hash)
147 print()
148 print("Target")
149 print(" outpoint: %s" % target["outpoint"])
150 print(" value: %s BTF" % utxo["value_btf"])
151 print(" height: %d" % utxo["created_height"])
152 print(" index: %d of %d" % (target["leaf_index"], proof["commitment"]["leaf_count"]))
153 print()
154 print("Commitment")
155 print(" root: %s" % proof["commitment"]["root"])
156 print(" path: %d sibling hashes" % len(proof["proof"]))
157 print(" tip: %s" % proof["scanned"]["tip_hash"])
158 print()
159 print("Verdict: %s" % proof["verdict"])
160
161
162def main(argv):
163 ap = argparse.ArgumentParser(description=__doc__)
164 ap.add_argument("outpoint", help="unspent output to prove, as txid:vout")
165 ap.add_argument("--datadir", default=chain.default_datadir(), help="Bitflash data directory")
166 ap.add_argument("--max-blocks", type=int, default=0,
167 help="number of blocks to scan; 0 means all block files")
168 ap.add_argument("--strict-duplicates", action="store_true",
169 help="fail instead of using legacy last-write-wins duplicate output handling")
170 ap.add_argument("--json", action="store_true", help="print canonical JSON")
171 ap.add_argument("--out", help="write canonical JSON proof to this path")
172 args = ap.parse_args(argv)
173
174 proof = build_proof(args)
175 data = canonical_bytes(proof)
176 if args.out:
177 Path(args.out).write_bytes(data)
178 if args.json:
179 sys.stdout.buffer.write(data)
180 else:
181 print_text(proof)
182 return 0
183
184
185if __name__ == "__main__":
186 raise SystemExit(main(sys.argv[1:]))
diff --git a/scripts/verify-fair-launch.py b/scripts/verify-fair-launch.py
new file mode 100644
index 0000000..c33c8be
--- /dev/null
+++ b/scripts/verify-fair-launch.py
@@ -0,0 +1,166 @@
1#!/usr/bin/env python3
2"""Produce a deterministic Bitflash fair-launch audit report."""
3
4import argparse
5import hashlib
6import sys
7from pathlib import Path
8
9import bitflash_chain as chain
10
11
12def build_report(blocks, source_mode):
13 genesis = blocks[0]
14 launch_outputs = genesis["transactions"][0]["vout"] if genesis["transactions"] else []
15 coinbase_outputs = []
16 total_mined = 0
17 non_coinbase_txs = 0
18
19 for height, block in enumerate(blocks):
20 if not block["transactions"]:
21 continue
22 cb = block["transactions"][0]
23 if chain.is_coinbase(cb):
24 amount = sum(out["value_satoshis"] for out in cb["vout"])
25 total_mined += amount
26 coinbase_outputs.append({
27 "height": height,
28 "block_hash": block["hash"],
29 "coinbase_txid": cb["txid"],
30 "value_satoshis": amount,
31 "value_btf": chain.format_money(amount),
32 "outputs": cb["vout"],
33 })
34 non_coinbase_txs += sum(1 for tx in block["transactions"] if not chain.is_coinbase(tx))
35
36 checks = {
37 "genesis_hash_matches_source": genesis["hash"] == chain.GENESIS_HASH,
38 "genesis_merkle_matches_source": genesis["merkle_root"] == chain.GENESIS_MERKLE,
39 "computed_merkle_matches_header": genesis["computed_merkle_root"] == genesis["merkle_root"],
40 "genesis_has_single_transaction": genesis["tx_count"] == 1,
41 "genesis_transaction_is_coinbase": bool(genesis["transactions"] and chain.is_coinbase(genesis["transactions"][0])),
42 "genesis_supply_is_50_btf": sum(out["value_satoshis"] for out in launch_outputs) == 50 * chain.COIN,
43 }
44
45 report = {
46 "schema": "bitflash-fair-launch-report-1",
47 "deterministic": True,
48 "source_mode": source_mode,
49 "chain": {
50 "name": "Bitflash",
51 "ticker": "BTF",
52 "message_start_hex": chain.MAGIC.hex(),
53 "coin": chain.COIN,
54 },
55 "source_constants": {
56 "genesis_hash": chain.GENESIS_HASH,
57 "genesis_merkle_root": chain.GENESIS_MERKLE,
58 "genesis_time": chain.GENESIS_TIME,
59 "genesis_time_utc": chain.utc(chain.GENESIS_TIME),
60 "genesis_bits": "0x%08x" % chain.GENESIS_BITS,
61 "genesis_nonce": chain.GENESIS_NONCE,
62 "genesis_message": chain.GENESIS_TEXT,
63 },
64 "scanned": {
65 "block_count": len(blocks),
66 "first_height": 0,
67 "last_height": len(blocks) - 1,
68 "non_coinbase_transactions": non_coinbase_txs,
69 "total_coinbase_satoshis": total_mined,
70 "total_coinbase_btf": chain.format_money(total_mined),
71 },
72 "genesis": {
73 "hash": genesis["hash"],
74 "merkle_root": genesis["merkle_root"],
75 "computed_merkle_root": genesis["computed_merkle_root"],
76 "time": genesis["time"],
77 "time_utc": genesis["time_utc"],
78 "bits": genesis["bits"],
79 "nonce": genesis["nonce"],
80 "tx_count": genesis["tx_count"],
81 "coinbase_txid": genesis["transactions"][0]["txid"] if genesis["transactions"] else "",
82 "outputs": launch_outputs,
83 },
84 "coinbase_outputs": coinbase_outputs,
85 "checks": checks,
86 "verdict": "FAIR_LAUNCH_BASELINE_VERIFIED" if all(checks.values()) else "CHECKS_FAILED",
87 "external_timestamping": {
88 "status": "not_embedded",
89 "recommended": "OpenTimestamps",
90 "instructions": [
91 "Save the canonical JSON report.",
92 "Run: ots stamp bitflash-fair-launch-report.json",
93 "Later verify with: ots verify bitflash-fair-launch-report.json.ots",
94 ],
95 },
96 }
97 report["attestation_payload_sha256"] = hashlib.sha256(chain.canonical_bytes(report)).hexdigest()
98 return report
99
100
101def print_text(report):
102 checks = report["checks"]
103 print("Bitflash fair-launch verifier")
104 print("schema: %s" % report["schema"])
105 print("source: %s" % report["source_mode"])
106 print("attestation payload sha256: %s" % report["attestation_payload_sha256"])
107 print()
108 print("Genesis")
109 print(" hash: %s" % report["genesis"]["hash"])
110 print(" merkle: %s" % report["genesis"]["merkle_root"])
111 print(" time: %s" % report["genesis"]["time_utc"])
112 print(" txs: %d" % report["genesis"]["tx_count"])
113 print(" supply: %s BTF" % chain.format_money(sum(o["value_satoshis"] for o in report["genesis"]["outputs"])))
114 print()
115 print("Scanned")
116 print(" blocks: %d" % report["scanned"]["block_count"])
117 print(" height: %d..%d" % (report["scanned"]["first_height"], report["scanned"]["last_height"]))
118 print(" minted: %s BTF" % report["scanned"]["total_coinbase_btf"])
119 print(" normal transactions in range: %d" % report["scanned"]["non_coinbase_transactions"])
120 print()
121 print("Checks")
122 for key in sorted(checks):
123 print(" %s %s" % ("OK " if checks[key] else "FAIL", key))
124 print()
125 print("Verdict: %s" % report["verdict"])
126 print()
127 print("OpenTimestamps")
128 print(" canonical JSON can be stamped later with: ots stamp <report.json>")
129
130
131def main(argv):
132 ap = argparse.ArgumentParser(description=__doc__)
133 ap.add_argument("--datadir", default=chain.default_datadir(), help="Bitflash data directory")
134 ap.add_argument("--max-blocks", type=int, default=100, help="number of blocks to scan from blk*.dat")
135 ap.add_argument("--json", action="store_true", help="print canonical JSON")
136 ap.add_argument("--out", help="write canonical JSON to this path")
137 ap.add_argument("--reconstruct-genesis", action="store_true",
138 help="do not read disk; reconstruct only the genesis block from source constants")
139 args = ap.parse_args(argv)
140
141 if args.reconstruct_genesis:
142 blocks = [chain.reconstructed_genesis()]
143 source_mode = "reconstructed-genesis"
144 else:
145 blocks = chain.read_blocks(args.datadir, args.max_blocks)
146 if not blocks:
147 raise SystemExit("no Bitflash blocks found in %s; use --reconstruct-genesis for a source-only check" % args.datadir)
148 source_mode = "local-block-files"
149
150 try:
151 report = build_report(blocks, source_mode)
152 except chain.ParseError as e:
153 raise SystemExit(str(e))
154 data = chain.canonical_bytes(report)
155
156 if args.out:
157 Path(args.out).write_bytes(data)
158 if args.json:
159 sys.stdout.buffer.write(data)
160 else:
161 print_text(report)
162 return 0 if report["verdict"] == "FAIR_LAUNCH_BASELINE_VERIFIED" else 1
163
164
165if __name__ == "__main__":
166 raise SystemExit(main(sys.argv[1:]))
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 @@
1#!/usr/bin/env bash 1#!/usr/bin/env bash
2set -euo pipefail 2set -euo pipefail
3 3
4repo="Bitflash-sh/bitflash" 4# Verifies a Bitflash release: the checksum file's signature against a pinned
5tag="${1:-latest}" 5# key, then the assets against the checksums.
6#
7# The signing key is embedded below, not fetched. That is the whole point of a
8# pinned key: the trust anchor ships with this script (in git), so it does not
9# depend on the same server that serves the release. A server that is
10# compromised can swap the binaries and the checksums, but it cannot forge a
11# signature by this key.
12
13host="https://releases.bitflash.network"
6require_signature=0 14require_signature=0
7 15
16# The Bitflash release signing key. Verification requires this exact key --
17# gpg's own "good signature" is not enough, because a good signature by *some*
18# key proves nothing. See issue #138.
19RELEASE_KEY_FP="910A2B4CCA879E81FB4B2AEA1D4A53D3B78AA4B8"
20read -r -d '' RELEASE_KEY <<'KEY' || true
21-----BEGIN PGP PUBLIC KEY BLOCK-----
22
23mDMEanSOmRYJKwYBBAHaRw8BAQdAXj24i2BYUcAmOpoXwjE4ZTynHsifCZ+niwQK
24HqO0nMa0UEJpdGZsYXNoIFJlbGVhc2VzIChiaXRmbGFzaC5uZXR3b3JrIHJlbGVh
25c2Ugc2lnbmluZykgPHJlbGVhc2VzQGJpdGZsYXNoLm5ldHdvcms+iJAEExYKADgW
26IQSRCitMyoeegftLKuodSlPTt4qkuAUCanSOmQIbAwULCQgHAgYVCgkICwIEFgID
27AQIeAQIXgAAKCRAdSlPTt4qkuIi9AP96X3OcstiCTTgKOr16Vhds3LyuZ19q3Apq
284KAcNuOUMAD+NqgJ1CqkLGPXfZLCtYBCRHXNYWxMMEk+ViLcRtdLMg4=
29=mxic
30-----END PGP PUBLIC KEY BLOCK-----
31KEY
32
8usage() { 33usage() {
9 cat <<'EOF' 34 cat <<'EOF'
10Usage: scripts/verify-release.sh [tag|latest] [--require-signature] 35Usage: scripts/verify-release.sh [tag|latest] [--require-signature]
11 36
12Downloads the release checksum file and every asset named in it, then verifies: 37Downloads the release checksum file and every asset named in it, then verifies:
13 1. SHA256SUMS.asc, if present, against SHA256SUMS with gpg. 38 1. SHA256SUMS.asc against SHA256SUMS, requiring the pinned Bitflash release
39 key (fingerprint 910A 2B4C CA87 9E81 FB4B 2AEA 1D4A 53D3 B78A A4B8).
14 2. SHA256SUMS against the downloaded assets. 40 2. SHA256SUMS against the downloaded assets.
15 41
16Use --require-signature for release audits. Without it, older releases that 42Releases from before signing was introduced ship only SHA256SUMS; without
17ship only SHA256SUMS are checked for integrity and reported as unsigned. 43--require-signature those are checked for integrity and reported as unsigned.
18EOF 44EOF
19} 45}
20 46
47tag="${1:-latest}"
21for arg in "${@:2}"; do 48for arg in "${@:2}"; do
22 case "$arg" in 49 case "$arg" in
23 --require-signature) require_signature=1 ;; 50 --require-signature) require_signature=1 ;;
@@ -25,19 +52,9 @@ for arg in "${@:2}"; do
25 *) echo "unknown option: $arg" >&2; usage >&2; exit 2 ;; 52 *) echo "unknown option: $arg" >&2; usage >&2; exit 2 ;;
26 esac 53 esac
27done 54done
55if [ "$tag" = "-h" ] || [ "$tag" = "--help" ]; then usage; exit 0; fi
28 56
29if [ "$tag" = "-h" ] || [ "$tag" = "--help" ]; then 57need() { command -v "$1" >/dev/null 2>&1 || { echo "missing required command: $1" >&2; exit 1; }; }
30 usage
31 exit 0
32fi
33
34need() {
35 if ! command -v "$1" >/dev/null 2>&1; then
36 echo "missing required command: $1" >&2
37 exit 1
38 fi
39}
40
41need sed 58need sed
42need awk 59need awk
43 60
@@ -46,39 +63,25 @@ if command -v sha256sum >/dev/null 2>&1; then
46elif command -v shasum >/dev/null 2>&1; then 63elif command -v shasum >/dev/null 2>&1; then
47 sha_check=(shasum -a 256 -c SHA256SUMS) 64 sha_check=(shasum -a 256 -c SHA256SUMS)
48else 65else
49 echo "missing required command: sha256sum or shasum" >&2 66 echo "missing required command: sha256sum or shasum" >&2; exit 1
50 exit 1
51fi 67fi
52 68
53download() { 69download() {
54 local url="$1" 70 if command -v curl >/dev/null 2>&1; then curl -fsSL "$1" -o "$2"
55 local out="$2" 71 elif command -v wget >/dev/null 2>&1; then wget -q "$1" -O "$2"
56 if command -v curl >/dev/null 2>&1; then 72 else echo "missing required command: curl or wget" >&2; exit 1; fi
57 curl -fsSL "$url" -o "$out"
58 elif command -v wget >/dev/null 2>&1; then
59 wget -q "$url" -O "$out"
60 else
61 echo "missing required command: curl or wget" >&2
62 exit 1
63 fi
64} 73}
65 74
66if [ "$tag" = "latest" ]; then 75if [ "$tag" = "latest" ]; then
67 tag="$(download "https://api.github.com/repos/$repo/releases/latest" - | 76 tag="$(download "$host/latest.txt" - 2>/dev/null | tr -d '[:space:]' || true)"
68 sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | 77 [ -n "$tag" ] || { echo "could not resolve the latest release tag from $host/latest.txt" >&2; exit 1; }
69 head -1)"
70 if [ -z "$tag" ]; then
71 echo "could not resolve latest release tag" >&2
72 exit 1
73 fi
74fi 78fi
75 79
76workdir="$(mktemp -d "${TMPDIR:-/tmp}/bitflash-release-verify.XXXXXX")" 80workdir="$(mktemp -d "${TMPDIR:-/tmp}/bitflash-release-verify.XXXXXX")"
77cleanup() { rm -rf "$workdir"; } 81cleanup() { rm -rf "$workdir"; }
78trap cleanup EXIT 82trap cleanup EXIT
79 83
80base_url="https://github.com/$repo/releases/download/$tag" 84base_url="$host/$tag"
81
82echo "Verifying Bitflash $tag" 85echo "Verifying Bitflash $tag"
83echo "Working directory: $workdir" 86echo "Working directory: $workdir"
84cd "$workdir" 87cd "$workdir"
@@ -86,27 +89,32 @@ cd "$workdir"
86download "$base_url/SHA256SUMS" SHA256SUMS 89download "$base_url/SHA256SUMS" SHA256SUMS
87 90
88if download "$base_url/SHA256SUMS.asc" SHA256SUMS.asc 2>/dev/null; then 91if download "$base_url/SHA256SUMS.asc" SHA256SUMS.asc 2>/dev/null; then
89 if ! command -v gpg >/dev/null 2>&1; then 92 need gpg
90 echo "SHA256SUMS.asc exists, but gpg is not installed" >&2 93 # Verify in a throwaway keyring holding ONLY the pinned key, and require that
94 # the signature validates against it with the pinned fingerprint. This is the
95 # difference between "signed" and "signed by Bitflash".
96 keyring="$workdir/keyring"
97 mkdir -p "$keyring"; chmod 700 "$keyring"
98 printf '%s\n' "$RELEASE_KEY" | GNUPGHOME="$keyring" gpg --batch --quiet --import 2>/dev/null
99 status="$(GNUPGHOME="$keyring" gpg --batch --status-fd=1 --verify SHA256SUMS.asc SHA256SUMS 2>/dev/null || true)"
100 if printf '%s' "$status" | grep -q "VALIDSIG.*$RELEASE_KEY_FP"; then
101 echo "Signature OK -- signed by the pinned Bitflash release key"
102 else
103 echo "SIGNATURE REJECTED: not a valid signature by the pinned release key" >&2
104 echo " expected key: $RELEASE_KEY_FP" >&2
91 exit 1 105 exit 1
92 fi 106 fi
93 gpg --verify SHA256SUMS.asc SHA256SUMS
94 echo "Signature OK"
95else 107else
96 if [ "$require_signature" -eq 1 ]; then 108 if [ "$require_signature" -eq 1 ]; then
97 echo "release does not publish SHA256SUMS.asc" >&2 109 echo "release does not publish SHA256SUMS.asc" >&2; exit 1
98 exit 1
99 fi 110 fi
100 echo "WARNING: no SHA256SUMS.asc found; checking hashes only" 111 echo "WARNING: no SHA256SUMS.asc found; checking hashes only (unsigned release)"
101fi 112fi
102 113
103awk '{print $2}' SHA256SUMS | while IFS= read -r file; do 114awk '{print $2}' SHA256SUMS | while IFS= read -r file; do
104 file="${file#\*}" 115 file="${file#\*}"; file="${file#./}"
105 file="${file#./}"
106 [ -z "$file" ] && continue 116 [ -z "$file" ] && continue
107 case "$file" in 117 case "$file" in */*|..*) echo "refusing unexpected checksum path: $file" >&2; exit 1 ;; esac
108 */*|..*) echo "refusing unexpected checksum path: $file" >&2; exit 1 ;;
109 esac
110 echo "Downloading $file" 118 echo "Downloading $file"
111 download "$base_url/$file" "$file" 119 download "$base_url/$file" "$file"
112done 120done
diff --git a/scripts/verify-utxo-proof.py b/scripts/verify-utxo-proof.py
new file mode 100644
index 0000000..50d9356
--- /dev/null
+++ b/scripts/verify-utxo-proof.py
@@ -0,0 +1,146 @@
1#!/usr/bin/env python3
2"""Verify a Bitflash UTXO inclusion proof against a UTXO root."""
3
4import argparse
5import hashlib
6import json
7import sys
8from pathlib import Path
9
10import bitflash_chain as chain
11
12SCHEMA = "bitflash-utxo-inclusion-proof-1"
13
14
15def canonical_bytes(obj):
16 return (json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + "\n").encode("ascii")
17
18
19def load_proof(path):
20 try:
21 return json.loads(Path(path).read_text(encoding="ascii"))
22 except Exception as e:
23 raise SystemExit("could not read proof: %s" % e)
24
25
26def verify(proof, expected_root=None):
27 failures = []
28 if proof.get("schema") != SCHEMA:
29 failures.append("unexpected schema")
30 commitment = proof.get("commitment", {})
31 if commitment.get("algorithm") != "sorted-outpoint-merkle-sha256":
32 failures.append("unexpected commitment algorithm")
33 if commitment.get("leaf_domain") != chain.UTXO_LEAF_DOMAIN:
34 failures.append("unexpected leaf domain")
35 if commitment.get("node_domain") != chain.UTXO_NODE_DOMAIN_TAG:
36 failures.append("unexpected node domain")
37
38 target = proof.get("target", {})
39 outpoint = target.get("outpoint", "")
40 utxo = target.get("utxo", {})
41 if outpoint != "%s:%s" % (target.get("txid"), target.get("vout")):
42 failures.append("target outpoint does not match txid:vout")
43
44 try:
45 current = chain.utxo_leaf_hash(outpoint, utxo)
46 leaf_ok = target.get("leaf_hash") == current.hex()
47 except Exception as e:
48 failures.append("could not hash target leaf: %s" % e)
49 current = b"\x00" * 32
50 leaf_ok = False
51
52 if not leaf_ok:
53 failures.append("target leaf hash mismatch")
54
55 for i, step in enumerate(proof.get("proof", [])):
56 try:
57 sibling = bytes.fromhex(step["hash"])
58 except Exception:
59 failures.append("bad sibling hash at proof level %d" % i)
60 sibling = b"\x00" * 32
61 if len(sibling) != 32:
62 failures.append("bad sibling hash length at proof level %d" % i)
63 side = step.get("side")
64 if side == "right":
65 current = chain.sha256(chain.UTXO_NODE_DOMAIN + current + sibling)
66 elif side == "left":
67 current = chain.sha256(chain.UTXO_NODE_DOMAIN + sibling + current)
68 else:
69 failures.append("bad sibling side at proof level %d" % i)
70
71 computed_root = current.hex()
72 root = expected_root or commitment.get("root")
73 if computed_root != root:
74 failures.append("computed root does not match expected root")
75
76 return {
77 "schema": "bitflash-utxo-proof-verification-1",
78 "deterministic": True,
79 "target": {
80 "outpoint": outpoint,
81 "value_satoshis": utxo.get("value_satoshis"),
82 "value_btf": utxo.get("value_btf"),
83 "created_height": utxo.get("created_height"),
84 },
85 "commitment": {
86 "expected_root": root,
87 "computed_root": computed_root,
88 "leaf_hash": target.get("leaf_hash"),
89 "path_length": len(proof.get("proof", [])),
90 },
91 "checks": {
92 "schema_is_supported": proof.get("schema") == SCHEMA,
93 "leaf_hash_matches_target": leaf_ok,
94 "root_matches": computed_root == root,
95 },
96 "failures": failures,
97 "verdict": "UTXO_PROOF_VERIFIED" if not failures else "UTXO_PROOF_FAILED",
98 }
99
100
101def print_text(result):
102 data_hash = hashlib.sha256(canonical_bytes(result)).hexdigest()
103 print("Bitflash UTXO proof verifier")
104 print("schema: %s" % result["schema"])
105 print("canonical verification sha256: %s" % data_hash)
106 print()
107 print("Target")
108 print(" outpoint: %s" % result["target"]["outpoint"])
109 print(" value: %s BTF" % result["target"]["value_btf"])
110 print(" height: %s" % result["target"]["created_height"])
111 print()
112 print("Commitment")
113 print(" expected: %s" % result["commitment"]["expected_root"])
114 print(" computed: %s" % result["commitment"]["computed_root"])
115 print(" path: %d sibling hashes" % result["commitment"]["path_length"])
116 print()
117 print("Checks")
118 for key in sorted(result["checks"]):
119 print(" %s %s" % ("OK " if result["checks"][key] else "FAIL", key))
120 if result["failures"]:
121 print()
122 print("Failures")
123 for failure in result["failures"]:
124 print(" - %s" % failure)
125 print()
126 print("Verdict: %s" % result["verdict"])
127
128
129def main(argv):
130 ap = argparse.ArgumentParser(description=__doc__)
131 ap.add_argument("proof", help="canonical JSON proof from prove-utxo.py")
132 ap.add_argument("--root", help="expected UTXO root; defaults to root embedded in proof")
133 ap.add_argument("--json", action="store_true", help="print canonical JSON")
134 args = ap.parse_args(argv)
135
136 result = verify(load_proof(args.proof), args.root)
137 data = canonical_bytes(result)
138 if args.json:
139 sys.stdout.buffer.write(data)
140 else:
141 print_text(result)
142 return 0 if result["verdict"] == "UTXO_PROOF_VERIFIED" else 1
143
144
145if __name__ == "__main__":
146 raise SystemExit(main(sys.argv[1:]))
diff --git a/scripts/verify-utxo-set.py b/scripts/verify-utxo-set.py
new file mode 100644
index 0000000..b5bf3d5
--- /dev/null
+++ b/scripts/verify-utxo-set.py
@@ -0,0 +1,131 @@
1#!/usr/bin/env python3
2"""Build a deterministic Bitflash UTXO-set commitment from local blk*.dat."""
3
4import argparse
5import hashlib
6import sys
7from pathlib import Path
8
9import bitflash_chain as chain
10
11
12def build_report(raw_blocks, include_utxos=False, strict_duplicates=False):
13 blocks = chain.select_main_chain(raw_blocks)
14 state = chain.apply_blocks(blocks, strict_duplicates=strict_duplicates)
15 utxos = state["utxos"]
16 stats = state["stats"]
17 tip = blocks[-1]
18 root = chain.utxo_root(utxos)
19 checks = {
20 "genesis_hash_matches_source": blocks[0]["hash"] == chain.GENESIS_HASH,
21 "genesis_merkle_matches_source": blocks[0]["merkle_root"] == chain.GENESIS_MERKLE,
22 "all_scanned_merkle_roots_match": all(b["computed_merkle_root"] == b["merkle_root"] for b in blocks),
23 "no_missing_spends": len(stats["missing_spends"]) == 0,
24 }
25 warnings = {
26 "legacy_duplicate_output_overwrites": len(stats["duplicate_outputs"]),
27 }
28 report = {
29 "schema": "bitflash-utxo-set-commitment-1",
30 "deterministic": True,
31 "chain": {
32 "name": "Bitflash",
33 "ticker": "BTF",
34 "message_start_hex": chain.MAGIC.hex(),
35 "coin": chain.COIN,
36 },
37 "commitment": {
38 "algorithm": "sorted-outpoint-merkle-sha256",
39 "leaf_domain": "BTFUTXO1",
40 "node_domain": "BTFNODE1",
41 "root": root,
42 },
43 "scanned": {
44 "raw_block_count": len(raw_blocks),
45 "side_blocks_ignored": len(raw_blocks) - len(blocks),
46 "main_chain_block_count": len(blocks),
47 "first_height": 0,
48 "last_height": len(blocks) - 1,
49 "tip_hash": tip["hash"],
50 "tip_time": tip["time"],
51 "tip_time_utc": tip["time_utc"],
52 },
53 "stats": stats,
54 "checks": checks,
55 "warnings": warnings,
56 "verdict": "UTXO_COMMITMENT_VERIFIED" if all(checks.values()) else "CHECKS_FAILED",
57 }
58 if include_utxos:
59 report["utxos"] = {k: utxos[k] for k in sorted(utxos)}
60 return report
61
62
63def print_text(report):
64 data_hash = hashlib.sha256(chain.canonical_bytes(report)).hexdigest()
65 print("Bitflash UTXO-set commitment verifier")
66 print("schema: %s" % report["schema"])
67 print("canonical report sha256: %s" % data_hash)
68 print()
69 print("Commitment")
70 print(" root: %s" % report["commitment"]["root"])
71 print(" algo: %s" % report["commitment"]["algorithm"])
72 print()
73 print("Scanned")
74 print(" main chain blocks: %d" % report["scanned"]["main_chain_block_count"])
75 print(" raw disk blocks: %d" % report["scanned"]["raw_block_count"])
76 print(" side ignored: %d" % report["scanned"]["side_blocks_ignored"])
77 print(" height: %d..%d" % (report["scanned"]["first_height"], report["scanned"]["last_height"]))
78 print(" tip: %s" % report["scanned"]["tip_hash"])
79 print()
80 print("Supply")
81 print(" utxos: %d" % report["stats"]["utxo_count"])
82 print(" supply: %s BTF" % report["stats"]["total_unspent_btf"])
83 print(" mined: %s BTF" % report["stats"]["total_coinbase_btf"])
84 print(" delta: %s BTF" % report["stats"]["coinbase_minus_unspent_btf"])
85 print(" fees: %s BTF" % report["stats"]["total_fees_btf"])
86 if report["warnings"]["legacy_duplicate_output_overwrites"]:
87 print(" legacy duplicate overwrites: %d" % report["warnings"]["legacy_duplicate_output_overwrites"])
88 print()
89 print("Checks")
90 for key in sorted(report["checks"]):
91 print(" %s %s" % ("OK " if report["checks"][key] else "FAIL", key))
92 print()
93 print("Verdict: %s" % report["verdict"])
94 print()
95 print("OpenTimestamps")
96 print(" canonical JSON can be stamped later with: ots stamp <report.json>")
97
98
99def main(argv):
100 ap = argparse.ArgumentParser(description=__doc__)
101 ap.add_argument("--datadir", default=chain.default_datadir(), help="Bitflash data directory")
102 ap.add_argument("--max-blocks", type=int, default=0,
103 help="number of blocks to scan; 0 means all block files")
104 ap.add_argument("--include-utxos", action="store_true",
105 help="include the full sorted UTXO map in JSON output")
106 ap.add_argument("--strict-duplicates", action="store_true",
107 help="fail instead of using legacy last-write-wins duplicate output handling")
108 ap.add_argument("--json", action="store_true", help="print canonical JSON")
109 ap.add_argument("--out", help="write canonical JSON to this path")
110 args = ap.parse_args(argv)
111
112 blocks = chain.read_blocks(args.datadir, args.max_blocks)
113 if not blocks:
114 raise SystemExit("no Bitflash blocks found in %s" % args.datadir)
115 try:
116 report = build_report(blocks, include_utxos=args.include_utxos,
117 strict_duplicates=args.strict_duplicates)
118 except chain.ParseError as e:
119 raise SystemExit(str(e))
120 data = chain.canonical_bytes(report)
121 if args.out:
122 Path(args.out).write_bytes(data)
123 if args.json:
124 sys.stdout.buffer.write(data)
125 else:
126 print_text(report)
127 return 0 if report["verdict"] == "UTXO_COMMITMENT_VERIFIED" else 1
128
129
130if __name__ == "__main__":
131 raise SystemExit(main(sys.argv[1:]))