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
+38
-22
@@ -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
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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'));
|
||||
|
||||
@@ -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
@@ -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
|
||||
Reference in new issue
Block a user