Extend local inscription lab with MIME bodies and parent provenance

This commit is contained in:
Codex committed 2026-10-05 01:23:21 +00:00
1 parent 83a19b51ff
commit acd2652046
12 files changed
+383 -95

No files matched your search

+38 -22
View File
@@ -13,7 +13,7 @@ const scriptHex = script => Script.encode(script, false).hex;
const assert = (condition, message) => { if (!condition) throw new Error(message); };
// Original, intentionally narrow implementation of testnet_generator's local UTXO/mining model.
// This is NOT a Bitcoin consensus engine. Only our key-path and ord text templates are accepted.
// This is NOT a Bitcoin consensus engine. Only our key-path and ord MIME templates are accepted.
export class LocalChain {
constructor(snapshot) {
this.blocks = []; this.mempool = []; this.utxos = new Map(); this.transactions = new Map();
@@ -28,11 +28,11 @@ export class LocalChain {
coins(script) { return [...this.utxos.values()].filter(c => scriptHex(c.scriptPubKey) === scriptHex(script)); }
balance(script) { return this.coins(script).reduce((sum, c) => sum + c.value, 0); }
apply(raw, height, coinbase = false) {
assert(typeof raw === 'string' && raw.length < 20000 && /^(?:[a-f0-9]{2})+$/.test(raw), 'Invalid raw transaction.');
assert(typeof raw === 'string' && raw.length < 40000 && /^(?:[a-f0-9]{2})+$/.test(raw), 'Invalid raw transaction.');
const tx = Tx.decode(raw), txid = Tx.util.getTxid(tx);
assert(!this.transactions.has(txid), 'Duplicate transaction.');
assert(tx.version === 2 && tx.locktime === 0, 'Unsupported transaction version or locktime.');
assert(tx.vin.length === 1 && tx.vout.length >= 1 && tx.vout.length <= 2, 'Unsupported transaction shape.');
assert((tx.vin.length === 1 || (!coinbase && tx.vin.length === 2)) && tx.vout.length >= 1 && tx.vout.length <= 2, 'Unsupported transaction shape.');
let total = 0;
for (const output of tx.vout) {
const value = Number(output.value);
@@ -40,38 +40,54 @@ export class LocalChain {
assert(/^5120[0-9a-f]{64}$/.test(scriptHex(output.scriptPubKey)), 'Only Taproot outputs are supported.');
total += value;
}
let inscription;
let inscription, preservedParent;
const input = tx.vin[0];
if (coinbase) {
assert(input.txid === ZERO && input.vout === 0xffffffff && tx.vout.length === 1 && total === REWARD, 'Invalid local coinbase.');
assert(scriptHex(input.scriptSig) === Script.encode([new TextEncoder().encode(`${NETWORK}:${height}`)], false).hex, 'Wrong coinbase height.');
assert(!input.witness?.length, 'Unexpected coinbase witness.');
} else {
const coin = this.utxos.get(key(input.txid, input.vout));
assert(coin, 'Input is missing or already spent.');
assert(!coin.inscription, 'v0 protects inscription outputs from spending.');
assert(input.sequence === 'ffffffff' && scriptHex(input.scriptSig) === '', 'Unsupported input flags.');
assert(coin.value - total === FEE, 'v0 requires a fixed 500 sat fee per transaction.');
input.prevout = { value: coin.value, scriptPubKey: coin.scriptPubKey };
const witness = input.witness;
assert(witness?.length === 1 || witness?.length === 3, 'Unsupported witness.');
assert(/^[0-9a-f]{128}$/.test(witness[0]), 'Only default 64-byte Schnorr signatures are accepted.');
const coins = tx.vin.map(input => this.utxos.get(key(input.txid, input.vout)));
assert(coins.every(Boolean), 'Input is missing or already spent.');
assert(new Set(tx.vin.map(i => key(i.txid, i.vout))).size === tx.vin.length, 'Duplicate input.');
assert(!coins[0].inscription, 'v0 protects inscription outputs from spending.');
tx.vin.forEach((input, i) => {
assert(input.sequence === 'ffffffff' && scriptHex(input.scriptSig) === '', 'Unsupported input flags.');
input.prevout = { value: coins[i].value, scriptPubKey: coins[i].scriptPubKey };
assert(input.witness?.length === 1 || (i === 0 && input.witness?.length === 3), 'Unsupported witness.');
assert(/^[0-9a-f]{128}$/.test(input.witness[0]), 'Only default 64-byte Schnorr signatures are accepted.');
});
assert(coins.reduce((sum, c) => sum + c.value, 0) - total === FEE, 'v0 requires a fixed 500 sat fee per transaction.');
let config = {};
if (witness.length === 3) {
const parsed = readEnvelope(witness);
assert(tx.vout.length === 1 && total === POSTAGE, 'Invalid reveal postage.');
if (input.witness.length === 3) {
const parsed = readEnvelope(input.witness);
assert(Number(tx.vout[0].value) === POSTAGE, 'Invalid reveal postage.');
assert(scriptHex(tx.vout[0].scriptPubKey) === `5120${parsed.pubkey}`, 'Reveal must return to its signing wallet.');
if (parsed.parent) {
const parent = coins[1];
assert(parent?.inscription?.id === parsed.parent, 'Referenced parent inscription must actually be spent.');
assert(tx.vout.length === 2 && Number(tx.vout[1].value) === parent.value &&
scriptHex(parent.scriptPubKey) === `5120${parsed.pubkey}` &&
scriptHex(tx.vout[1].scriptPubKey) === scriptHex(parent.scriptPubKey), 'Parent must be owned and preserved in the same wallet.');
preservedParent = parent.inscription;
} else {
assert(tx.vin.length === 1 && tx.vout.length === 1, 'v0 protects inscription outputs: parent spend requires correct tag.');
}
config = { pubkey: parsed.pubkey };
inscription = { id: `${txid}i0`, txid, commitTxid: input.txid, text: parsed.text, contentType: parsed.contentType, height, value: POSTAGE };
}
let valid = false;
try { valid = Signer.taproot.verify(tx, 0, config); } catch { /* Normalize malformed curve/signature errors. */ }
assert(valid, 'Invalid signature or Taproot commitment.');
this.utxos.delete(key(input.txid, input.vout));
inscription = { id: `${txid}i0`, txid, commitTxid: input.txid, text: parsed.text,
bytes: Array.from(parsed.bytes), contentType: parsed.contentType, parent: parsed.parent, height, value: POSTAGE };
} else assert(tx.vin.length === 1, 'v0 protects inscription outputs: only explicit child reveals can spend a parent.');
tx.vin.forEach((input, i) => {
let valid = false;
try { valid = Signer.taproot.verify(tx, i, i === 0 ? config : {}); } catch { /* Normalize malformed signatures. */ }
assert(valid, 'Invalid signature or Taproot commitment.');
});
tx.vin.forEach(input => this.utxos.delete(key(input.txid, input.vout)));
}
tx.vout.forEach((output, vout) => this.utxos.set(key(txid, vout), {
txid, vout, value: Number(output.value), scriptPubKey: output.scriptPubKey, height,
...(inscription && vout === 0 ? { inscription } : {}),
...(preservedParent && vout === 1 ? { inscription: preservedParent } : {}),
}));
this.transactions.set(txid, { txid, raw, height, coinbase, tx, ...(inscription ? { inscription } : {}) });
return txid;
+68 -29
View File
@@ -1,49 +1,88 @@
import { Script, Tap, Tx } from '@cmdcode/tapscript';
import { getVin, getVout } from '../upstream/primitives.js';
export const MAX_BYTES = 280;
export const MAX_BYTES = 16 * 1024;
export const POSTAGE = 1000;
export const FEE = 500; // Fixed fee per transaction on our private chain.
export const FEE = 500;
export const MIME = 'text/plain;charset=utf-8'; // Legacy snapshots remain readable.
export const MIME_TYPES = ['text/plain', 'application/json', 'text/html', 'image/svg+xml', 'image/png', 'image/jpeg', 'image/webp'];
const encoder = new TextEncoder();
const hex = bytes => Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
const unhex = value => Uint8Array.from(value.match(/../g) ?? [], b => parseInt(b, 16));
export const textHex = text => hex(encoder.encode(text));
export const MIME = 'text/plain;charset=utf-8';
export function body(value) {
const result = typeof value === 'string' ? { contentType: MIME, bytes: encoder.encode(value) } : value;
if (!result || ![...MIME_TYPES, MIME].includes(result.contentType)) throw new Error('Unsupported MIME type.');
if (!(result.bytes instanceof Uint8Array) || !result.bytes.length) throw new Error('Enter non-empty inscription bytes.');
if (result.bytes.length > MAX_BYTES) throw new Error(`Body must be at most ${MAX_BYTES} bytes.`);
return { contentType: result.contentType, bytes: new Uint8Array(result.bytes) };
}
export function validateText(text) {
if (typeof text !== 'string' || !text.trim()) throw new Error('Enter a non-empty text inscription.');
if (encoder.encode(text).length > MAX_BYTES) throw new Error(`Text must be at most ${MAX_BYTES} UTF-8 bytes.`);
body(text);
}
export function serializeParent(id) {
const match = /^([0-9a-f]{64})i(0|[1-9][0-9]*)$/.exec(id);
if (!match || Number(match[2]) > 0xffffffff) throw new Error('Invalid parent inscription id.');
let index = Number(match[2]), suffix = '';
while (index) { suffix += (index % 256).toString(16).padStart(2, '0'); index = Math.floor(index / 256); }
return match[1].match(/../g).reverse().join('') + suffix;
}
function parseParent(value) {
if (typeof value !== 'string' || !/^[0-9a-f]{64,72}$/.test(value) || value.length % 2) throw new Error('Invalid parent tag.');
const index = [...unhex(value.slice(64))].reduce((sum, b, i) => sum + b * 256 ** i, 0);
const id = `${value.slice(0,64).match(/../g).reverse().join('')}i${index}`;
if (serializeParent(id) !== value) throw new Error('Non-canonical parent tag.');
return id;
}
// Envelope adapted from inscriptions-online at 470b8bdf29ead319cff9dc119a005cbf58ee598c (CC0).
// Local extension: MIME bytes, 520-byte pushes, optional ord parent tag 3.
export function envelope(pubkey, value, parent = null) {
const { contentType, bytes } = body(value);
const chunks = [];
for (let i = 0; i < bytes.length; i += 520) chunks.push(hex(bytes.slice(i, i + 520)));
// tapscript's generic encoder normalizes one-byte data into numbers (and can
// append a sign byte). Encode data pushes explicitly to preserve every byte.
const push = value => {
const size = value.length / 2;
return (size <= 75 ? size.toString(16).padStart(2, '0') : size <= 255
? '4c' + size.toString(16).padStart(2, '0')
: '4d' + (size % 256).toString(16).padStart(2, '0') + Math.floor(size / 256).toString(16).padStart(2, '0')) + value;
};
return push(pubkey) + 'ac0063' + push(textHex('ord')) + push('01') + push(textHex(contentType)) +
(parent ? push('03') + push(serializeParent(parent)) : '') + '00' + chunks.map(push).join('') + '68';
}
// Envelope adapted from inscriptions-online index.html at 470b8bdf29ead319cff9dc119a005cbf58ee598c (CC0).
// Modern tapscript API; text-only, 280-byte cap, one inscription per reveal.
export const envelope = (pubkey, text) => [
pubkey, 'OP_CHECKSIG', 'OP_0', 'OP_IF', textHex('ord'), '01', textHex(MIME), 'OP_0', textHex(text), 'OP_ENDIF',
];
export function readEnvelope(witness) {
if (!Array.isArray(witness) || witness.length !== 3) throw new Error('Unsupported script-path witness.');
const parts = Script.decode(witness[1]);
const payload = parts[8];
if (typeof payload !== 'string' || !/^(?:[0-9a-f]{2})+$/.test(payload)) throw new Error('Invalid text envelope.');
const text = new TextDecoder('utf-8', { fatal: true }).decode(Uint8Array.from(payload.match(/../g), b => parseInt(b, 16)));
validateText(text);
const expected = Script.encode(envelope(parts[0], text), false).hex;
if (expected !== witness[1]) throw new Error('Only the v0 ord text envelope is supported.');
const parent = parts[7] === '03' ? parseParent(parts[8]) : null;
const payload = parts.slice(parent ? 10 : 8, -1);
if (!payload.length || payload.some(p => typeof p !== 'string' || !/^(?:[0-9a-f]{2})+$/.test(p))) throw new Error('Invalid body envelope.');
const contentType = new TextDecoder().decode(unhex(parts[6]));
const value = body({ contentType, bytes: unhex(payload.join('')) });
const expected = envelope(parts[0], value, parent);
// Read original v0 text snapshots whose content-type tag used OP_1.
const legacy = !parent && contentType === MIME && value.bytes.length <= 280 &&
Script.encode([parts[0], 'OP_CHECKSIG', 'OP_0', 'OP_IF', textHex('ord'), '01', textHex(MIME), 'OP_0', hex(value.bytes), 'OP_ENDIF'], false).hex;
if (expected !== witness[1] && legacy !== witness[1]) throw new Error('Unsupported ord envelope.');
if (!/^[0-9a-f]{64}$/.test(parts[0])) throw new Error('Invalid inscription signing key.');
return { text, pubkey: parts[0], contentType: MIME };
return { ...value, parent, text: new TextDecoder().decode(value.bytes), pubkey: parts[0] };
}
export class InscriptionAdapter {
constructor(chain, wallet) { this.chain = chain; this.wallet = wallet; }
prepare(text) {
validateText(text);
const coin = this.chain.coins(this.wallet.script).find(c => !c.inscription && c.value >= POSTAGE + 2 * FEE + 330);
prepare(value, parentId = null) {
const payload = body(value);
const coins = this.chain.coins(this.wallet.script);
const parent = parentId ? coins.find(c => c.inscription?.id === parentId) : null;
if (parentId && !parent) throw new Error('Parent must be an owned, unspent inscription.');
const coin = coins.find(c => !c.inscription && c.value >= POSTAGE + 2 * FEE + 330);
if (!coin) throw new Error('Mine a block first to get spendable fake BTC.');
return { text, coin, totalFee: FEE * 2, postage: POSTAGE };
return { ...payload, coin, parent, totalFee: FEE * 2, postage: POSTAGE };
}
// Called only by the explicit UI “Sign & inscribe” action.
inscribe(text) {
const { coin } = this.prepare(text);
const script = envelope(this.wallet.pubkey, text);
inscribe(value, parentId = null) {
const { coin, parent, ...payload } = this.prepare(value, parentId);
const script = envelope(this.wallet.pubkey, payload, parentId);
const leaf = Tap.encodeScript(script);
const [tapkey, control] = Tap.getPubKey(this.wallet.pubkey, { target: leaf });
const commit = Tx.create({
@@ -53,11 +92,11 @@ export class InscriptionAdapter {
commit.vin[0].witness = [this.wallet.sign(commit)];
const commitId = Tx.util.getTxid(commit);
const reveal = Tx.create({
vin: [getVin(commitId, 0, POSTAGE + FEE, ['OP_1', tapkey])],
vout: [getVout(POSTAGE, this.wallet.script)],
vin: [getVin(commitId, 0, POSTAGE + FEE, ['OP_1', tapkey]), ...(parent ? [getVin(parent.txid, parent.vout, parent.value, parent.scriptPubKey)] : [])],
vout: [getVout(POSTAGE, this.wallet.script), ...(parent ? [getVout(parent.value, this.wallet.script)] : [])],
});
reveal.vin[0].witness = [this.wallet.sign(reveal, leaf), Script.encode(script, false).hex, control];
// Package acceptance is atomic: no stranded commit on a failed reveal.
if (parent) reveal.vin[1].witness = [this.wallet.sign(reveal, undefined, 1)];
this.chain.submitPackage([Tx.encode(commit).hex, Tx.encode(reveal).hex]);
return `${Tx.util.getTxid(reveal)}i0`;
}
+2 -2
View File
@@ -22,7 +22,7 @@ export class TestWallet {
return new TestWallet(backup.secret);
}
backup() { return { kind: 'test-wallet', version: 1, network: NETWORK, secret: this.secret }; }
sign(tx, extension) {
return Signer.taproot.sign(this.secret, tx, 0, extension ? { extension } : {}).hex;
sign(tx, extension, inputIndex = 0) {
return Signer.taproot.sign(this.secret, tx, inputIndex, extension ? { extension } : {}).hex;
}
}
+68 -19
View File
@@ -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'));
+36
View File
@@ -0,0 +1,36 @@
// 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) {
const bytes = new Uint8Array(item.bytes);
const type = item.contentType.split(';')[0];
const text = new TextDecoder().decode(bytes);
if (type === 'text/plain' || type === 'application/json') {
const node = document.createElement('pre'); node.textContent = text; return node;
}
let node, blob;
if (type === 'text/html' || type === 'image/svg+xml') {
const inert = document.implementation.createHTMLDocument('');
const template = inert.createElement('template'); template.innerHTML = text;
const clean = parent => {
for (const child of [...parent.children]) {
if (!tags.has(child.localName.toLowerCase())) { child.remove(); continue; }
for (const attr of [...child.attributes]) if (!attrs.has(attr.name.toLowerCase())) child.removeAttribute(attr.name);
clean(child);
}
};
clean(template.content);
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`;
node.referrerPolicy = 'no-referrer';
} else {
blob = new Blob([bytes], { type }); node = document.createElement('img'); node.alt = `${type} inscription`;
}
const url = URL.createObjectURL(blob); node.src = url; node.dataset.previewUrl = url;
node.className = 'inscription-preview'; return node;
}
export function clearPreviews(root) {
root.querySelectorAll('[data-preview-url]').forEach(node => URL.revokeObjectURL(node.dataset.previewUrl));
}
+1 -1
View File
@@ -1 +1 @@
:root{font-family:Arial,Helvetica,sans-serif;font-synthesis:none;--orange:#f7931a;--orange-2:#ffad42;--bg:#0f1113;--panel:#171a1e;--panel-2:#20242a;--text:#f3f5f7;--muted:#9ca4ad;--line:#30363d;--soft:#23272e;--danger:#ff9b8d;color:var(--text);background:var(--bg)}:root[data-theme="light"]{--bg:#f5f3ef;--panel:#fffdf9;--panel-2:#f0ece5;--text:#1a1a1a;--muted:#6f6a63;--line:#d8d1c8;--soft:#ece7df;--danger:#a54536}*{box-sizing:border-box}html{background:var(--bg);color-scheme:dark}html[data-theme="light"]{color-scheme:light}body{margin:0;background:var(--bg);color:var(--text);transition:background .18s,color .18s}a{color:var(--orange);text-underline-offset:4px}button,input,textarea{font:inherit}.file-button,button{cursor:pointer}button{border:1px solid var(--line);border-radius:8px;background:var(--panel-2);color:var(--text);padding:12px 17px;font-weight:600;transition:background .15s,border-color .15s,transform .15s}button:hover:not(:disabled){border-color:var(--orange);transform:translateY(-1px)}button:disabled{opacity:.4;cursor:not-allowed;transform:none}button.primary{background:var(--orange);color:#1c1207;border-color:var(--orange)}button.primary:hover:not(:disabled){background:var(--orange-2)}.primary span{float:right;margin-left:20px}.quiet{background:transparent;border-color:var(--line);font-size:13px}button:focus-visible,a:focus-visible,summary:focus-visible,.file-button:focus-visible{outline:3px solid var(--orange-2);outline-offset:4px}header{max-width:1240px;margin:auto;padding:24px 32px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line)}.brand{text-decoration:none;color:var(--text);display:flex;align-items:center;gap:10px;font-size:15px;font-weight:800;letter-spacing:1px}.mark{display:inline-grid;place-items:center;background:var(--orange);color:#1c1207;width:34px;height:34px;border-radius:8px;font-size:21px;letter-spacing:-4px;padding-right:4px}.version{font:11px monospace;background:var(--soft);color:var(--orange);border-radius:4px;padding:3px 5px}.header-actions{display:flex;align-items:center;gap:14px}.network{font:11px monospace;color:var(--muted);letter-spacing:1px}.network i{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--orange);margin-right:6px}.theme-toggle{padding:8px 10px;font:11px monospace}main{max-width:1176px;padding:0 0 10px;margin:auto}.hero{display:flex;justify-content:space-between;align-items:center;padding:53px 0 36px}.eyebrow{font:11px monospace;letter-spacing:1.8px;color:var(--orange);margin:0 0 19px}h1{font-size:clamp(42px,5.5vw,70px);font-weight:500;line-height:1.02;letter-spacing:-3px;margin:0 0 24px}.intro{color:var(--muted);font-size:16px;line-height:1.65}.orbit{width:255px;height:220px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:36px;background:radial-gradient(ellipse,color-mix(in srgb,var(--orange) 16%,transparent),transparent 70%);margin-right:65px}.cube{display:grid;place-items:center;width:110px;height:110px;border:1px solid var(--orange);box-shadow:13px 13px 0 color-mix(in srgb,var(--orange) 18%,var(--panel)),14px 14px 0 color-mix(in srgb,var(--orange) 48%,var(--panel));transform:rotate(-12deg);background:var(--panel-2);color:var(--orange)}.orbit span{font:10px monospace;letter-spacing:2px;color:var(--muted)}.notice{background:color-mix(in srgb,var(--orange) 7%,var(--panel));border:1px solid color-mix(in srgb,var(--orange) 24%,var(--line));border-radius:8px;padding:14px 18px;font-size:12px;color:var(--muted);line-height:1.6}.notice strong{color:var(--text)}#status{min-height:52px;padding:16px 2px;font-size:13px;color:var(--orange)}#status.error{color:var(--danger)}.workspace{display:grid;grid-template-columns:1fr 1fr;gap:18px}.panel{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:24px;min-width:0}.panel-title{display:flex;align-items:center;gap:10px;margin-bottom:22px}.panel-title h2{font-size:17px;margin:0;font-weight:500;letter-spacing:-.4px}.step{font:11px monospace;color:var(--orange);background:color-mix(in srgb,var(--orange) 10%,var(--panel));border:1px solid color-mix(in srgb,var(--orange) 28%,var(--line));border-radius:6px;padding:7px}.tag{margin-left:auto;font:9px monospace;letter-spacing:1px;color:var(--muted);border:1px solid var(--line);padding:5px;border-radius:4px}.panel p{line-height:1.6;font-size:14px;color:var(--muted)}.label{display:block;font:10px monospace!important;letter-spacing:1.5px;color:var(--muted);margin:0 0 10px}.balance{display:flex;align-items:baseline;gap:12px;font-size:37px;letter-spacing:-1px;font-variant-numeric:tabular-nums}.balance small{color:var(--orange);font-size:13px;letter-spacing:1px}.muted{color:var(--muted)!important;font-size:12px!important;line-height:1.65}label{display:block;font-size:12px;color:var(--muted);margin:14px 0 9px}input,textarea{width:100%;color:var(--text);background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:12px;outline:none}input{fontLine truncated
:root{font-family:Arial,Helvetica,sans-serif;font-synthesis:none;--orange:#f7931a;--orange-2:#ffad42;--bg:#0f1113;--panel:#171a1e;--panel-2:#20242a;--text:#f3f5f7;--muted:#9ca4ad;--line:#30363d;--soft:#23272e;--danger:#ff9b8d;color:var(--text);background:var(--bg)}:root[data-theme="light"]{--bg:#f5f3ef;--panel:#fffdf9;--panel-2:#f0ece5;--text:#1a1a1a;--muted:#6f6a63;--line:#d8d1c8;--soft:#ece7df;--danger:#a54536}*{box-sizing:border-box}html{background:var(--bg);color-scheme:dark}html[data-theme="light"]{color-scheme:light}body{margin:0;background:var(--bg);color:var(--text);transition:background .18s,color .18s}a{color:var(--orange);text-underline-offset:4px}button,input,textarea{font:inherit}.file-button,button{cursor:pointer}button{border:1px solid var(--line);border-radius:8px;background:var(--panel-2);color:var(--text);padding:12px 17px;font-weight:600;transition:background .15s,border-color .15s,transform .15s}button:hover:not(:disabled){border-color:var(--orange);transform:translateY(-1px)}button:disabled{opacity:.4;cursor:not-allowed;transform:none}button.primary{background:var(--orange);color:#1c1207;border-color:var(--orange)}button.primary:hover:not(:disabled){background:var(--orange-2)}.primary span{float:right;margin-left:20px}.quiet{background:transparent;border-color:var(--line);font-size:13px}button:focus-visible,a:focus-visible,summary:focus-visible,.file-button:focus-visible{outline:3px solid var(--orange-2);outline-offset:4px}header{max-width:1240px;margin:auto;padding:24px 32px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line)}.brand{text-decoration:none;color:var(--text);display:flex;align-items:center;gap:10px;font-size:15px;font-weight:800;letter-spacing:1px}.mark{display:inline-grid;place-items:center;background:var(--orange);color:#1c1207;width:34px;height:34px;border-radius:8px;font-size:21px;letter-spacing:-4px;padding-right:4px}.version{font:11px monospace;background:var(--soft);color:var(--orange);border-radius:4px;padding:3px 5px}.header-actions{display:flex;align-items:center;gap:14px}.network{font:11px monospace;color:var(--muted);letter-spacing:1px}.network i{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--orange);margin-right:6px}.theme-toggle{padding:8px 10px;font:11px monospace}main{max-width:1176px;padding:0 0 10px;margin:auto}.hero{display:flex;justify-content:space-between;align-items:center;padding:53px 0 36px}.eyebrow{font:11px monospace;letter-spacing:1.8px;color:var(--orange);margin:0 0 19px}h1{font-size:clamp(42px,5.5vw,70px);font-weight:500;line-height:1.02;letter-spacing:-3px;margin:0 0 24px}.intro{color:var(--muted);font-size:16px;line-height:1.65}.orbit{width:255px;height:220px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:36px;background:radial-gradient(ellipse,color-mix(in srgb,var(--orange) 16%,transparent),transparent 70%);margin-right:65px}.cube{display:grid;place-items:center;width:110px;height:110px;border:1px solid var(--orange);box-shadow:13px 13px 0 color-mix(in srgb,var(--orange) 18%,var(--panel)),14px 14px 0 color-mix(in srgb,var(--orange) 48%,var(--panel));transform:rotate(-12deg);background:var(--panel-2);color:var(--orange)}.orbit span{font:10px monospace;letter-spacing:2px;color:var(--muted)}.notice{background:color-mix(in srgb,var(--orange) 7%,var(--panel));border:1px solid color-mix(in srgb,var(--orange) 24%,var(--line));border-radius:8px;padding:14px 18px;font-size:12px;color:var(--muted);line-height:1.6}.notice strong{color:var(--text)}#status{min-height:52px;padding:16px 2px;font-size:13px;color:var(--orange)}#status.error{color:var(--danger)}.workspace{display:grid;grid-template-columns:1fr 1fr;gap:18px}.panel{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:24px;min-width:0}.panel-title{display:flex;align-items:center;gap:10px;margin-bottom:22px}.panel-title h2{font-size:17px;margin:0;font-weight:500;letter-spacing:-.4px}.step{font:11px monospace;color:var(--orange);background:color-mix(in srgb,var(--orange) 10%,var(--panel));border:1px solid color-mix(in srgb,var(--orange) 28%,var(--line));border-radius:6px;padding:7px}.tag{margin-left:auto;font:9px monospace;letter-spacing:1px;color:var(--muted);border:1px solid var(--line);padding:5px;border-radius:4px}.panel p{line-height:1.6;font-size:14px;color:var(--muted)}.label{display:block;font:10px monospace!important;letter-spacing:1.5px;color:var(--muted);margin:0 0 10px}.balance{display:flex;align-items:baseline;gap:12px;font-size:37px;letter-spacing:-1px;font-variant-numeric:tabular-nums}.balance small{color:var(--orange);font-size:13px;letter-spacing:1px}.muted{color:var(--muted)!important;font-size:12px!important;line-height:1.65}label{display:block;font-size:12px;color:var(--muted);margin:14px 0 9px}input,textarea{width:100%;color:var(--text);background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:12px;outline:none}input{fontLine truncated