diff options
| -rwxr-xr-x | scripts/build-explorer.py | 243 |
1 files changed, 243 insertions, 0 deletions
diff --git a/scripts/build-explorer.py b/scripts/build-explorer.py new file mode 100755 index 0000000..7849e8a --- /dev/null +++ b/scripts/build-explorer.py | |||
| @@ -0,0 +1,243 @@ | |||
| 1 | #!/usr/bin/env python3 | ||
| 2 | # | ||
| 3 | # Build a static Bitflash block explorer (v0) from a node's blk0001.dat. | ||
| 4 | # Emits into <out-dir>: | ||
| 5 | # blocks.json summary of every main-chain block (newest first) | ||
| 6 | # block/<height>.json full detail per block (header + all txs) | ||
| 7 | # index.html self-contained viewer (vanilla JS, same-origin fetch) | ||
| 8 | # | ||
| 9 | # Scope (v0, deliberately): block list, block detail, and coinbase. No mempool, | ||
| 10 | # no per-address history. On this chain the coinbase payout key rotates every | ||
| 11 | # block, so an address index would be nearly useless; block-level transparency is | ||
| 12 | # what lets miners and users confirm the chain is honest. | ||
| 13 | # | ||
| 14 | # Usage: build-explorer.py <blk0001.dat> <out-dir> | ||
| 15 | |||
| 16 | import sys, os, json, hashlib, struct | ||
| 17 | |||
| 18 | MAGIC = bytes([0xbf, 0x20, 0x5c, 0xfd]) | ||
| 19 | ADDRESS_VERSION = 25 # Bitflash addresses start with "B" | ||
| 20 | |||
| 21 | def sha256d(b): return hashlib.sha256(hashlib.sha256(b).digest()).digest() | ||
| 22 | |||
| 23 | B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" | ||
| 24 | def b58check(payload): | ||
| 25 | v = bytes([ADDRESS_VERSION]) + payload | ||
| 26 | v += sha256d(v)[:4] | ||
| 27 | n = int.from_bytes(v, 'big') | ||
| 28 | s = "" | ||
| 29 | while n > 0: | ||
| 30 | n, r = divmod(n, 58); s = B58[r] + s | ||
| 31 | for byte in v: | ||
| 32 | if byte == 0: s = "1" + s | ||
| 33 | else: break | ||
| 34 | return s | ||
| 35 | |||
| 36 | def hash160(b): | ||
| 37 | return hashlib.new('ripemd160', hashlib.sha256(b).digest()).digest() | ||
| 38 | |||
| 39 | class R: | ||
| 40 | def __init__(s, b, o=0): s.b = b; s.o = o | ||
| 41 | def u(s, n): v = s.b[s.o:s.o+n]; s.o += n; return v | ||
| 42 | def i32(s): return struct.unpack('<i', s.u(4))[0] | ||
| 43 | def u32(s): return struct.unpack('<I', s.u(4))[0] | ||
| 44 | def i64(s): return struct.unpack('<q', s.u(8))[0] | ||
| 45 | def cs(s): | ||
| 46 | c = s.b[s.o]; s.o += 1 | ||
| 47 | if c < 253: return c | ||
| 48 | if c == 253: return struct.unpack('<H', s.u(2))[0] | ||
| 49 | if c == 254: return struct.unpack('<I', s.u(4))[0] | ||
| 50 | return struct.unpack('<Q', s.u(8))[0] | ||
| 51 | def script(s): n = s.cs(); return s.u(n) | ||
| 52 | |||
| 53 | def spk_info(spk): | ||
| 54 | # P2PKH: OP_DUP OP_HASH160 <20> OP_EQUALVERIFY OP_CHECKSIG | ||
| 55 | if len(spk) == 25 and spk[0] == 0x76 and spk[1] == 0xa9 and spk[2] == 0x14 and spk[23] == 0x88 and spk[24] == 0xac: | ||
| 56 | return {"type": "p2pkh", "address": b58check(spk[3:23])} | ||
| 57 | # P2PK: <pubkey> OP_CHECKSIG (65- or 33-byte key) | ||
| 58 | if len(spk) >= 35 and spk[-1] == 0xac: | ||
| 59 | if spk[0] == 0x41 and len(spk) == 67: | ||
| 60 | pk = spk[1:66]; return {"type": "p2pk", "pubkey": pk.hex(), "address": b58check(hash160(pk))} | ||
| 61 | if spk[0] == 0x21 and len(spk) == 35: | ||
| 62 | pk = spk[1:34]; return {"type": "p2pk", "pubkey": pk.hex(), "address": b58check(hash160(pk))} | ||
| 63 | return {"type": "other", "hex": spk.hex()[:80]} | ||
| 64 | |||
| 65 | def parse_tx(r): | ||
| 66 | start = r.o | ||
| 67 | r.i32() | ||
| 68 | nin = r.cs(); vin = [] | ||
| 69 | for _ in range(nin): | ||
| 70 | ph = r.u(32); idx = r.u32(); r.script(); r.u32() | ||
| 71 | coinbase = (ph == b'\x00' * 32 and idx == 0xffffffff) | ||
| 72 | vin.append({"coinbase": coinbase, "prev": (None if coinbase else ph[::-1].hex()), "vout": (None if coinbase else idx)}) | ||
| 73 | nout = r.cs(); vout = [] | ||
| 74 | for _ in range(nout): | ||
| 75 | val = r.i64(); spk = r.script(); vout.append(dict({"value": val}, **spk_info(spk))) | ||
| 76 | r.u32() | ||
| 77 | raw = r.b[start:r.o] | ||
| 78 | return {"txid": sha256d(raw)[::-1].hex(), "vin": vin, "vout": vout, "size": len(raw)} | ||
| 79 | |||
| 80 | def main(): | ||
| 81 | if len(sys.argv) != 3: | ||
| 82 | print("usage: build-explorer.py <blk0001.dat> <out-dir>", file=sys.stderr); sys.exit(2) | ||
| 83 | blkpath, out = sys.argv[1], sys.argv[2] | ||
| 84 | data = open(blkpath, 'rb').read() | ||
| 85 | blocks = {}; o = 0; n = len(data) | ||
| 86 | while o + 8 <= n: | ||
| 87 | if data[o:o+4] != MAGIC: o += 1; continue | ||
| 88 | o += 4; size = struct.unpack('<I', data[o:o+4])[0]; o += 4 | ||
| 89 | blk = data[o:o+size]; o += size | ||
| 90 | if len(blk) < 80: continue | ||
| 91 | h = sha256d(blk[:80])[::-1].hex() | ||
| 92 | r = R(blk, 0) | ||
| 93 | ver = r.i32(); prev = r.u(32)[::-1].hex(); merk = r.u(32)[::-1].hex() | ||
| 94 | t = r.u32(); bits = r.u32(); nonce = r.u32() | ||
| 95 | ntx = r.cs(); txs = [] | ||
| 96 | try: | ||
| 97 | for _ in range(ntx): txs.append(parse_tx(r)) | ||
| 98 | except Exception: | ||
| 99 | continue | ||
| 100 | blocks[h] = {"hash": h, "prev": prev, "merkleRoot": merk, "version": ver, | ||
| 101 | "time": t, "bits": bits, "nonce": nonce, "size": size, "txs": txs} | ||
| 102 | if not blocks: | ||
| 103 | print("no blocks parsed", file=sys.stderr); sys.exit(1) | ||
| 104 | |||
| 105 | hc = {} | ||
| 106 | def height(h): | ||
| 107 | chain = []; cur = h | ||
| 108 | while cur in blocks and cur not in hc: | ||
| 109 | chain.append(cur); cur = blocks[cur]["prev"] | ||
| 110 | base = hc.get(cur, 0) | ||
| 111 | for hh in reversed(chain): base += 1; hc[hh] = base | ||
| 112 | return hc[h] | ||
| 113 | for h in blocks: height(h) | ||
| 114 | tip = max(blocks, key=lambda h: hc[h]) | ||
| 115 | chain = []; cur = tip | ||
| 116 | while cur in blocks: chain.append(cur); cur = blocks[cur]["prev"] | ||
| 117 | chain.reverse() | ||
| 118 | |||
| 119 | os.makedirs(os.path.join(out, "block"), exist_ok=True) | ||
| 120 | summaries = [] | ||
| 121 | for h in chain: | ||
| 122 | b = blocks[h]; H = hc[h] | ||
| 123 | cb = b["txs"][0]["vout"][0] if b["txs"] and b["txs"][0]["vout"] else {} | ||
| 124 | summaries.append({"height": H, "hash": h, "time": b["time"], "nTx": len(b["txs"]), | ||
| 125 | "size": b["size"], "cbValue": cb.get("value", 0), "cbAddr": cb.get("address", "")}) | ||
| 126 | json.dump(dict({"height": H}, **b), open(os.path.join(out, "block", "%d.json" % H), "w"), separators=(',', ':')) | ||
| 127 | summaries.sort(key=lambda x: -x["height"]) | ||
| 128 | json.dump({"network": "bitflash", "tipHeight": hc[tip], "count": len(chain), "blocks": summaries}, | ||
| 129 | open(os.path.join(out, "blocks.json"), "w"), separators=(',', ':')) | ||
| 130 | open(os.path.join(out, "index.html"), "w").write(INDEX_HTML) | ||
| 131 | print("explorer built: %d main-chain blocks, tip %d, into %s" % (len(chain), hc[tip], out)) | ||
| 132 | |||
| 133 | |||
| 134 | INDEX_HTML = r"""<!doctype html> | ||
| 135 | <html lang="en"> | ||
| 136 | <head> | ||
| 137 | <meta charset="utf-8"> | ||
| 138 | <meta name="viewport" content="width=device-width, initial-scale=1"> | ||
| 139 | <title>Bitflash Block Explorer</title> | ||
| 140 | <style> | ||
| 141 | :root { color-scheme: light dark; --bg:#fff; --fg:#111; --mut:#666; --line:#e2e2e2; --card:#fafafa; --acc:#2b6cb0; } | ||
| 142 | @media (prefers-color-scheme: dark){ :root{ --bg:#0e0f11; --fg:#e6e6e6; --mut:#9aa0a6; --line:#26282c; --card:#16181b; --acc:#63b3ed; } } | ||
| 143 | * { box-sizing: border-box; } | ||
| 144 | body { font: 15px/1.5 system-ui, sans-serif; margin:0; background:var(--bg); color:var(--fg); } | ||
| 145 | header { padding:20px 24px; border-bottom:1px solid var(--line); display:flex; gap:16px; align-items:baseline; flex-wrap:wrap; } | ||
| 146 | h1 { font-size:18px; margin:0; } | ||
| 147 | .mut { color:var(--mut); } | ||
| 148 | .wrap { padding:16px 24px; max-width:1100px; margin:0 auto; } | ||
| 149 | 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; } | ||
| 150 | table { width:100%; border-collapse:collapse; margin-top:12px; } | ||
| 151 | th,td { text-align:left; padding:9px 10px; border-bottom:1px solid var(--line); font-variant-numeric:tabular-nums; } | ||
| 152 | th { font-size:12px; text-transform:uppercase; letter-spacing:.05em; color:var(--mut); } | ||
| 153 | tr.blk { cursor:pointer; } | ||
| 154 | tr.blk:hover td { background:var(--card); } | ||
| 155 | code { font-size:12px; word-break:break-all; } | ||
| 156 | .scroll { overflow-x:auto; } | ||
| 157 | a { color:var(--acc); text-decoration:none; } | ||
| 158 | .card { border:1px solid var(--line); border-radius:10px; background:var(--card); padding:14px 16px; margin-top:12px; } | ||
| 159 | .kv { display:grid; grid-template-columns:150px 1fr; gap:4px 12px; font-size:13px; } | ||
| 160 | .kv div:nth-child(odd){ color:var(--mut); } | ||
| 161 | .tx { border-top:1px solid var(--line); padding:10px 0; } | ||
| 162 | .io { display:grid; grid-template-columns:1fr 1fr; gap:16px; } | ||
| 163 | @media (max-width:640px){ .io{ grid-template-columns:1fr; } .kv{ grid-template-columns:110px 1fr; } } | ||
| 164 | .pill { display:inline-block; font-size:11px; padding:1px 7px; border-radius:99px; border:1px solid var(--line); color:var(--mut); } | ||
| 165 | button.link { background:none; border:none; color:var(--acc); cursor:pointer; font:inherit; padding:0; } | ||
| 166 | .note { color:var(--mut); font-size:13px; max-width:820px; } | ||
| 167 | </style> | ||
| 168 | </head> | ||
| 169 | <body> | ||
| 170 | <header> | ||
| 171 | <h1>Bitflash Block Explorer</h1> | ||
| 172 | <span class="mut" id="tip"></span> | ||
| 173 | <span style="flex:1"></span> | ||
| 174 | <button class="link" id="home" style="display:none">back to blocks</button> | ||
| 175 | </header> | ||
| 176 | <div class="wrap"> | ||
| 177 | <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. On Bitflash the coinbase payout key rotates every block, so an address index would say almost nothing; what matters is that every block and every coinbase is here to check.</p> | ||
| 178 | <div id="listview"> | ||
| 179 | <input id="q" placeholder="Search by height or block hash"> | ||
| 180 | <div class="scroll"><table> | ||
| 181 | <thead><tr><th>Height</th><th>Time (UTC)</th><th>Txs</th><th>Size</th><th>Coinbase</th><th>To</th></tr></thead> | ||
| 182 | <tbody id="rows"></tbody> | ||
| 183 | </table></div> | ||
| 184 | <p class="mut" id="more"></p> | ||
| 185 | </div> | ||
| 186 | <div id="detail" style="display:none"></div> | ||
| 187 | </div> | ||
| 188 | <script> | ||
| 189 | var SUM = [], BYH = {}, SHOWN = 200; | ||
| 190 | function btf(sat){ return (sat/1e8).toFixed(8).replace(/0+$/,'').replace(/\.$/,'')+" BTF"; } | ||
| 191 | function ts(t){ return new Date(t*1000).toISOString().replace('T',' ').replace('.000Z',' UTC'); } | ||
| 192 | function esc(s){ return String(s).replace(/[&<>]/g,function(c){return {'&':'&','<':'<','>':'>'}[c];}); } | ||
| 193 | function short(h){ return h ? h.slice(0,10)+"…"+h.slice(-6) : "-"; } | ||
| 194 | |||
| 195 | function renderList(filter){ | ||
| 196 | var q=(filter||"").trim().toLowerCase(), rows=document.getElementById('rows'), out=""; | ||
| 197 | var list = q ? SUM.filter(function(b){ return String(b.height)===q || b.hash.indexOf(q)===0; }) : SUM.slice(0,SHOWN); | ||
| 198 | for(var i=0;i<list.length;i++){ var b=list[i]; | ||
| 199 | out += '<tr class="blk" data-h="'+b.height+'"><td>'+b.height+'</td><td>'+ts(b.time)+'</td><td>'+b.nTx+'</td><td>'+b.size+' B</td><td>'+btf(b.cbValue)+'</td><td><code>'+(b.cbAddr?esc(b.cbAddr):'-')+'</code></td></tr>'; | ||
| 200 | } | ||
| 201 | rows.innerHTML = out || '<tr><td colspan="6" class="mut">no match</td></tr>'; | ||
| 202 | document.getElementById('more').textContent = (!q && SUM.length>SHOWN) ? ("showing latest "+SHOWN+" of "+SUM.length+" blocks; search to find any") : ""; | ||
| 203 | Array.prototype.forEach.call(rows.querySelectorAll('tr.blk'), function(tr){ tr.onclick=function(){ openBlock(tr.getAttribute('data-h')); }; }); | ||
| 204 | } | ||
| 205 | function ioSide(title, items, html){ | ||
| 206 | var s='<div><div class="mut">'+title+'</div>'; for(var i=0;i<items.length;i++){ s+=html(items[i]); } return s+'</div>'; | ||
| 207 | } | ||
| 208 | function openBlock(h){ | ||
| 209 | fetch('block/'+h+'.json').then(function(r){return r.json();}).then(function(b){ | ||
| 210 | var s='<div class="card"><div class="kv">'+ | ||
| 211 | '<div>Height</div><div>'+b.height+'</div>'+ | ||
| 212 | '<div>Hash</div><div><code>'+esc(b.hash)+'</code></div>'+ | ||
| 213 | '<div>Previous</div><div><button class="link" onclick="openBlock('+(b.height-1)+')"><code>'+esc(short(b.prev))+'</code></button></div>'+ | ||
| 214 | '<div>Merkle root</div><div><code>'+esc(b.merkleRoot)+'</code></div>'+ | ||
| 215 | '<div>Time (UTC)</div><div>'+ts(b.time)+'</div>'+ | ||
| 216 | '<div>Bits / Nonce</div><div>'+b.bits.toString(16)+' / '+b.nonce+'</div>'+ | ||
| 217 | '<div>Size / Txs</div><div>'+b.size+' B / '+b.txs.length+'</div>'+ | ||
| 218 | '</div></div>'; | ||
| 219 | for(var i=0;i<b.txs.length;i++){ var t=b.txs[i]; | ||
| 220 | s+='<div class="tx"><div><code>'+esc(t.txid)+'</code> '+(i===0?'<span class="pill">coinbase</span>':'')+'</div><div class="io">'; | ||
| 221 | s+=ioSide('Inputs', t.vin, function(v){ return '<div>'+(v.coinbase?'<span class="pill">coinbase</span>':'<code>'+esc(short(v.prev))+':'+v.vout+'</code>')+'</div>'; }); | ||
| 222 | s+=ioSide('Outputs', t.vout, function(o){ var to=o.address?esc(o.address):(o.pubkey?'pubkey '+esc(o.pubkey.slice(0,16))+'…':esc(o.type)); return '<div>'+btf(o.value)+' → <code>'+to+'</code></div>'; }); | ||
| 223 | s+='</div></div>'; | ||
| 224 | } | ||
| 225 | document.getElementById('listview').style.display='none'; | ||
| 226 | var d=document.getElementById('detail'); d.style.display='block'; d.innerHTML='<h2 style="font-size:16px">Block '+b.height+'</h2>'+s; | ||
| 227 | document.getElementById('home').style.display='inline'; | ||
| 228 | window.scrollTo(0,0); | ||
| 229 | }); | ||
| 230 | } | ||
| 231 | document.getElementById('home').onclick=function(){ document.getElementById('detail').style.display='none'; document.getElementById('listview').style.display='block'; this.style.display='none'; }; | ||
| 232 | document.getElementById('q').oninput=function(){ renderList(this.value); }; | ||
| 233 | fetch('blocks.json').then(function(r){return r.json();}).then(function(d){ | ||
| 234 | SUM=d.blocks; document.getElementById('tip').textContent='tip height '+d.tipHeight+' · '+d.count+' blocks'; | ||
| 235 | renderList(''); | ||
| 236 | }); | ||
| 237 | </script> | ||
| 238 | </body> | ||
| 239 | </html> | ||
| 240 | """ | ||
| 241 | |||
| 242 | if __name__ == "__main__": | ||
| 243 | main() | ||