Build browser-native Super Testnet v0 inscription loop
This commit is contained in:
1 parent
d3272032c8
commit
4fa4130150
25 files changed
+2393
-2
No files matched your search
+183
@@ -0,0 +1,183 @@
|
||||
import './style.css';
|
||||
import { TestWallet } from './adapters/wallet.js';
|
||||
import { LocalRPC } from './adapters/chain.js';
|
||||
import { InscriptionAdapter, MAX_BYTES } from './adapters/inscription.js';
|
||||
import { STORAGE_KEY, readLab, saveLab, 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)}`;
|
||||
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; };
|
||||
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 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;
|
||||
$('payload').disabled = disabled || !wallet;
|
||||
$('review').disabled = disabled || !wallet || !chain?.coins(wallet.script).some(c => !c.inscription) || !$('payload').value.trim();
|
||||
$('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() : [];
|
||||
$('collection-count').textContent = items.length;
|
||||
$('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'), el('pre', item.text));
|
||||
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(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);
|
||||
}
|
||||
inspect(false);
|
||||
}
|
||||
async function mutate(action, success) {
|
||||
if (busy || failed) return;
|
||||
busy = true; render();
|
||||
try {
|
||||
if (!navigator.locks) throw new Error('This app needs a secure-context browser with Web Locks. Use localhost or HTTPS.');
|
||||
await navigator.locks.request(STORAGE_KEY, async () => {
|
||||
const candidate = readLab();
|
||||
await action(candidate);
|
||||
saveLab(candidate); lab = candidate;
|
||||
});
|
||||
closeReview(); message(success);
|
||||
} catch (error) { message(error.message, true); }
|
||||
finally { busy = false; render(); }
|
||||
}
|
||||
$('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.');
|
||||
$('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.');
|
||||
$('payload').oninput = () => {
|
||||
closeReview(); const bytes = new TextEncoder().encode($('payload').value).length;
|
||||
$('byte-count').textContent = `${bytes} / ${MAX_BYTES} UTF-8 bytes`; render();
|
||||
};
|
||||
$('review').onclick = () => {
|
||||
try {
|
||||
new InscriptionAdapter(lab.chain, lab.wallet).prepare($('payload').value);
|
||||
reviewed = { text: $('payload').value, address: lab.wallet.address };
|
||||
$('review-text').textContent = reviewed.text; $('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.text);
|
||||
}, 'Inscription submitted and visible in your wallet. Mine a block to confirm it.');
|
||||
};
|
||||
$('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'); 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]);
|
||||
pane.append(el('h2', `Block ${block.height}`)); add('Local block hash', block.hash); 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); }
|
||||
} 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');
|
||||
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);
|
||||
pane.append(el('pre', record.inscription.text));
|
||||
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();
|
||||
}
|
||||
load();
|
||||
Reference in new issue
Block a user