Add delegate recursion and reinscription UI
This commit is contained in:
1 parent
9f27897967
commit
5d1a1ae866
10 files changed
+327
-31
No files matched your search
+29
-9
@@ -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
@@ -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); });
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user