Files
orgchain/src/main.js
T
teahubxyzandClaude Opus 5.5 2ea7884688 orgchain: the tree (lineage lab keys, act as any node, graduation by claim + countersign)
Start the tree: a fresh 24-word lab root and docs/lineage.md's drawing as keys. Orgs get BIP-85 seeds from their
parent (m/83696968'/39'/0'/24'/i'), wallets are BIP-86 m/86'/1'/account'/0/0, npubs are NIP-06; seats are accounts
1..n of their org's seed; indices are never reused. Act as any node to mine, inscribe or sign with its key.

Graduation retires a leaf org on a derived edge and adopts a fresh root in its place. The handoff is OpenOrdex's
buy-order direction: the recipient builds a 2-in/2-out transaction and signs its fee input first, so its signature
fixes where the inscription goes; the holder then countersigns the inscription input. Both are default signatures,
so chain.js is unchanged. The keyring and open claims save with the lab (optional fields, same storage key).

Tests: BIP-85, BIP-86 and NIP-06 spec vectors plus the handoff and its failure cases (36/36 unit).
tests/tree.spec.js rehearses a full graduation in Chromium at desktop and phone sizes, with layout checks;
tests/flow.spec.js now follows this branch's copy and theme, and waits for a mined block before reloading
(28/28 e2e).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:47:56 -07:00

420 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { RECURSIVE_DEMO } from './recursion.js';
import './style.css';
import './lineage.css';
import { encodeMetadata, parseMetadataJSON } from './adapters/metadata.js';
import { MetadataIndex, deriveProtocol } from './adapters/metadata-index.js';
import { LocalOrdAPI } from './adapters/local-ord-api.js';
import { mineLab, setMiner, minerStatus } from './miner.js';
import { TestWallet, ExtensionWallet, loadWalletBackup } from './adapters/wallet.js';
import { inscribeWithProvider } from './adapters/extension-inscription.js';
import { LocalRPC } from './adapters/chain.js';
import { InscriptionAdapter, MAX_BYTES, MIME_TYPES } from './adapters/inscription.js';
import { preview, clearPreviews } from './preview.js';
import { STORAGE_KEY, readLab, mutateLab, decodeLab, encodeLab } from './storage.js';
import { initTree, renderTree, actingLabel } from './tree.js';
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 = 'orgchain-theme';
let fileBytes = null, fileLoading = false, fileRevision = 0;
let lab, busy = false, reviewed = null, failed = false;
let inscriboorProvider = null;
const INSCRIBOOR_RELEASE = { version: '0.2.4', url: 'https://supertestnet.lol/downloads/inscriboor-extension-v0.2.4.zip' };
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: dark)').matches ? 'dark' : 'light'));
}
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;
clearPreviews($('review-preview')); $('review-preview').replaceChildren();
$('review-metadata').textContent = ''; $('review-text').textContent = ''; $('review-parent').textContent = '';
}
const composerBody = () => ({ contentType: $('mime-type').value, bytes: fileBytes ?? new TextEncoder().encode($('payload').value) });
function updateCount() { $('byte-count').textContent = `${composerBody().bytes.length} / ${MAX_BYTES} bytes`; }
function resetComposer() {
fileRevision++; fileBytes = null; $('body-file').value = ''; $('payload').value = '';
$('delegate').value = ''; $('pointer').value = ''; $('reinscribe').value = ''; $('metaprotocol').value = ''; $('metadata').value = ''; updateMetadataCount(); $('parent').value = ''; closeReview(); updateCount();
}
function lineage(item, chain) {
const p = el('p', undefined, 'tx-links');
if (item.delegate) p.append(link(`Delegate ${short(item.delegate)}`, `#inscription/${item.delegate}`));
for (const record of chain.transactions.values()) if (record.inscription?.delegate === item.id)
p.append(link(`Delegated by ${short(record.inscription.id)}`, `#inscription/${record.inscription.id}`));
if (item.sat !== undefined) {
p.append(el('span', `Synthetic sat ${item.sat} · stack position ${item.reinscriptionPosition} · ${JSON.stringify(chain.satLocation(item.sat))}`));
for (const record of chain.transactions.values()) if (record.inscription?.sat === item.sat)
p.append(link(`Sat history #${record.inscription.reinscriptionPosition}`, `#inscription/${record.inscription.id}`));
}
if (item.parent) p.append(link(`Parent ${short(item.parent)}`, `#inscription/${item.parent}`));
for (const record of chain.transactions.values()) if (record.inscription?.parent === item.id)
p.append(link(`Child ${short(record.inscription.id)}`, `#inscription/${record.inscription.id}`));
return p;
}
function updateMetadataCount() {
try {
const value = parseMetadataJSON($('metadata').value);
$('metadata-count').textContent = `${value === undefined ? 0 : encodeMetadata(value).length} / 2048 CBOR bytes · ord tag 5 · JSON is authoring format only`;
} catch (error) { $('metadata-count').textContent = `${error.message} Limit: 2048 CBOR bytes.`; }
}
function metadataView(item) {
const node = el('div', undefined, 'metadata-document');
if (item.metadata !== undefined) node.append(el('strong', 'Decoded metadata'), el('pre', JSON.stringify(item.metadata, null, 2)));
if (item.metaprotocol) node.append(el('strong', `Metaprotocol: ${item.metaprotocol}`));
return node;
}
function renderIndexes(chain) {
if (!chain) return;
const index = new MetadataIndex(chain), selected = $('protocol').value;
const stats = index.stats;
$('index-stats').textContent = `Metadata index: ${stats.events} events · ${stats.withMetadata} with metadata · ${stats.confirmed} confirmed · ${stats.pending} pending · ${stats.protocols.length} protocols`;
$('protocol').replaceChildren(new Option('Select a protocol', ''));
index.protocols.forEach(name => $('protocol').append(new Option(JSON.stringify(name), JSON.stringify(name))));
if ([...$('protocol').options].some(o => o.value === selected)) $('protocol').value = selected;
const protocol = $('protocol').value ? JSON.parse($('protocol').value) : null;
const events = protocol === null ? [] : index.forProtocol(protocol);
$('protocol-stats').textContent = `${events.length} events · ${events.filter(e => e.status === 'confirmed').length} confirmed · ${events.filter(e => e.status === 'pending').length} pending`;
$('protocol-events').replaceChildren();
for (const event of events) {
const row = el('article'); row.append(link(short(event.id), `#inscription/${event.id}`), el('pre', JSON.stringify(event, null, 2)));
$('protocol-events').append(row);
}
$('derived-state').textContent = !$('reducer').value ? 'No reducer selected.' : protocol === null ? 'Select a protocol first.' : JSON.stringify(deriveProtocol(index, protocol, $('reducer').value), null, 2);
}
$('protocol').onchange = $('reducer').onchange = () => renderIndexes(lab.chain);
const apiPresets = ['/r/blockheight', '/r/blockhash/1', '/r/blockinfo/1', '/r/blockinfo/:hash', '/content/:id', '/r/inscription/:id', '/r/metadata/:id', '/r/parents/:id', '/r/children/:id', '/r/tx/:txid', '/local/metadata-index', '/local/protocols', '/local/protocol?name=example'];
apiPresets.forEach(path => $('api-preset').append(new Option(path, path)));
$('api-preset').onchange = () => {
const event = new MetadataIndex(lab.chain).events.at(-1);
$('api-path').value = $('api-preset').value.replace(':id', event?.id ?? ':id').replace(':txid', event?.id.slice(0, -2) ?? ':txid').replace(':hash', lab.chain.blocks.at(-1)?.hash ?? ':hash');
};
$('api-try').onclick = () => {
try {
const response = new LocalOrdAPI(lab.chain).request($('api-path').value);
const binary = response.body instanceof Uint8Array;
$('api-response').textContent = JSON.stringify({ ...response, ...(binary ? { body: Array.from(response.body), encoding: 'byte array', textPreview: new TextDecoder().decode(response.body) } : {}) }, null, 2);
} catch (error) { $('api-response').textContent = error.message; }
};
$('api-copy').onclick = async () => {
try { await navigator.clipboard.writeText($('api-response').textContent); message('Local API response copied.'); }
catch (error) { message(`Could not copy response: ${error.message}`, 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.contentType} · ${record.inscription.bytes.length} bytes`));
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;
$('empty-wallet').hidden = !!wallet; $('active-wallet').hidden = !wallet;
$('wallet-state').textContent = wallet ? (wallet.kind === 'extension-wallet' ? 'INSCRIBOOR / TEST' : actingLabel(lab) ?? 'LOCAL / TEST ONLY') : 'NOT CREATED';
$('create-wallet').disabled = disabled; $('connect-inscriboor').disabled = disabled; $('load-wallet').disabled = disabled;
const inscriboorNeedsUpdate = !!inscriboorProvider && inscriboorProvider.version !== INSCRIBOOR_RELEASE.version;
$('connect-inscriboor').textContent = inscriboorNeedsUpdate ? 'Update Inscriboor ↓' : inscriboorProvider ? 'Connect Inscriboor ↗' : 'Get Inscriboor ↓';
$('inscriboor-status').textContent = inscriboorNeedsUpdate ? `Inscriboor ${inscriboorProvider.version ?? 'older build'} detected · ${INSCRIBOOR_RELEASE.version} available.` : inscriboorProvider ? `Inscriboor ${inscriboorProvider.version ?? ''} detected · extension key stays outside this page.` : 'Inscriboor not detected. Install it, refresh, then connect.';
$('mine').disabled = disabled || !wallet;
$('miner-interval').disabled = disabled || !wallet;
$('miner-interval').value = String(lab?.miner?.interval ?? 0);
$('miner-status').textContent = minerStatus(lab);
$('payload').disabled = disabled || !wallet || !!fileBytes || fileLoading || $('mime-type').value.startsWith('image/') && $('mime-type').value !== 'image/svg+xml';
for (const id of ['mime-type', 'body-file', 'parent', 'metadata', 'metaprotocol', 'delegate', 'pointer', 'reinscribe', 'recursive-demo']) $(id).disabled = disabled || !wallet || fileLoading;
$('review').disabled = disabled || !wallet || !chain?.coins(wallet.script).some(c => !c.inscription) || fileLoading || !composerBody().bytes.length;
$('sign').disabled = disabled;
$('export-wallet').disabled = disabled; $('export-lab').disabled = busy;
$('height').textContent = chain?.blocks.length ?? 0;
$('pending').textContent = chain?.mempool.length ?? 0;
if (wallet) {
$('address').value = wallet.address;
$('balance').textContent = btc(chain.balance(wallet.script));
const reserved = chain.coins(wallet.script).filter(c => c.inscription).reduce((n,c) => n+c.value,0);
$('reserved').textContent = `${reserved.toLocaleString()} sats protected in collectibles · balance includes pending outputs`;
$('export-wallet').textContent = wallet.kind === 'extension-wallet' ? 'Save wallet descriptor' : 'Save test wallet';
$('signing-note').textContent = wallet.kind === 'extension-wallet' ? 'The next button opens Inscriboor for approval. The page never receives the extension key.' : 'The next button signs locally with this browser test wallet and submits commit + reveal.';
}
const items = wallet ? new InscriptionAdapter(chain, wallet).list() : [];
const selectedParent = $('parent').value;
$('parent').replaceChildren(new Option('No parent', ''));
items.forEach(item => $('parent').append(new Option(short(item.id), item.id)));
$('parent').value = selectedParent;
const selectedSat = $('reinscribe').value;
$('reinscribe').replaceChildren(new Option('New sat', ''));
items.forEach(item => $('reinscribe').append(new Option(`Sat ${item.sat}: ${short(item.id)}`, item.id)));
$('reinscribe').value = selectedSat;
$('collection-count').textContent = items.length;
clearPreviews($('collectibles'));
$('collectibles').replaceChildren();
if (!items.length) {
const empty = el('p', 'Your first inscription belongs here.', 'empty');
empty.append(el('br'), el('span', 'Fund your wallet, write some text, and make it yours.'));
$('collectibles').append(empty);
}
for (const item of items.toReversed()) {
const card = el('article', undefined, 'collectible');
card.append(el('span', item.height ? `CONFIRMED · BLOCK ${item.height}` : 'PENDING · MINE TO CONFIRM', 'tag'), preview(item, new LocalOrdAPI(chain)));
card.append(link(`Inscription ${short(item.id)}`, `#inscription/${item.id}`));
const trail = el('p', undefined, 'tx-links');
trail.append(link('View reveal transaction ↗', `#tx/${item.txid}`));
if (item.height) trail.append(link(`View block ${item.height} ↗`, `#block/${chain.blocks[item.height - 1].hash}`));
card.append(metadataView(item), lineage(item, chain), trail); $('collectibles').append(card);
}
renderIndexes(chain);
renderMempool(chain);
renderBlocks(chain);
renderTree(lab, disabled);
inspect(false);
}
async function mutate(action, success, onSuccess = () => {}) {
if (busy || failed) return;
busy = true; render();
try {
const result = await mutateLab(action); lab = result.lab;
if (result.changed) { closeReview(); onSuccess(); message(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();
}, 'Test wallet created. Next: mine a block to get 50 fake BTC.');
$('connect-inscriboor').onclick = async () => {
try {
const provider = inscriboorProvider ?? window.inscriboor;
if (!provider?.request || provider.version !== INSCRIBOOR_RELEASE.version) { location.href = INSCRIBOOR_RELEASE.url; return; }
const account = (await provider.request({ method:'bitcoin_getAccounts' }))?.[0];
await mutate(next => {
if (next.wallet) throw new Error('A wallet already exists in this browser. Reset or restore a different lab first.');
next.wallet = new ExtensionWallet(account, provider);
}, 'Inscriboor connected. Mine a block, prepare an inscription, then approve it in the extension.');
} catch (error) { message(error.message, true); }
};
const mine = automatic => mutate(next => mineLab(next, automatic), 'Block mined. Pending transactions moved from the mempool into your newest block.');
$('mine').onclick = () => mine(false);
$('miner-interval').onchange = () => {
const interval = Number($('miner-interval').value);
mutate(next => setMiner(next, interval), interval ? `Auto miner set to ${interval}s.` : 'Auto mining OFF');
};
setInterval(() => {
$('miner-status').textContent = minerStatus(lab);
if (!busy && !failed && lab?.wallet && lab.miner.interval && Date.now() >= lab.miner.nextAt) mine(true);
}, 250);
$('metadata').oninput = () => { closeReview(); updateMetadataCount(); };
MIME_TYPES.forEach(type => $('mime-type').append(new Option(type, type)));
$('payload').oninput = () => { closeReview(); updateCount(); render(); };
$('parent').onchange = $('reinscribe').onchange = closeReview;
$('delegate').oninput = $('pointer').oninput = $('metaprotocol').oninput = closeReview;
$('recursive-demo').onclick = () => {
fileRevision++; fileBytes = null; $('body-file').value = ''; $('mime-type').value = 'text/html';
$('payload').value = RECURSIVE_DEMO; closeReview(); updateCount(); render();
};
$('mime-type').onchange = () => {
fileRevision++; fileBytes = null; $('body-file').value = ''; closeReview(); updateCount(); render();
};
$('body-file').accept = MIME_TYPES.join(',');
$('body-file').onchange = async event => {
const file = event.target.files[0], revision = ++fileRevision;
closeReview(); fileBytes = null;
if (!file) { updateCount(); render(); return; }
fileLoading = true; render();
try {
if (file.size > MAX_BYTES) throw new Error(`Body must be at most ${MAX_BYTES} bytes.`);
if (!MIME_TYPES.includes(file.type)) throw new Error('Unsupported file MIME type.');
const bytes = new Uint8Array(await file.arrayBuffer());
if (revision !== fileRevision) return;
fileBytes = bytes; $('mime-type').value = file.type; $('payload').value = '';
} catch (error) { $('body-file').value = ''; message(error.message, true); }
finally { fileLoading = false; updateCount(); render(); }
};
$('review').onclick = () => {
try {
const payload = composerBody(), parent = $('parent').value || null, metadata = parseMetadataJSON($('metadata').value);
const options = { delegate: $('delegate').value.trim() || null, metaprotocol: $('metaprotocol').value.trim() || null, reinscribe: $('reinscribe').value || null, ...($('pointer').value.trim() ? { pointer: $('pointer').value.trim() } : {}) };
new InscriptionAdapter(lab.chain, lab.wallet).prepare(payload, parent, metadata, options);
closeReview();
reviewed = { payload, parent, metadata, options, address: lab.wallet.address };
$('review-text').textContent = ['text/plain', 'application/json'].includes(payload.contentType) ? new TextDecoder().decode(payload.bytes) : `${payload.contentType} · ${payload.bytes.length} bytes`;
if (!['text/plain', 'application/json'].includes(payload.contentType)) $('review-preview').append(preview(payload));
$('review-parent').textContent = parent ? `Parent: ${parent}. Its output will be spent and its full value returned to this wallet. This relationship is immutable.` : 'No parent.';
$('review-parent').textContent += ` Delegate: ${options.delegate ?? 'none'}. Reinscribe: ${options.reinscribe ?? 'none'}. Pointer: ${options.pointer ?? 'automatic'}. Metaprotocol: ${options.metaprotocol ?? 'none'}.`;
$('review-metadata').textContent = metadata === undefined ? 'No metadata.' : `Metadata (CBOR, tag 5): ${JSON.stringify(metadata, null, 2)}`;
$('review-panel').hidden = false;
$('sign').focus(); message('Review the payload and fake-sat costs. Nothing has been signed yet.');
} catch (error) { message(error.message, true); }
};
$('cancel-review').onclick = closeReview;
$('sign').onclick = () => {
const consent = reviewed;
if (!consent) return;
mutate(async next => {
if (next.wallet?.address !== consent.address) throw new Error('Wallet changed. Review again before signing.');
if (next.wallet.kind === 'extension-wallet') {
const provider = inscriboorProvider ?? window.inscriboor;
if (!provider?.request) throw new Error('Reconnect the Inscriboor extension before signing.');
next.wallet.attach(provider);
await inscribeWithProvider(next.chain, next.wallet, consent.payload, consent.parent, consent.metadata, consent.options);
} else new InscriptionAdapter(next.chain, next.wallet).inscribe(consent.payload, consent.parent, consent.metadata, consent.options);
}, 'Inscription submitted. Its commit + reveal are now visible in the mempool; mine a block to confirm them.', resetComposer);
};
$('export-wallet').onclick = () => download('super-testnet-wallet.json', JSON.stringify(lab.wallet.backup(), null, 2));
$('export-lab').onclick = () => {
try { download('super-testnet-lab.json', failed ? localStorage.getItem(STORAGE_KEY) ?? '{}' : encodeLab(lab)); }
catch (error) { message(error.message, true); }
};
$('load-wallet').onchange = async event => {
const file = event.target.files[0]; event.target.value = '';
if (!file) return;
if (file.size > 5_000_000) { message('Backup too large (maximum 5 MB).', true); return; }
try {
const raw = await file.text(), data = JSON.parse(raw);
const restore = data.kind === 'test-lab';
const loaded = restore ? decodeLab(raw) : loadWalletBackup(data);
if (lab.wallet && !confirm(restore ? 'Replace this entire local lab with the backup? Save your current lab first.' : 'Switch the active test wallet? The local chain stays here. Save your current wallet first.')) return;
await mutate(next => {
if (restore) Object.assign(next, loaded); else next.wallet = loaded;
}, restore ? 'Complete lab restored, including its wallet and chain.' : 'Test wallet loaded. Its collectibles on this local chain are shown below.');
} catch (error) { message(`Backup not loaded: ${error.message}`, true); }
};
$('reset').onclick = async () => {
if (busy || !confirm('Delete this browser’s local chain and test wallet? Export a complete lab first if you want to keep it.')) return;
try { await navigator.locks.request(STORAGE_KEY, () => localStorage.removeItem(STORAGE_KEY)); location.hash = ''; location.reload(); }
catch (error) { message(error.message, true); }
};
function inspect(scroll = true) {
const [kind, id] = location.hash.slice(1).split('/');
const pane = $('inspector'); clearPreviews(pane); pane.replaceChildren(); pane.hidden = !kind;
if (!kind || !lab) return;
const chain = lab.chain, rpc = new LocalRPC(chain);
const add = (label, value) => { const p = el('p'); p.append(el('strong', `${label}: `), el('code', String(value))); pane.append(p); };
try {
if (kind === 'block') {
const block = rpc.call('getblock', [id]);
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());
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('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);
add('Body size', `${record.inscription.bytes.length} bytes`);
pane.append(preview(record.inscription, new LocalOrdAPI(chain)), metadataView(record.inscription), lineage(record.inscription, chain));
pane.append(link('Inspect commit transaction ↗', `#tx/${record.inscription.commitTxid}`));
}
pane.append(el('h3', 'Inputs → outputs'));
const tx = record.tx;
tx.vin.forEach(input => {
const p = el('p');
if (record.coinbase) p.textContent = 'Local coinbase · 50 fake BTC created';
else p.append(link(`Input ${input.txid}:${input.vout}`, `#tx/${input.txid}`));
pane.append(p);
});
tx.vout.forEach((out, i) => add(`Output ${i}`, `${Number(out.value).toLocaleString()} sats`));
add('Fee', record.coinbase ? '0 (local reward)' : '500 fake sats');
pane.append(el('h3', 'Raw Bitcoin transaction'), el('pre', rpc.call('getrawtransaction', [txid])));
if (!record.coinbase) { pane.append(el('h3', 'Witness'), el('pre', JSON.stringify(tx.vin.map(input => input.witness), null, 2))); }
} else throw new Error('Unknown explorer view.');
} catch (error) { pane.append(el('p', error.message)); }
if (scroll) { pane.scrollIntoView({ behavior: 'smooth', block: 'start' }); pane.focus({ preventScroll: true }); }
}
window.addEventListener('hashchange', () => inspect());
window.addEventListener('storage', event => {
if (event.key !== STORAGE_KEY) return;
closeReview(); load(); if (!failed) message('Local lab changed in another tab; refreshed.');
});
function load() {
try { lab = readLab(); failed = false; if (lab.wallet?.kind === 'extension-wallet' && inscriboorProvider) lab.wallet.attach(inscriboorProvider); if (lab.wallet) message(lab.wallet.kind === 'extension-wallet' ? 'Inscriboor wallet descriptor loaded. Reconnect the extension to sign.' : 'Test wallet loaded from this browser. Your local chain is ready.'); }
catch (error) { failed = true; message(`Cannot load saved lab: ${error.message} Export it for recovery, then reset this local lab.`, true); }
render();
}
window.addEventListener('inscriboor#initialized', () => { inscriboorProvider = window.inscriboor; if (lab?.wallet?.kind === 'extension-wallet') lab.wallet.attach(inscriboorProvider); render(); });
inscriboorProvider = window.inscriboor?.isInscriboor ? window.inscriboor : null;
initTree({ mutate, message });
initTheme();
load();