Files
app/src/main.js
T

369 lines
22 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 './style.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 } from './adapters/wallet.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';
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 fileBytes = null, fileLoading = false, fileRevision = 0;
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 : '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;
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 = '';
$('metadata').value = ''; updateMetadataCount(); $('parent').value = ''; closeReview(); updateCount();
}
function lineage(item, chain) {
const p = el('p', undefined, 'tx-links');
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)));
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 ? 'LOCAL / TEST ONLY' : 'NOT CREATED';
$('create-wallet').disabled = disabled; $('load-wallet').disabled = disabled;
$('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']) $(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`;
}
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;
$('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));
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);
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.');
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 = closeReview;
$('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);
new InscriptionAdapter(lab.chain, lab.wallet).prepare(payload, parent, metadata);
closeReview();
reviewed = { payload, parent, metadata, 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-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(next => {
if (next.wallet?.address !== consent.address) throw new Error('Wallet changed. Review again before signing.');
new InscriptionAdapter(next.chain, next.wallet).inscribe(consent.payload, consent.parent, consent.metadata);
}, '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) : TestWallet.load(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), 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[0].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) message('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();
}
initTheme();
load();