Add delegate recursion and reinscription UI

This commit is contained in:
recovery committed 2026-10-05 03:57:01 +00:00
1 parent 9f27897967
commit 5d1a1ae866
10 files changed
+327 -31

No files matched your search

+29 -9
View File
@@ -1,3 +1,4 @@
import { RECURSIVE_DEMO } from './recursion.js';
import './style.css';
import { encodeMetadata, parseMetadataJSON } from './adapters/metadata.js';
import { MetadataIndex, deriveProtocol } from './adapters/metadata-index.js';
@@ -57,10 +58,18 @@ const composerBody = () => ({ contentType: $('mime-type').value, bytes: fileByte
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();
$('delegate').value = ''; $('pointer').value = ''; $('reinscribe').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}`));
@@ -163,7 +172,7 @@ function render() {
$('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;
for (const id of ['mime-type', 'body-file', 'parent', 'metadata', '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;
@@ -180,6 +189,10 @@ function render() {
$('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();
@@ -190,7 +203,7 @@ 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'), preview(item));
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}`));
@@ -231,7 +244,12 @@ setInterval(() => {
$('metadata').oninput = () => { closeReview(); updateMetadataCount(); };
MIME_TYPES.forEach(type => $('mime-type').append(new Option(type, type)));
$('payload').oninput = () => { closeReview(); updateCount(); render(); };
$('parent').onchange = closeReview;
$('parent').onchange = $('reinscribe').onchange = closeReview;
$('delegate').oninput = $('pointer').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();
};
@@ -253,12 +271,14 @@ $('body-file').onchange = async event => {
$('review').onclick = () => {
try {
const payload = composerBody(), parent = $('parent').value || null, metadata = parseMetadataJSON($('metadata').value);
new InscriptionAdapter(lab.chain, lab.wallet).prepare(payload, parent, metadata);
const options = { delegate: $('delegate').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, address: lab.wallet.address };
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'}.`;
$('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.');
@@ -270,7 +290,7 @@ $('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.payload, consent.parent, consent.metadata);
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));
@@ -336,7 +356,7 @@ function inspect(scroll = true) {
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(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'));
@@ -350,7 +370,7 @@ function inspect(scroll = true) {
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))); }
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 }); }
+17 -2
View File
@@ -1,8 +1,14 @@
import { recursivePayload, connectRecursion } from './recursion.js';
// Inert parsing only; inscription markup is never attached to the parent DOM.
// The deliberately small allowlist also removes navigation (including meta refresh).
const tags = new Set('p div span h1 h2 h3 h4 h5 h6 br hr pre code b strong i em u s ul ol li table thead tbody tr td th blockquote svg g path rect circle ellipse line polyline polygon text tspan defs lineargradient radialgradient stop'.split(' '));
const attrs = new Set('style title width height viewbox fill stroke stroke-width d x y x1 y1 x2 y2 cx cy r rx ry points transform opacity offset stop-color font-size text-anchor'.split(' '));
export function preview(item) {
export function preview(item, api = null) {
if (api && item.id) {
const response = api.request(`/content/${item.id}`);
if (response.status !== 200) { const error = document.createElement('pre'); error.textContent = `${response.status}: ${response.body.error}`; return error; }
item = { ...item, bytes: response.body, contentType: response.contentType };
}
const bytes = new Uint8Array(item.bytes);
const type = item.contentType.split(';')[0];
const text = new TextDecoder().decode(bytes);
@@ -13,6 +19,7 @@ export function preview(item) {
if (type === 'text/html' || type === 'image/svg+xml') {
const inert = document.implementation.createHTMLDocument('');
const template = inert.createElement('template'); template.innerHTML = text;
const scripts = [...template.content.querySelectorAll('script')].filter(s => !s.src).map(s => s.textContent);
const clean = parent => {
for (const child of [...parent.children]) {
if (!tags.has(child.localName.toLowerCase())) { child.remove(); continue; }
@@ -21,6 +28,14 @@ export function preview(item) {
}
};
clean(template.content);
if (api && item.id && scripts.length) {
node = document.createElement('iframe'); node.setAttribute('sandbox', 'allow-scripts');
node.title = `${type} recursive preview`; node.referrerPolicy = 'no-referrer';
const payload = recursivePayload(template.innerHTML, scripts, item.id);
node.onload = () => { node.cleanup?.(); node.cleanup = connectRecursion(node, api, payload); };
node.src = '/recursive-preview.html'; node.dataset.previewUrl = ''; node.className = 'inscription-preview';
return node;
}
const csp = "default-src 'none'; style-src 'unsafe-inline'; script-src 'none'; form-action 'none'; base-uri 'none'";
blob = new Blob([`<!doctype html><meta http-equiv="Content-Security-Policy" content="${csp}">`, template.innerHTML], { type: 'text/html' });
node = document.createElement('iframe'); node.setAttribute('sandbox', ''); node.title = `${type} safe preview`;
@@ -32,5 +47,5 @@ export function preview(item) {
node.className = 'inscription-preview'; return node;
}
export function clearPreviews(root) {
root.querySelectorAll('[data-preview-url]').forEach(node => URL.revokeObjectURL(node.dataset.previewUrl));
root.querySelectorAll('[data-preview-url]').forEach(node => { node.cleanup?.(); URL.revokeObjectURL(node.dataset.previewUrl); });
}
+44
View File
@@ -0,0 +1,44 @@
// Deliberately exact paths: no URLs, query strings, escapes, traversal or local
// mutation endpoints. Both sides validate; the parent is the authority.
export function recursivePath(path) {
return typeof path === 'string' && /^(?:\/content\/[0-9a-f]{64}i(?:0|[1-9][0-9]*)|\/r\/(?:inscription|metadata|parents|children)\/[0-9a-f]{64}i(?:0|[1-9][0-9]*)|\/r\/tx\/[0-9a-f]{64}|\/r\/blockheight|\/r\/blockhash\/(?:0|[1-9][0-9]*)|\/r\/blockinfo\/(?:0|[1-9][0-9]*|[0-9a-f]{64}))$/.test(path);
}
export const RECURSIVE_DEMO = `<h2>Recursive local inscription</h2><p>Live data from this browser's chain:</p>
<script>
const height = await (await fetch('/r/blockheight')).json();
const me = await (await fetch('/r/inscription/' + ord.id)).json();
const metadata = await (await fetch('/r/metadata/' + ord.id)).json();
ord.render(JSON.stringify({ id: ord.id, height, sat: me.sat, metadata }, null, 2));
</script>`;
// This function is serialized into a worker, never executed in the parent DOM.
function workerBootstrap(id, allowed) {
let next = 0; const pending = new Map();
self.onmessage = ({ data }) => {
const resolve = pending.get(data.token); if (!resolve) return;
pending.delete(data.token); resolve(data);
};
self.fetch = async (path, options = {}) => {
if (!allowed(path) || (options.method && options.method !== 'GET')) throw new TypeError('Only whitelisted local GET paths are allowed.');
const token = ++next;
const response = await new Promise(resolve => { pending.set(token, resolve); postMessage({ type: 'fetch', token, path }); });
if (response.error) throw new TypeError(response.error);
const body = response.body instanceof Uint8Array ? response.body : JSON.stringify(response.body);
return new Response(body, { status: response.status, headers: { 'Content-Type': response.contentType } });
};
self.ord = Object.freeze({ id, contentPath: '/content/' + id, render: text => postMessage({ type: 'render', text: String(text).slice(0, 65536) }) });
}
export function recursivePayload(markup, scripts, id) {
const worker = `const recursivePath = ${recursivePath.toString()}; (${workerBootstrap.toString()})(${JSON.stringify(id)}, recursivePath); (async () => {${scripts.join('\n;\n')}\n})().catch(e => ord.render(e.message));`;
return { markup, worker };
}
export function connectRecursion(frame, api, payload) {
const channel = new MessageChannel();
channel.port1.onmessage = ({data}) => {
if (!data || data.type !== 'fetch') return;
const result = recursivePath(data.path) ? api.request(data.path) : { error: 'Blocked local path.' };
channel.port1.postMessage({ ...result, token: data.token });
};
frame.contentWindow.postMessage({ type: 'local-ord', ...payload }, '*', [channel.port2]);
return () => channel.port1.close();
}