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:
Diffstat (limited to 'scripts/build-explorer.py')
-rwxr-xr-xscripts/build-explorer.py336
1 files changed, 336 insertions, 0 deletions
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:]))