Extend local inscription lab with MIME bodies and parent provenance
This commit is contained in:
1 parent
83a19b51ff
commit
acd2652046
12 files changed
+383
-95
No files matched your search
+68
-19
@@ -1,13 +1,15 @@
|
||||
import './style.css';
|
||||
import { TestWallet } from './adapters/wallet.js';
|
||||
import { LocalRPC } from './adapters/chain.js';
|
||||
import { InscriptionAdapter, MAX_BYTES } from './adapters/inscription.js';
|
||||
import { InscriptionAdapter, MAX_BYTES, MIME_TYPES } from './adapters/inscription.js';
|
||||
import { preview, clearPreviews } from './preview.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)}`;
|
||||
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); };
|
||||
@@ -42,7 +44,25 @@ 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 closeReview() {
|
||||
reviewed = null; $('review-panel').hidden = true;
|
||||
clearPreviews($('review-preview')); $('review-preview').replaceChildren();
|
||||
$('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 = '';
|
||||
$('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 renderMempool(chain) {
|
||||
const pane = $('mempool-list');
|
||||
@@ -57,7 +77,7 @@ function renderMempool(chain) {
|
||||
button.append(el('strong', txKind(record, chain)));
|
||||
button.append(el('span', short(record.txid)));
|
||||
button.append(el('span', `${Math.round(record.raw.length / 2).toLocaleString()} bytes · 500 fake sat fee`));
|
||||
if (record.inscription) button.append(el('span', `“${record.inscription.text.slice(0, 34)}${record.inscription.text.length > 34 ? '…' : ''}”`));
|
||||
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);
|
||||
}
|
||||
@@ -87,8 +107,9 @@ function render() {
|
||||
$('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();
|
||||
$('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']) $(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;
|
||||
@@ -100,7 +121,12 @@ function render() {
|
||||
$('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');
|
||||
@@ -109,19 +135,19 @@ function render() {
|
||||
}
|
||||
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(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(trail); $('collectibles').append(card);
|
||||
card.append(lineage(item, chain), trail); $('collectibles').append(card);
|
||||
}
|
||||
renderMempool(chain);
|
||||
renderBlocks(chain);
|
||||
inspect(false);
|
||||
}
|
||||
|
||||
async function mutate(action, success) {
|
||||
async function mutate(action, success, onSuccess = () => {}) {
|
||||
if (busy || failed) return;
|
||||
busy = true; render();
|
||||
try {
|
||||
@@ -131,7 +157,7 @@ async function mutate(action, success) {
|
||||
await action(candidate);
|
||||
saveLab(candidate); lab = candidate;
|
||||
});
|
||||
closeReview(); message(success);
|
||||
closeReview(); onSuccess(); message(success);
|
||||
} catch (error) { message(error.message, true); }
|
||||
finally { busy = false; render(); }
|
||||
}
|
||||
@@ -145,15 +171,37 @@ $('mine').onclick = () => mutate(next => {
|
||||
if (!next.wallet) throw new Error('Create or load a wallet first.');
|
||||
next.chain.mine(next.wallet.script);
|
||||
}, 'Block mined. Pending transactions moved from the mempool into your newest block.');
|
||||
$('payload').oninput = () => {
|
||||
closeReview(); const bytes = new TextEncoder().encode($('payload').value).length;
|
||||
$('byte-count').textContent = `${bytes} / ${MAX_BYTES} UTF-8 bytes`; render();
|
||||
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 {
|
||||
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;
|
||||
const payload = composerBody(), parent = $('parent').value || null;
|
||||
new InscriptionAdapter(lab.chain, lab.wallet).prepare(payload, parent);
|
||||
closeReview();
|
||||
reviewed = { payload, parent, 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-panel').hidden = false;
|
||||
$('sign').focus(); message('Review the payload and fake-sat costs. Nothing has been signed yet.');
|
||||
} catch (error) { message(error.message, true); }
|
||||
};
|
||||
@@ -163,8 +211,8 @@ $('sign').onclick = () => {
|
||||
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. Its commit + reveal are now visible in the mempool; mine a block to confirm them.');
|
||||
new InscriptionAdapter(next.chain, next.wallet).inscribe(consent.payload, consent.parent);
|
||||
}, '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 = () => {
|
||||
@@ -193,7 +241,7 @@ $('reset').onclick = async () => {
|
||||
|
||||
function inspect(scroll = true) {
|
||||
const [kind, id] = location.hash.slice(1).split('/');
|
||||
const pane = $('inspector'); pane.replaceChildren(); pane.hidden = !kind;
|
||||
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); };
|
||||
@@ -228,7 +276,8 @@ function inspect(scroll = true) {
|
||||
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));
|
||||
add('Body size', `${record.inscription.bytes.length} bytes`);
|
||||
pane.append(preview(record.inscription), lineage(record.inscription, chain));
|
||||
pane.append(link('Inspect commit transaction ↗', `#tx/${record.inscription.commitTxid}`));
|
||||
}
|
||||
pane.append(el('h3', 'Inputs → outputs'));
|
||||
|
||||
Reference in new issue
Block a user