upleb.uk

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

summaryrefslogtreecommitdiff
path: root/scripts/build-explorer.py
diff options
context:
space:
mode:
authorbitflash-maintainer <noreply@bitflash.invalid>2026-09-06 01:53:03 +0000
committerbitflash-maintainer <noreply@bitflash.invalid>2026-09-06 01:53:03 +0000
commitef9f06d43fc6577c15df2256abbb25b20d4227b8 (patch)
tree7e9132aa871fdc01cb95268d2f5ea3bb11853ddf /scripts/build-explorer.py
parentbe63e83d79e7e00e6b8ca889a70e6a19886aa1d8 (diff)
parentceae3d076b5ad11ee84729ae43f14e9312896646 (diff)
Merge branch 'chore/main-catchup-2026-08-26' into 'main'HEADmain
Bring main onto the line this project actually ships (incl. the 1.2.23 compactblock port) Closes #6, #7, #8, #11, #35, #34, #29, #28, #27, and #16 See merge request !16
Diffstat (limited to 'scripts/build-explorer.py')
-rwxr-xr-xscripts/build-explorer.py376
1 files changed, 376 insertions, 0 deletions
diff --git a/scripts/build-explorer.py b/scripts/build-explorer.py
new file mode 100755
index 0000000..dbb8e17
--- /dev/null
+++ b/scripts/build-explorer.py
@@ -0,0 +1,376 @@
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 shutil
8import sys
9from pathlib import Path
10
11import bitflash_chain as chain
12
13
14ADDRESS_VERSION = 25
15B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
16
17
18def b58check(payload):
19 v = bytes([ADDRESS_VERSION]) + payload
20 v += chain.sha256d(v)[:4]
21 n = int.from_bytes(v, "big")
22 s = ""
23 while n > 0:
24 n, r = divmod(n, 58)
25 s = B58[r] + s
26 for byte in v:
27 if byte == 0:
28 s = "1" + s
29 else:
30 break
31 return s
32
33
34def hash160(b):
35 return hashlib.new("ripemd160", hashlib.sha256(b).digest()).digest()
36
37
38def spk_info(script_hex):
39 try:
40 spk = bytes.fromhex(script_hex)
41 except ValueError:
42 return {"type": "malformed", "hex": script_hex[:80]}
43 if (
44 len(spk) == 25 and spk[0] == 0x76 and spk[1] == 0xA9
45 and spk[2] == 0x14 and spk[23] == 0x88 and spk[24] == 0xAC
46 ):
47 return {"type": "p2pkh", "address": b58check(spk[3:23])}
48 if len(spk) >= 35 and spk[-1] == 0xAC:
49 if spk[0] == 0x41 and len(spk) == 67:
50 pk = spk[1:66]
51 return {"type": "p2pk", "pubkey": pk.hex(), "address": b58check(hash160(pk))}
52 if spk[0] == 0x21 and len(spk) == 35:
53 pk = spk[1:34]
54 return {"type": "p2pk", "pubkey": pk.hex(), "address": b58check(hash160(pk))}
55 return {"type": "other", "hex": script_hex[:80]}
56
57
58def explorer_tx(tx):
59 vin = []
60 for txin in tx["vin"]:
61 coinbase = (
62 txin["prevout_hash"] == "0" * 64
63 and txin["prevout_n"] == 0xFFFFFFFF
64 )
65 vin.append({
66 "coinbase": coinbase,
67 "prev": None if coinbase else txin["prevout_hash"],
68 "vout": None if coinbase else txin["prevout_n"],
69 })
70 vout = []
71 for out in tx["vout"]:
72 vout.append(dict({"value": out["value_satoshis"]}, **spk_info(out["script_pub_key_hex"])))
73 return {"txid": tx["txid"], "vin": vin, "vout": vout, "size": len(chain.ser_tx(tx))}
74
75
76def parse_args(argv):
77 ap = argparse.ArgumentParser(description=__doc__)
78 ap.add_argument("--datadir", help="Bitflash data directory containing blk*.dat")
79 ap.add_argument("--max-blocks", type=int, default=0,
80 help="number of blocks to scan; 0 means all block files")
81 ap.add_argument("paths", nargs="+",
82 help="with --datadir: <out-dir>; otherwise: <blk*.dat>... <out-dir>")
83 args = ap.parse_args(argv)
84 if args.datadir:
85 if len(args.paths) != 1:
86 raise SystemExit("usage with --datadir: build-explorer.py --datadir DATADIR <out-dir>")
87 args.block_files = None
88 args.out_dir = args.paths[0]
89 else:
90 if len(args.paths) < 2:
91 raise SystemExit("usage: build-explorer.py <blk*.dat>... <out-dir>")
92 args.block_files = args.paths[:-1]
93 args.out_dir = args.paths[-1]
94 return args
95
96
97def load_blocks(args):
98 if args.datadir:
99 return chain.read_blocks(args.datadir, args.max_blocks)
100 return chain.read_blocks_from_files(args.block_files, args.max_blocks)
101
102
103def build_explorer(raw_blocks, out_dir):
104 try:
105 blocks = chain.select_main_chain(raw_blocks)
106 except chain.ParseError as e:
107 raise SystemExit(str(e))
108 out = Path(out_dir)
109 (out / "block").mkdir(parents=True, exist_ok=True)
110
111 summaries = []
112 for height, block in enumerate(blocks):
113 txs = [explorer_tx(tx) for tx in block["transactions"]]
114 detail = {
115 "height": height,
116 "hash": block["hash"],
117 "prev": block["previous_hash"],
118 "merkleRoot": block["merkle_root"],
119 "version": block["version"],
120 "time": block["time"],
121 "bits": int(block["bits"], 16),
122 "nonce": block["nonce"],
123 "size": block["size"],
124 "txs": txs,
125 }
126 cb = txs[0]["vout"][0] if txs and txs[0]["vout"] else {}
127 summaries.append({
128 "height": height,
129 "hash": block["hash"],
130 "time": block["time"],
131 "nTx": len(txs),
132 "size": block["size"],
133 "cbValue": cb.get("value", 0),
134 "cbAddr": cb.get("address", ""),
135 })
136 (out / "block" / ("%d.json" % height)).write_text(
137 json.dumps(detail, sort_keys=True, separators=(",", ":")) + "\n",
138 encoding="ascii",
139 )
140
141 summaries.sort(key=lambda x: -x["height"])
142 (out / "blocks.json").write_text(
143 json.dumps({
144 "network": "bitflash",
145 "tipHeight": len(blocks) - 1,
146 "count": len(blocks),
147 "blocks": summaries,
148 }, sort_keys=True, separators=(",", ":")) + "\n",
149 encoding="ascii",
150 )
151 (out / "index.html").write_text(INDEX_HTML, encoding="utf-8")
152 (out / "style.css").write_text(STYLE_CSS, encoding="utf-8")
153 (out / "explorer.js").write_text(EXPLORER_JS, encoding="utf-8")
154 logo = Path(__file__).resolve().parents[1] / "docs" / "logo.png"
155 if logo.exists():
156 shutil.copyfile(logo, out / "logo.png")
157 return len(blocks), len(blocks) - 1
158
159
160def main(argv):
161 args = parse_args(argv)
162 raw_blocks = load_blocks(args)
163 if not raw_blocks:
164 raise SystemExit("no Bitflash blocks parsed")
165 count, tip = build_explorer(raw_blocks, args.out_dir)
166 print("explorer built: %d main-chain blocks, tip %d, into %s" % (count, tip, args.out_dir))
167 return 0
168
169
170INDEX_HTML = r"""<!doctype html>
171<html lang="en">
172<head>
173<meta charset="utf-8">
174<meta name="viewport" content="width=device-width, initial-scale=1">
175<title>Bitflash Block Explorer</title>
176<meta name="description" content="Read-only Bitflash block explorer generated from local blk*.dat files.">
177<link rel="icon" href="logo.png" type="image/png">
178<link rel="stylesheet" href="style.css">
179<script src="explorer.js" defer></script>
180</head>
181<body>
182<header class="topbar">
183 <a class="brand" href="https://bitflash.network/"><img src="logo.png" width="22" height="22" alt="">Bitflash</a>
184 <nav aria-label="Bitflash sites">
185 <a href="https://bitflash.network/">home</a>
186 <a href="https://git.bitflash.network/bitflash/bitflash">code</a>
187 <a href="https://docs.bitflash.network/">docs</a>
188 <a href="https://releases.bitflash.network/">downloads</a>
189 <a href="https://explorer.bitflash.network/" aria-current="page">explorer</a>
190 <a href="https://status.bitflash.network/">status</a>
191 </nav>
192 <span class="meta" id="tip"></span>
193</header>
194<div class="wrap">
195 <h1>Block explorer <button class="link" id="home" hidden>back to blocks</button></h1>
196 <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>
197 <div id="listview">
198 <input id="q" placeholder="Search by height or block hash">
199 <div class="scroll"><table>
200 <thead><tr><th>Height</th><th>Time (UTC)</th><th>Txs</th><th>Size</th><th>Coinbase</th><th>To</th></tr></thead>
201 <tbody id="rows"></tbody>
202 </table></div>
203 <p class="mut" id="more"></p>
204 </div>
205 <div id="detail" hidden></div>
206</div>
207</body>
208</html>
209"""
210
211
212STYLE_CSS = r""":root{
213 color-scheme: light dark;
214 --bg:#fff; --bg-2:#f6f6f6; --fg:#111; --dim:#6b6b6b; --line:#d7d7d7; --line-hard:#111;
215 --accent:#0b7a34; --accent-2:#12a047; --bar:3rem;
216 --mono: ui-monospace,"Cascadia Mono","Segoe UI Mono",Consolas,"DejaVu Sans Mono","Liberation Mono","Courier New",monospace;
217}
218@media (prefers-color-scheme: dark){:root{
219 --bg:#000; --bg-2:#0b0b0b; --fg:#dcdcdc; --dim:#8a8a8a; --line:#242424; --line-hard:#4a4a4a;
220 --accent:#2ee06a; --accent-2:#6bff9f;
221}}
222*{box-sizing:border-box;}
223html,body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.55 var(--mono);-webkit-text-size-adjust:100%;}
224a{color:var(--accent);}
225.topbar{display:flex;align-items:center;gap:1rem;flex-wrap:wrap;min-height:var(--bar);
226 padding:.5rem clamp(.9rem,2.5vw,3rem);border-bottom:1px solid var(--line);position:sticky;top:0;background:var(--bg);z-index:5;}
227.brand{display:flex;align-items:center;gap:.6rem;text-decoration:none;color:var(--fg);font-weight:bold;}
228.brand img{width:22px;height:22px;display:block;}
229.topbar nav{display:flex;flex-wrap:wrap;gap:.15rem 1.4rem;font-size:.86rem;}
230.topbar nav a{text-decoration:none;color:var(--dim);}
231.topbar nav a::before{content:"/";color:var(--line-hard);margin-right:.3rem;}
232.topbar nav a:hover{color:var(--fg);text-decoration:underline;}
233.topbar nav a[aria-current="page"]{color:var(--accent);font-weight:bold;}
234.topbar nav a[aria-current="page"]::before{color:var(--accent);}
235.topbar .meta{margin-left:auto;font-size:.76rem;color:var(--dim);letter-spacing:.06em;}
236.wrap{padding:1.4rem clamp(.9rem,2.5vw,3rem) 4rem;}
237h1{font-size:1rem;margin:0 0 .3rem;letter-spacing:.02em;}
238h2{font-size:.95rem;margin:1.2rem 0 .4rem;}
239.mut{color:var(--dim);}
240.note{color:var(--dim);font-size:.82rem;max-width:60rem;margin:.2rem 0 1rem;}
241input{width:100%;max-width:28rem;padding:.5rem .7rem;border:1px solid var(--line);background:var(--bg-2);color:var(--fg);font:inherit;font-size:.86rem;}
242input:focus{outline:none;border-color:var(--accent);}
243.scroll{overflow-x:auto;}
244table{width:100%;border-collapse:collapse;margin-top:.8rem;font-size:.86rem;}
245th,td{text-align:left;padding:.5rem .6rem;border-bottom:1px solid var(--line);font-variant-numeric:tabular-nums;}
246th{font-size:.72rem;text-transform:uppercase;letter-spacing:.1em;color:var(--dim);font-weight:normal;}
247tr.blk{cursor:pointer;}
248tr.blk:hover td{background:var(--bg-2);}
249code{font-size:.8rem;word-break:break-all;color:var(--fg);}
250.card{border:1px solid var(--line);background:var(--bg-2);padding:.9rem 1rem;margin-top:.8rem;}
251.kv{display:grid;grid-template-columns:11rem 1fr;gap:.25rem .8rem;font-size:.82rem;}
252.kv div:nth-child(odd){color:var(--dim);}
253.tx{border-top:1px solid var(--line);padding:.7rem 0;}
254.io{display:grid;grid-template-columns:1fr 1fr;gap:1rem;}
255@media (max-width:640px){.io{grid-template-columns:1fr;}.kv{grid-template-columns:7rem 1fr;}.topbar .meta{margin-left:0;width:100%;}}
256.pill{display:inline-block;font-size:.68rem;padding:.05rem .5rem;border:1px solid var(--line);color:var(--dim);text-transform:uppercase;letter-spacing:.05em;}
257button.link{background:none;border:none;color:var(--accent);cursor:pointer;font:inherit;padding:0;text-decoration:underline;}
258"""
259
260
261EXPLORER_JS = r"""
262var SUM = [], SHOWN = 200;
263function btf(sat){ return (sat/1e8).toFixed(8).replace(/0+$/,'').replace(/\.$/,'')+" BTF"; }
264function ts(t){ return new Date(t*1000).toISOString().replace('T',' ').replace('.000Z',' UTC'); }
265function short(h){ return h ? h.slice(0,10)+"..."+h.slice(-6) : "-"; }
266function E(tag, cls, text){
267 var e = document.createElement(tag);
268 if(cls) e.className = cls;
269 if(text !== undefined) e.textContent = text;
270 return e;
271}
272function code(text){ return E('code', '', text); }
273function clear(e){ while(e.firstChild) e.removeChild(e.firstChild); }
274function append(parent){
275 for(var i=1;i<arguments.length;i++) parent.appendChild(arguments[i]);
276 return parent;
277}
278function row(cells, cls){
279 var tr = E('tr', cls || '');
280 cells.forEach(function(c){ append(tr, E('td', '', c)); });
281 return tr;
282}
283function renderList(filter){
284 var q=(filter||"").trim().toLowerCase(), rows=document.getElementById('rows');
285 clear(rows);
286 var list = q ? SUM.filter(function(b){ return String(b.height)===q || b.hash.indexOf(q)===0; }) : SUM.slice(0,SHOWN);
287 if(!list.length){
288 var empty = E('tr');
289 var td = E('td', 'mut', 'no match');
290 td.colSpan = 6;
291 append(empty, td); append(rows, empty);
292 }
293 list.forEach(function(b){
294 var tr = row([String(b.height), ts(b.time), String(b.nTx), b.size+' B', btf(b.cbValue), ''], 'blk');
295 tr.dataset.h = b.height;
296 clear(tr.children[5]);
297 append(tr.children[5], code(b.cbAddr || '-'));
298 tr.onclick = function(){ openBlock(tr.dataset.h); };
299 append(rows, tr);
300 });
301 document.getElementById('more').textContent = (!q && SUM.length>SHOWN) ? ("showing latest "+SHOWN+" of "+SUM.length+" blocks; search to find any") : "";
302}
303function kv(parent, key, value, isCode){
304 append(parent, E('div', '', key));
305 append(parent, isCode ? append(E('div'), code(value)) : E('div', '', value));
306}
307function ioSide(title, items, render){
308 var side = E('div');
309 append(side, E('div', 'mut', title));
310 items.forEach(function(item){ append(side, render(item)); });
311 return side;
312}
313function openBlock(h){
314 fetch('block/'+h+'.json').then(function(r){return r.json();}).then(function(b){
315 var detail = document.getElementById('detail');
316 clear(detail);
317 append(detail, E('h2', '', 'Block '+b.height));
318 var card = E('div', 'card'), grid = E('div', 'kv');
319 kv(grid, 'Height', String(b.height));
320 kv(grid, 'Hash', b.hash, true);
321 var prev = E('div');
322 if(b.height > 0){
323 var btn = E('button', 'link');
324 append(btn, code(short(b.prev)));
325 btn.onclick = function(){ openBlock(b.height - 1); };
326 append(prev, btn);
327 } else {
328 append(prev, code(short(b.prev)));
329 }
330 append(grid, E('div', '', 'Previous'), prev);
331 kv(grid, 'Merkle root', b.merkleRoot, true);
332 kv(grid, 'Time (UTC)', ts(b.time));
333 kv(grid, 'Bits / Nonce', b.bits.toString(16)+' / '+b.nonce);
334 kv(grid, 'Size / Txs', b.size+' B / '+b.txs.length);
335 append(card, grid); append(detail, card);
336 b.txs.forEach(function(t, i){
337 var tx = E('div', 'tx');
338 var head = E('div');
339 append(head, code(t.txid));
340 if(i === 0) append(head, E('span', 'pill', 'coinbase'));
341 var io = E('div', 'io');
342 append(io, ioSide('Inputs', t.vin, function(v){
343 var d = E('div');
344 if(v.coinbase) append(d, E('span', 'pill', 'coinbase'));
345 else append(d, code(short(v.prev)+':'+v.vout));
346 return d;
347 }));
348 append(io, ioSide('Outputs', t.vout, function(o){
349 var d = E('div');
350 var to = o.address ? o.address : (o.pubkey ? 'pubkey '+o.pubkey.slice(0,16)+'...' : o.type);
351 append(d, document.createTextNode(btf(o.value)+' -> '), code(to));
352 return d;
353 }));
354 append(tx, head, io); append(detail, tx);
355 });
356 document.getElementById('listview').hidden = true;
357 detail.hidden = false;
358 document.getElementById('home').hidden = false;
359 window.scrollTo(0,0);
360 });
361}
362document.getElementById('home').onclick=function(){
363 document.getElementById('detail').hidden = true;
364 document.getElementById('listview').hidden = false;
365 this.hidden = true;
366};
367document.getElementById('q').oninput=function(){ renderList(this.value); };
368fetch('blocks.json').then(function(r){return r.json();}).then(function(d){
369 SUM=d.blocks; document.getElementById('tip').textContent='tip height '+d.tipHeight+' - '+d.count+' blocks';
370 renderList('');
371});
372"""
373
374
375if __name__ == "__main__":
376 raise SystemExit(main(sys.argv[1:]))