Add live mempool and block explorer interactions
This commit is contained in:
1 parent
54c2108a98
commit
c6d3af5dfd
1 file changed
+92
-14
+92
-14
@@ -7,15 +7,79 @@ import { STORAGE_KEY, readLab, saveLab, decodeLab, encodeLab } from './storage.j
|
||||
const $ = id => document.getElementById(id);
|
||||
const btc = sats => (sats / 1e8).toFixed(8);
|
||||
const short = id => `${id.slice(0, 12)}…${id.slice(-8)}`;
|
||||
const THEME_KEY = 'super-testnet-theme';
|
||||
let lab, busy = false, reviewed = null, failed = false;
|
||||
|
||||
const message = (text, error = false) => { $('status').textContent = text; $('status').classList.toggle('error', error); };
|
||||
const el = (tag, text, className) => { const node = document.createElement(tag); if (text !== undefined) node.textContent = text; if (className) node.className = className; return node; };
|
||||
const link = (text, hash) => { const node = el('a', text); node.href = hash; return node; };
|
||||
const byteSize = raws => Math.round(raws.reduce((sum, raw) => sum + raw.length / 2, 0));
|
||||
const age = time => {
|
||||
const seconds = Math.max(0, Math.floor((Date.now() - time) / 1000));
|
||||
if (seconds < 5) return 'just now';
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
return `${Math.floor(minutes / 60)}h ago`;
|
||||
};
|
||||
function txKind(record, chain) {
|
||||
if (record.coinbase) return 'COINBASE';
|
||||
if (record.inscription) return 'INSCRIPTION REVEAL';
|
||||
const reveal = [...chain.transactions.values()].find(tx => tx.inscription?.commitTxid === record.txid);
|
||||
return reveal ? 'INSCRIPTION COMMIT' : 'TRANSACTION';
|
||||
}
|
||||
function setTheme(theme, persist = false) {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
$('theme-toggle').textContent = theme === 'dark' ? '☀ Light' : '☾ Dark';
|
||||
$('theme-toggle').setAttribute('aria-label', `Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`);
|
||||
if (persist) localStorage.setItem(THEME_KEY, theme);
|
||||
}
|
||||
function initTheme() {
|
||||
const saved = localStorage.getItem(THEME_KEY);
|
||||
setTheme(saved === 'light' || saved === 'dark' ? saved : (matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'));
|
||||
}
|
||||
function download(name, contents) {
|
||||
const url = URL.createObjectURL(new Blob([contents], { type: 'application/json' }));
|
||||
const anchor = link('', url); anchor.download = name; anchor.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
function closeReview() { reviewed = null; $('review-panel').hidden = true; }
|
||||
|
||||
function renderMempool(chain) {
|
||||
const pane = $('mempool-list');
|
||||
pane.replaceChildren();
|
||||
const pending = [...(chain?.transactions?.values?.() ?? [])].filter(record => !record.height && !record.coinbase);
|
||||
if (!pending.length) {
|
||||
pane.append(el('div', 'Mempool empty. Create an inscription and its commit + reveal transactions will wait here until you mine.', 'mempool-empty'));
|
||||
return;
|
||||
}
|
||||
for (const record of pending) {
|
||||
const button = el('button', undefined, 'mempool-card');
|
||||
button.append(el('strong', txKind(record, chain)));
|
||||
button.append(el('span', short(record.txid)));
|
||||
button.append(el('span', `${Math.round(record.raw.length / 2).toLocaleString()} bytes · 500 fake sat fee`));
|
||||
if (record.inscription) button.append(el('span', `“${record.inscription.text.slice(0, 34)}${record.inscription.text.length > 34 ? '…' : ''}”`));
|
||||
button.onclick = () => { location.hash = `tx/${record.txid}`; };
|
||||
pane.append(button);
|
||||
}
|
||||
}
|
||||
function renderBlocks(chain) {
|
||||
const pane = $('blocks');
|
||||
pane.replaceChildren();
|
||||
if (!chain?.blocks.length) {
|
||||
pane.append(el('p', 'No blocks yet. Mine your first one above.', 'muted'));
|
||||
return;
|
||||
}
|
||||
for (const block of chain.blocks.toReversed()) {
|
||||
const button = el('button', undefined, 'block-card');
|
||||
button.append(el('span', `HEIGHT ${block.height}`, 'block-accent'));
|
||||
button.append(el('strong', `${block.raws.length} tx`));
|
||||
button.append(el('span', `${byteSize(block.raws).toLocaleString()} bytes · ${age(block.time)}`));
|
||||
button.append(el('span', short(block.hash)));
|
||||
button.onclick = () => { location.hash = `block/${block.hash}`; };
|
||||
pane.append(button);
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
const wallet = lab?.wallet, chain = lab?.chain;
|
||||
const disabled = busy || failed;
|
||||
@@ -52,15 +116,11 @@ function render() {
|
||||
if (item.height) trail.append(link(`View block ${item.height} ↗`, `#block/${chain.blocks[item.height - 1].hash}`));
|
||||
card.append(trail); $('collectibles').append(card);
|
||||
}
|
||||
$('blocks').replaceChildren();
|
||||
if (!chain?.blocks.length) $('blocks').append(el('p', 'No blocks yet. Mine your first one above.', 'muted'));
|
||||
for (const block of (chain?.blocks ?? []).toReversed()) {
|
||||
const button = el('button', undefined, 'block-card');
|
||||
button.append(el('strong', `Block ${block.height}`), el('span', `${block.raws.length} transaction${block.raws.length === 1 ? '' : 's'} · ${short(block.hash)}`));
|
||||
button.onclick = () => { location.hash = `block/${block.hash}`; }; $('blocks').append(button);
|
||||
}
|
||||
renderMempool(chain);
|
||||
renderBlocks(chain);
|
||||
inspect(false);
|
||||
}
|
||||
|
||||
async function mutate(action, success) {
|
||||
if (busy || failed) return;
|
||||
busy = true; render();
|
||||
@@ -75,6 +135,8 @@ async function mutate(action, success) {
|
||||
} catch (error) { message(error.message, true); }
|
||||
finally { busy = false; render(); }
|
||||
}
|
||||
|
||||
$('theme-toggle').onclick = () => setTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark', true);
|
||||
$('create-wallet').onclick = () => mutate(next => {
|
||||
if (next.wallet) throw new Error('A wallet already exists in this browser. Reload to use it.');
|
||||
next.wallet = TestWallet.create();
|
||||
@@ -82,7 +144,7 @@ $('create-wallet').onclick = () => mutate(next => {
|
||||
$('mine').onclick = () => mutate(next => {
|
||||
if (!next.wallet) throw new Error('Create or load a wallet first.');
|
||||
next.chain.mine(next.wallet.script);
|
||||
}, 'Block mined. You received 50 fake BTC and all pending transactions are now confirmed.');
|
||||
}, 'Block mined. Pending transactions moved from the mempool into your newest block.');
|
||||
$('payload').oninput = () => {
|
||||
closeReview(); const bytes = new TextEncoder().encode($('payload').value).length;
|
||||
$('byte-count').textContent = `${bytes} / ${MAX_BYTES} UTF-8 bytes`; render();
|
||||
@@ -102,7 +164,7 @@ $('sign').onclick = () => {
|
||||
mutate(next => {
|
||||
if (next.wallet?.address !== consent.address) throw new Error('Wallet changed. Review again before signing.');
|
||||
new InscriptionAdapter(next.chain, next.wallet).inscribe(consent.text);
|
||||
}, 'Inscription submitted and visible in your wallet. Mine a block to confirm it.');
|
||||
}, 'Inscription submitted. Its commit + reveal are now visible in the mempool; mine a block to confirm them.');
|
||||
};
|
||||
$('export-wallet').onclick = () => download('super-testnet-wallet.json', JSON.stringify(lab.wallet.backup(), null, 2));
|
||||
$('export-lab').onclick = () => {
|
||||
@@ -138,16 +200,31 @@ function inspect(scroll = true) {
|
||||
try {
|
||||
if (kind === 'block') {
|
||||
const block = rpc.call('getblock', [id]);
|
||||
pane.append(el('h2', `Block ${block.height}`)); add('Local block hash', block.hash); add('Previous block', block.previousblockhash);
|
||||
const source = chain.blocks.find(candidate => candidate.hash === id);
|
||||
pane.append(el('h2', `Block ${block.height}`));
|
||||
add('Local block hash', block.hash);
|
||||
if (block.previousblockhash && block.height > 1) {
|
||||
const previous = chain.blocks[block.height - 2];
|
||||
const p = el('p'); p.append(el('strong', 'Previous block: '), link(short(block.previousblockhash), `#block/${previous.hash}`)); pane.append(p);
|
||||
} else add('Previous block', block.previousblockhash);
|
||||
add('Mined', new Date(block.time * 1000).toISOString());
|
||||
pane.append(el('p', 'Synthetic local block; not a Bitcoin block header.', 'muted'));
|
||||
for (const txid of block.tx) { const p = el('p'); p.append(link(`Transaction ${txid}`, `#tx/${txid}`)); pane.append(p); }
|
||||
add('Transactions', block.tx.length);
|
||||
add('Approx. serialized size', `${byteSize(source.raws).toLocaleString()} bytes`);
|
||||
pane.append(el('p', 'Synthetic local block; not a Bitcoin block header and no proof-of-work is performed.', 'muted'));
|
||||
pane.append(el('h3', 'Transactions'));
|
||||
for (const txid of block.tx) {
|
||||
const record = chain.transactions.get(txid), p = el('p');
|
||||
p.append(link(`${txKind(record, chain)} · ${short(txid)}`, `#tx/${txid}`)); pane.append(p);
|
||||
}
|
||||
} else if (kind === 'tx' || kind === 'inscription') {
|
||||
const txid = kind === 'tx' ? id : id?.replace(/i0$/, '');
|
||||
const record = chain.transactions.get(txid);
|
||||
if (!record || (kind === 'inscription' && record.inscription?.id !== id)) throw new Error('Transaction or inscription not found on this local chain.');
|
||||
pane.append(el('h2', kind === 'inscription' ? 'Inscription detail' : record.coinbase ? 'Coinbase transaction' : 'Transaction detail'));
|
||||
add('Transaction ID', txid); add('Status', record.height ? `${chain.blocks.length - record.height + 1} confirmation(s)` : 'Pending — mine a block');
|
||||
add('Type', txKind(record, chain));
|
||||
add('Transaction ID', txid);
|
||||
add('Status', record.height ? `${chain.blocks.length - record.height + 1} confirmation(s)` : 'Pending in mempool — mine a block');
|
||||
add('Serialized size', `${Math.round(record.raw.length / 2).toLocaleString()} bytes`);
|
||||
if (record.height) { const p = el('p'); p.append(link(`Inspect block ${record.height} ↗`, `#block/${chain.blocks[record.height - 1].hash}`)); pane.append(p); }
|
||||
if (record.inscription) {
|
||||
add('Inscription ID', record.inscription.id); add('Content type', record.inscription.contentType);
|
||||
@@ -180,4 +257,5 @@ function load() {
|
||||
catch (error) { failed = true; message(`Cannot load saved lab: ${error.message} Export it for recovery, then reset this local lab.`, true); }
|
||||
render();
|
||||
}
|
||||
load();
|
||||
initTheme();
|
||||
load();
|
||||
Reference in new issue
Block a user