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>
420 lines
27 KiB
JavaScript
420 lines
27 KiB
JavaScript
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(); |