recover-ord-pass

This commit is contained in:
recovery committed 2026-10-05 03:54:15 +00:00
1 parent aa39db3879
commit cdd5723977
15 files changed
+486 -76

No files matched your search

+43 -23
View File
@@ -1,3 +1,4 @@
import { outputRanges, satAt, offsetOf } from './sats.js';
import { Script, Signer, Tx } from '@cmdcode/tapscript';
import { sha256 } from '@noble/hashes/sha2';
import { bytesToHex } from '@noble/curves/abstract/utils';
@@ -26,6 +27,10 @@ export class LocalChain {
}
snapshot() { return { network: NETWORK, version: 1, blocks: structuredClone(this.blocks), mempool: [...this.mempool] }; }
coins(script) { return [...this.utxos.values()].filter(c => scriptHex(c.scriptPubKey) === scriptHex(script)); }
satLocation(sat) {
for (const coin of this.utxos.values()) { const offset = offsetOf(coin.ranges, sat); if (offset !== null) return { txid: coin.txid, vout: coin.vout, offset }; }
return null;
}
balance(script) { return this.coins(script).reduce((sum, c) => sum + c.value, 0); }
apply(raw, height, coinbase = false) {
assert(typeof raw === 'string' && raw.length < 40000 && /^(?:[a-f0-9]{2})+$/.test(raw), 'Invalid raw transaction.');
@@ -40,55 +45,70 @@ export class LocalChain {
assert(/^5120[0-9a-f]{64}$/.test(scriptHex(output.scriptPubKey)), 'Only Taproot outputs are supported.');
total += value;
}
let inscription, preservedParent;
const input = tx.vin[0];
let inscription;
let coins = [];
const scriptInput = tx.vin.findIndex(i => i.witness?.length === 3);
const input = tx.vin[scriptInput < 0 ? 0 : scriptInput];
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 coins = tx.vin.map(input => this.utxos.get(key(input.txid, input.vout)));
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(input.witness?.length === 1 || (i === scriptInput && 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 (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.');
const returnIndex = scriptInput;
assert(Number(tx.vout[returnIndex].value) === POSTAGE, 'Invalid reveal postage.');
assert(scriptHex(tx.vout[returnIndex].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 &&
const parentIndex = scriptInput === 0 ? 1 : 0, parent = coins[parentIndex];
assert(parent?.inscriptions?.some(i => i.id === parsed.parent), 'Referenced parent inscription must actually be spent.');
assert(tx.vout.length === 2 && Number(tx.vout[parentIndex].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.');
scriptHex(tx.vout[parentIndex].scriptPubKey) === scriptHex(parent.scriptPubKey), 'Parent must be owned and preserved in the same wallet.');
} else if (tx.vin.length === 2) {
assert(scriptInput === 1 && coins[0].inscriptions?.length && parsed.pointer !== undefined,
'Reinscription spend requires correct tag and owned sat.');
assert(Number(tx.vout[0].value) === coins[0].value && scriptHex(tx.vout[0].scriptPubKey) === scriptHex(coins[0].scriptPubKey) && scriptHex(coins[0].scriptPubKey) === `5120${parsed.pubkey}`, 'Reinscription sat must be returned to owner.');
}
config = { pubkey: parsed.pubkey };
inscription = { id: `${txid}i0`, txid, commitTxid: input.txid, text: parsed.text,
bytes: Array.from(parsed.bytes), contentType: parsed.contentType, parent: parsed.parent, metadata: parsed.metadata, height, value: POSTAGE };
} else assert(tx.vin.length === 1, 'v0 protects inscription outputs: only explicit child reveals can spend a parent.');
bytes: Array.from(parsed.bytes), contentType: parsed.contentType, parent: parsed.parent, delegate: parsed.delegate, pointer: parsed.pointer, metadata: parsed.metadata, height, value: POSTAGE };
}
tx.vin.forEach((input, i) => {
let valid = false;
try { valid = Signer.taproot.verify(tx, i, i === 0 ? config : {}); } catch { /* Normalize malformed signatures. */ }
try { valid = Signer.taproot.verify(tx, i, i === scriptInput ? 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 } : {}),
}));
const ranges = outputRanges(tx, coins, height, coinbase);
const carried = coins.flatMap(c => c.inscriptions ?? []);
if (inscription) {
const requested = BigInt(inscription.pointer ?? 0);
const position = requested < BigInt(total) ? Number(requested) : 0;
inscription.sat = satAt(ranges.flat(), position);
inscription.reinscriptionPosition = [...this.transactions.values()].filter(r => r.inscription?.sat === inscription.sat).length;
carried.push(inscription);
}
// Never silently burn an existing inscription into the synthetic fee sink.
assert(carried.every(i => ranges.some(r => offsetOf(r, i.sat) !== null)), 'Local lab protects inscription sats from fee loss.');
if (!coinbase) tx.vin.forEach(input => this.utxos.delete(key(input.txid, input.vout)));
tx.vout.forEach((output, vout) => {
const inscriptions = carried.filter(i => offsetOf(ranges[vout], i.sat) !== null);
this.utxos.set(key(txid, vout), { txid, vout, value: Number(output.value), scriptPubKey: output.scriptPubKey, height,
ranges: ranges[vout], inscriptions, ...(inscriptions.length ? { inscription: inscriptions[0] } : {}) });
});
this.transactions.set(txid, { txid, raw, height, coinbase, tx, ...(inscription ? { inscription } : {}) });
return txid;
}
+39 -15
View File
@@ -38,8 +38,9 @@ function parseParent(value) {
}
// 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, metadata = undefined) {
export function envelope(pubkey, value, parent = null, metadata = undefined, options = {}) {
const { contentType, bytes } = body(value);
const { delegate = null, pointer } = options;
const metadataBytes = metadata === undefined ? undefined : encodeMetadata(metadata);
const chunks = [];
for (let i = 0; i < bytes.length; i += 520) chunks.push(hex(bytes.slice(i, i + 520)));
@@ -52,6 +53,8 @@ export function envelope(pubkey, value, parent = null, metadata = undefined) {
: '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)) +
(delegate ? push('0b') + push(serializeParent(delegate)) : '') +
(pointer === undefined ? '' : push('02') + push(encodePointer(pointer))) +
(parent ? push('03') + push(serializeParent(parent)) : '') +
(metadataBytes === undefined ? '' : Array.from({ length: Math.ceil(metadataBytes.length / 520) }, (_, i) =>
push('05') + push(hex(metadataBytes.slice(i * 520, (i + 1) * 520)))).join('')) + '00' + chunks.map(push).join('') + '68';
@@ -59,7 +62,9 @@ export function envelope(pubkey, value, parent = null, metadata = undefined) {
export function readEnvelope(witness) {
if (!Array.isArray(witness) || witness.length !== 3) throw new Error('Unsupported script-path witness.');
const parts = Script.decode(witness[1]);
let cursor = 7, parent = null;
let cursor = 7, parent = null, delegate = null, pointer;
if (parts[cursor] === '0b') { delegate = parseParent(parts[cursor + 1]); cursor += 2; }
if (parts[cursor] === '02') { pointer = decodePointer(parts[cursor + 1] === 'OP_0' ? '' : parts[cursor + 1]); cursor += 2; }
if (parts[cursor] === '03') { parent = parseParent(parts[cursor + 1]); cursor += 2; }
const metadataChunks = [];
while (parts[cursor] === '05') {
@@ -73,30 +78,38 @@ export function readEnvelope(witness) {
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, metadata);
const expected = envelope(parts[0], value, parent, metadata, { delegate, pointer });
// Read original v0 text snapshots whose content-type tag used OP_1.
const legacy = metadata === undefined && !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 { ...value, parent, metadata, metadataBytes: metadataChunks.length ? unhex(metadataChunks.join('')) : undefined, text: new TextDecoder().decode(value.bytes), pubkey: parts[0] };
return { ...value, parent, delegate, pointer, metadata, metadataBytes: metadataChunks.length ? unhex(metadataChunks.join('')) : undefined, text: new TextDecoder().decode(value.bytes), pubkey: parts[0] };
}
export class InscriptionAdapter {
constructor(chain, wallet) { this.chain = chain; this.wallet = wallet; }
prepare(value, parentId = null, metadata = undefined) {
prepare(value, parentId = null, metadata = undefined, options = {}) {
const payload = body(value);
if (metadata !== undefined) encodeMetadata(metadata);
if (options.delegate) serializeParent(options.delegate);
if (options.pointer !== undefined) encodePointer(options.pointer);
if (parentId && options.reinscribe) throw new Error('Choose parent or reinscription, not both.');
const coins = this.chain.coins(this.wallet.script);
const parent = parentId ? coins.find(c => c.inscription?.id === parentId) : null;
const parent = parentId ? coins.find(c => c.inscriptions?.some(i => i.id === parentId)) : null;
if (parentId && !parent) throw new Error('Parent must be an owned, unspent inscription.');
const reinscribe = options.reinscribe ? coins.find(c => c.inscriptions?.some(i => i.id === options.reinscribe)) : null;
if (options.reinscribe && !reinscribe) throw new Error('Reinscription must spend an owned sat.');
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 { ...payload, coin, parent, totalFee: FEE * 2, postage: POSTAGE };
return { ...payload, coin, parent, reinscribe, totalFee: FEE * 2, postage: POSTAGE };
}
// Called only by the explicit UI “Sign & inscribe” action.
inscribe(value, parentId = null, metadata = undefined) {
const { coin, parent, ...payload } = this.prepare(value, parentId, metadata);
const script = envelope(this.wallet.pubkey, payload, parentId, metadata);
inscribe(value, parentId = null, metadata = undefined, options = {}) {
const { coin, parent, reinscribe, ...payload } = this.prepare(value, parentId, metadata, options);
const carried = parent ?? reinscribe;
const carriedItem = reinscribe?.inscriptions.find(i => i.id === options.reinscribe);
const pointer = carriedItem ? this.chain.satLocation(carriedItem.sat).offset : options.pointer ?? (parent ? parent.value : undefined);
const script = envelope(this.wallet.pubkey, payload, parentId, metadata, { ...options, pointer });
const leaf = Tap.encodeScript(script);
const [tapkey, control] = Tap.getPubKey(this.wallet.pubkey, { target: leaf });
const commit = Tx.create({
@@ -106,13 +119,24 @@ 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]), ...(parent ? [getVin(parent.txid, parent.vout, parent.value, parent.scriptPubKey)] : [])],
vout: [getVout(POSTAGE, this.wallet.script), ...(parent ? [getVout(parent.value, this.wallet.script)] : [])],
vin: [...(carried ? [getVin(carried.txid, carried.vout, carried.value, carried.scriptPubKey)] : []), getVin(commitId, 0, POSTAGE + FEE, ['OP_1', tapkey])],
vout: [...(carried ? [getVout(carried.value, this.wallet.script)] : []), getVout(POSTAGE, this.wallet.script)],
});
reveal.vin[0].witness = [this.wallet.sign(reveal, leaf), Script.encode(script, false).hex, control];
if (parent) reveal.vin[1].witness = [this.wallet.sign(reveal, undefined, 1)];
const revealInput = carried ? 1 : 0;
reveal.vin[revealInput].witness = [this.wallet.sign(reveal, leaf, revealInput), Script.encode(script, false).hex, control];
if (carried) reveal.vin[0].witness = [this.wallet.sign(reveal)];
this.chain.submitPackage([Tx.encode(commit).hex, Tx.encode(reveal).hex]);
return `${Tx.util.getTxid(reveal)}i0`;
}
list() { return this.chain.coins(this.wallet.script).filter(c => c.inscription).map(c => c.inscription); }
list() { return this.chain.coins(this.wallet.script).flatMap(c => c.inscriptions ?? []); }
}
export function encodePointer(value) {
let n; try { n = BigInt(value); } catch { throw new Error('Invalid pointer.'); }
if (n < 0n || n > 0xffffffffffffffffn || (typeof value === 'number' && !Number.isSafeInteger(value))) throw new Error('Invalid pointer.');
let result = ''; while (n) { result += Number(n & 255n).toString(16).padStart(2, '0'); n >>= 8n; } return result;
}
export function decodePointer(hex) {
if (typeof hex !== 'string' || !/^(?:[0-9a-f]{2}){0,8}$/.test(hex)) throw new Error('Invalid pointer tag.');
return [...unhex(hex)].reduce((n, b, i) => n + (BigInt(b) << BigInt(i * 8)), 0n).toString();
}
+29 -4
View File
@@ -1,3 +1,4 @@
import { offsetOf } from './sats.js';
import { Tx } from '@cmdcode/tapscript';
import { readEnvelope } from './inscription.js';
import { MetadataIndex } from './metadata-index.js';
@@ -13,6 +14,15 @@ export class LocalOrdAPI {
const chain = this.chain, index = this.index ?? new MetadataIndex(chain);
const blockInfo = block => ({ height: block.height, hash: block.hash, previous: block.previous,
time: block.time, txids: block.raws.map(raw => Tx.util.getTxid(raw)) });
if (root === 'local' && kind === 'sat' && /^(0|[1-9][0-9]*)$/.test(key ?? '')) {
const sat = Number(key), events = index.events.filter(e => e.sat === sat);
const coin = [...index.utxos.values()].find(c => c.ranges.some(([a,b]) => sat >= a && sat < b));
if (coin || events.length) return response({ sat, model: 'synthetic-local-fifo', inscriptions: events.map(e => e.id), location: coin ? { txid: coin.txid, vout: coin.vout, offset: offsetOf(coin.ranges, sat) } : null, ranges: coin?.ranges ?? [] });
}
if (root === 'local' && kind === 'utxo') {
const coin = index.utxos.get(key);
if (coin) return response({ ...coin, inscriptions: index.events.filter(e => e.location?.txid === coin.txid && e.location.vout === coin.vout).map(e => e.id) });
}
if (url.pathname === '/local/metadata-index') return response({ ...index.stats, events: index.events });
if (url.pathname === '/local/protocols') return response(index.protocols.map(protocol => {
const events = index.forProtocol(protocol);
@@ -32,15 +42,16 @@ export class LocalOrdAPI {
const event = index.events.find(e => e.id === id);
if (event) {
if (root === 'content') {
const record = chain.transactions.get(id.slice(0, -2));
const item = readEnvelope(Tx.decode(record.raw).vin[0].witness);
return response(item.bytes, item.contentType);
return resolveContent(index, id, target => {
const record = chain.transactions.get(target.replace(/i[0-9]+$/, ''));
return readEnvelope(Tx.decode(record.raw).vin.find(i => i.witness?.length === 3).witness);
});
}
if (kind === 'inscription') return response(event);
// ord's metadata endpoint returns CBOR hex. Expose decoded JSON in inscription/index responses.
if (kind === 'metadata') {
const record = chain.transactions.get(id.slice(0, -2));
const item = readEnvelope(Tx.decode(record.raw).vin[0].witness);
const item = readEnvelope(Tx.decode(record.raw).vin.find(i => i.witness?.length === 3).witness);
return response(item.metadataBytes ? Array.from(item.metadataBytes, b => b.toString(16).padStart(2, '0')).join('') : null);
}
if (kind === 'parents') return response({ ids: event.parent ? [event.parent] : [] });
@@ -49,3 +60,17 @@ export class LocalOrdAPI {
return response({ error: 'Not found in this local lab.' }, undefined, 404);
}
}
// Identity is the requested id even when the bytes/type come from another inscription.
export function resolveContent(index, id, read) {
const visited = new Set(); let target = id;
while (true) {
if (visited.has(target)) return { status: 508, contentType: 'application/json', body: { error: 'Delegate loop detected.' } };
visited.add(target);
const event = index.events.find(e => e.id === target);
if (!event) return { status: 404, contentType: 'application/json', body: { error: 'Delegate target not found.' } };
if (event.delegate) { target = event.delegate; continue; }
const item = read(target);
return { status: 200, contentType: item.contentType, body: item.bytes, inscriptionId: id, contentId: target };
}
}
+23 -3
View File
@@ -1,3 +1,4 @@
import { outputRanges, satAt, offsetOf } from './sats.js';
import { Tx } from '@cmdcode/tapscript';
import { readEnvelope } from './inscription.js';
@@ -6,12 +7,22 @@ import { readEnvelope } from './inscription.js';
export class MetadataIndex {
constructor(chain) {
this.events = [];
this.utxos = new Map();
const visit = (raw, height, block) => {
const tx = Tx.decode(raw);
if (tx.vin[0]?.witness?.length !== 3) return;
const item = readEnvelope(tx.vin[0].witness);
const txid = Tx.util.getTxid(tx), coinbase = tx.vin[0].txid === '0'.repeat(64);
const coins = coinbase ? [] : tx.vin.map(i => this.utxos.get(`${i.txid}:${i.vout}`));
const ranges = outputRanges(tx, coins, height, coinbase);
tx.vin.forEach(i => this.utxos.delete(`${i.txid}:${i.vout}`));
tx.vout.forEach((out, vout) => this.utxos.set(`${txid}:${vout}`, { txid, vout, value: Number(out.value), ranges: ranges[vout] }));
const witness = tx.vin.find(i => i.witness?.length === 3)?.witness;
if (!witness) return;
const item = readEnvelope(witness);
const pointer = BigInt(item.pointer ?? 0), total = tx.vout.reduce((n, o) => n + Number(o.value), 0);
const sat = satAt(ranges.flat(), pointer < BigInt(total) ? Number(pointer) : 0);
const metadata = item.metadata;
this.events.push({ id: `${Tx.util.getTxid(tx)}i0`, contentType: item.contentType,
this.events.push({ id: `${Tx.util.getTxid(tx)}i0`, contentType: item.contentType, delegate: item.delegate, pointer: item.pointer, sat,
reinscriptionPosition: this.events.filter(e => e.sat === sat).length,
status: height === null ? 'pending' : 'confirmed', height, block,
metadata: metadata ?? null, hasMetadata: metadata !== undefined, parent: item.parent,
protocol: metadata && !Array.isArray(metadata) && typeof metadata === 'object' &&
@@ -19,6 +30,15 @@ export class MetadataIndex {
};
for (const block of chain.blocks) for (const raw of block.raws) visit(raw, block.height, block.hash);
for (const raw of chain.mempool) visit(raw, null, null);
for (const event of this.events) {
event.location = null;
for (const coin of this.utxos.values()) {
const offset = offsetOf(coin.ranges, event.sat);
if (offset !== null) { event.location = { txid: coin.txid, vout: coin.vout, offset }; break; }
}
event.delegatedBy = this.events.filter(e => e.delegate === event.id).map(e => e.id);
event.inscriptions = this.events.filter(e => e.sat === event.sat).map(e => e.id);
}
}
forProtocol(name) { return this.events.filter(e => e.protocol === name); }
get protocols() { return [...new Set(this.events.filter(e => e.protocol !== null).map(e => e.protocol))].sort(); }
+25
View File
@@ -0,0 +1,25 @@
// Synthetic local numbering: each height issues 5e9 consecutive sats. Fees retire
// the unassigned input tail (not Bitcoin miner fee collection). Half-open ranges.
export const LOCAL_REWARD = 5e9;
export function sliceRanges(ranges, offset, length) {
const result = [];
for (const [start, end] of ranges) {
const size = end - start;
if (offset >= size) { offset -= size; continue; }
const take = Math.min(length, size - offset);
if (take) result.push([start + offset, start + offset + take]);
length -= take; offset = 0; if (!length) break;
}
return result;
}
export function satAt(ranges, offset) { return sliceRanges(ranges, offset, 1)[0]?.[0]; }
export function offsetOf(ranges, sat) {
let offset = 0;
for (const [start, end] of ranges) { if (sat >= start && sat < end) return offset + sat - start; offset += end - start; }
return null;
}
export function outputRanges(tx, coins, height, coinbase) {
const ranges = coinbase ? [[(height - 1) * LOCAL_REWARD, height * LOCAL_REWARD]] : coins.flatMap(c => c.ranges);
let offset = 0;
return tx.vout.map(out => { const result = sliceRanges(ranges, offset, Number(out.value)); offset += Number(out.value); return result; });
}
+29 -9
View File
@@ -1,3 +1,4 @@
import { RECURSIVE_DEMO } from './recursion.js';
import './style.css';
import { encodeMetadata, parseMetadataJSON } from './adapters/metadata.js';
import { MetadataIndex, deriveProtocol } from './adapters/metadata-index.js';
@@ -57,10 +58,18 @@ const composerBody = () => ({ contentType: $('mime-type').value, bytes: fileByte
function updateCount() { $('byte-count').textContent = `${composerBody().bytes.length} / ${MAX_BYTES} bytes`; }
function resetComposer() {
fileRevision++; fileBytes = null; $('body-file').value = ''; $('payload').value = '';
$('metadata').value = ''; updateMetadataCount(); $('parent').value = ''; closeReview(); updateCount();
$('delegate').value = ''; $('pointer').value = ''; $('reinscribe').value = ''; $('metadata').value = ''; updateMetadataCount(); $('parent').value = ''; closeReview(); updateCount();
}
function lineage(item, chain) {
const p = el('p', undefined, 'tx-links');
if (item.delegate) p.append(link(`Delegate ${short(item.delegate)}`, `#inscription/${item.delegate}`));
for (const record of chain.transactions.values()) if (record.inscription?.delegate === item.id)
p.append(link(`Delegated by ${short(record.inscription.id)}`, `#inscription/${record.inscription.id}`));
if (item.sat !== undefined) {
p.append(el('span', `Synthetic sat ${item.sat} · stack position ${item.reinscriptionPosition} · ${JSON.stringify(chain.satLocation(item.sat))}`));
for (const record of chain.transactions.values()) if (record.inscription?.sat === item.sat)
p.append(link(`Sat history #${record.inscription.reinscriptionPosition}`, `#inscription/${record.inscription.id}`));
}
if (item.parent) p.append(link(`Parent ${short(item.parent)}`, `#inscription/${item.parent}`));
for (const record of chain.transactions.values()) if (record.inscription?.parent === item.id)
p.append(link(`Child ${short(record.inscription.id)}`, `#inscription/${record.inscription.id}`));
@@ -163,7 +172,7 @@ function render() {
$('miner-interval').value = String(lab?.miner?.interval ?? 0);
$('miner-status').textContent = minerStatus(lab);
$('payload').disabled = disabled || !wallet || !!fileBytes || fileLoading || $('mime-type').value.startsWith('image/') && $('mime-type').value !== 'image/svg+xml';
for (const id of ['mime-type', 'body-file', 'parent', 'metadata']) $(id).disabled = disabled || !wallet || fileLoading;
for (const id of ['mime-type', 'body-file', 'parent', 'metadata', 'delegate', 'pointer', 'reinscribe', 'recursive-demo']) $(id).disabled = disabled || !wallet || fileLoading;
$('review').disabled = disabled || !wallet || !chain?.coins(wallet.script).some(c => !c.inscription) || fileLoading || !composerBody().bytes.length;
$('sign').disabled = disabled;
$('export-wallet').disabled = disabled; $('export-lab').disabled = busy;
@@ -180,6 +189,10 @@ function render() {
$('parent').replaceChildren(new Option('No parent', ''));
items.forEach(item => $('parent').append(new Option(short(item.id), item.id)));
$('parent').value = selectedParent;
const selectedSat = $('reinscribe').value;
$('reinscribe').replaceChildren(new Option('New sat', ''));
items.forEach(item => $('reinscribe').append(new Option(`Sat ${item.sat}: ${short(item.id)}`, item.id)));
$('reinscribe').value = selectedSat;
$('collection-count').textContent = items.length;
clearPreviews($('collectibles'));
$('collectibles').replaceChildren();
@@ -190,7 +203,7 @@ function render() {
}
for (const item of items.toReversed()) {
const card = el('article', undefined, 'collectible');
card.append(el('span', item.height ? `CONFIRMED · BLOCK ${item.height}` : 'PENDING · MINE TO CONFIRM', 'tag'), preview(item));
card.append(el('span', item.height ? `CONFIRMED · BLOCK ${item.height}` : 'PENDING · MINE TO CONFIRM', 'tag'), preview(item, new LocalOrdAPI(chain)));
card.append(link(`Inscription ${short(item.id)}`, `#inscription/${item.id}`));
const trail = el('p', undefined, 'tx-links');
trail.append(link('View reveal transaction ↗', `#tx/${item.txid}`));
@@ -231,7 +244,12 @@ setInterval(() => {
$('metadata').oninput = () => { closeReview(); updateMetadataCount(); };
MIME_TYPES.forEach(type => $('mime-type').append(new Option(type, type)));
$('payload').oninput = () => { closeReview(); updateCount(); render(); };
$('parent').onchange = closeReview;
$('parent').onchange = $('reinscribe').onchange = closeReview;
$('delegate').oninput = $('pointer').oninput = closeReview;
$('recursive-demo').onclick = () => {
fileRevision++; fileBytes = null; $('body-file').value = ''; $('mime-type').value = 'text/html';
$('payload').value = RECURSIVE_DEMO; closeReview(); updateCount(); render();
};
$('mime-type').onchange = () => {
fileRevision++; fileBytes = null; $('body-file').value = ''; closeReview(); updateCount(); render();
};
@@ -253,12 +271,14 @@ $('body-file').onchange = async event => {
$('review').onclick = () => {
try {
const payload = composerBody(), parent = $('parent').value || null, metadata = parseMetadataJSON($('metadata').value);
new InscriptionAdapter(lab.chain, lab.wallet).prepare(payload, parent, metadata);
const options = { delegate: $('delegate').value.trim() || null, reinscribe: $('reinscribe').value || null, ...($('pointer').value.trim() ? { pointer: $('pointer').value.trim() } : {}) };
new InscriptionAdapter(lab.chain, lab.wallet).prepare(payload, parent, metadata, options);
closeReview();
reviewed = { payload, parent, metadata, address: lab.wallet.address };
reviewed = { payload, parent, metadata, options, address: lab.wallet.address };
$('review-text').textContent = ['text/plain', 'application/json'].includes(payload.contentType) ? new TextDecoder().decode(payload.bytes) : `${payload.contentType} · ${payload.bytes.length} bytes`;
if (!['text/plain', 'application/json'].includes(payload.contentType)) $('review-preview').append(preview(payload));
$('review-parent').textContent = parent ? `Parent: ${parent}. Its output will be spent and its full value returned to this wallet. This relationship is immutable.` : 'No parent.';
$('review-parent').textContent += ` Delegate: ${options.delegate ?? 'none'}. Reinscribe: ${options.reinscribe ?? 'none'}. Pointer: ${options.pointer ?? 'automatic'}.`;
$('review-metadata').textContent = metadata === undefined ? 'No metadata.' : `Metadata (CBOR, tag 5): ${JSON.stringify(metadata, null, 2)}`;
$('review-panel').hidden = false;
$('sign').focus(); message('Review the payload and fake-sat costs. Nothing has been signed yet.');
@@ -270,7 +290,7 @@ $('sign').onclick = () => {
if (!consent) return;
mutate(next => {
if (next.wallet?.address !== consent.address) throw new Error('Wallet changed. Review again before signing.');
new InscriptionAdapter(next.chain, next.wallet).inscribe(consent.payload, consent.parent, consent.metadata);
new InscriptionAdapter(next.chain, next.wallet).inscribe(consent.payload, consent.parent, consent.metadata, consent.options);
}, 'Inscription submitted. Its commit + reveal are now visible in the mempool; mine a block to confirm them.', resetComposer);
};
$('export-wallet').onclick = () => download('super-testnet-wallet.json', JSON.stringify(lab.wallet.backup(), null, 2));
@@ -336,7 +356,7 @@ function inspect(scroll = true) {
if (record.inscription) {
add('Inscription ID', record.inscription.id); add('Content type', record.inscription.contentType);
add('Body size', `${record.inscription.bytes.length} bytes`);
pane.append(preview(record.inscription), metadataView(record.inscription), lineage(record.inscription, chain));
pane.append(preview(record.inscription, new LocalOrdAPI(chain)), metadataView(record.inscription), lineage(record.inscription, chain));
pane.append(link('Inspect commit transaction ↗', `#tx/${record.inscription.commitTxid}`));
}
pane.append(el('h3', 'Inputs → outputs'));
@@ -350,7 +370,7 @@ function inspect(scroll = true) {
tx.vout.forEach((out, i) => add(`Output ${i}`, `${Number(out.value).toLocaleString()} sats`));
add('Fee', record.coinbase ? '0 (local reward)' : '500 fake sats');
pane.append(el('h3', 'Raw Bitcoin transaction'), el('pre', rpc.call('getrawtransaction', [txid])));
if (!record.coinbase) { pane.append(el('h3', 'Witness'), el('pre', JSON.stringify(tx.vin[0].witness, null, 2))); }
if (!record.coinbase) { pane.append(el('h3', 'Witness'), el('pre', JSON.stringify(tx.vin.map(input => input.witness), null, 2))); }
} else throw new Error('Unknown explorer view.');
} catch (error) { pane.append(el('p', error.message)); }
if (scroll) { pane.scrollIntoView({ behavior: 'smooth', block: 'start' }); pane.focus({ preventScroll: true }); }
+17 -2
View File
@@ -1,8 +1,14 @@
import { recursivePayload, connectRecursion } from './recursion.js';
// Inert parsing only; inscription markup is never attached to the parent DOM.
// The deliberately small allowlist also removes navigation (including meta refresh).
const tags = new Set('p div span h1 h2 h3 h4 h5 h6 br hr pre code b strong i em u s ul ol li table thead tbody tr td th blockquote svg g path rect circle ellipse line polyline polygon text tspan defs lineargradient radialgradient stop'.split(' '));
const attrs = new Set('style title width height viewbox fill stroke stroke-width d x y x1 y1 x2 y2 cx cy r rx ry points transform opacity offset stop-color font-size text-anchor'.split(' '));
export function preview(item) {
export function preview(item, api = null) {
if (api && item.id) {
const response = api.request(`/content/${item.id}`);
if (response.status !== 200) { const error = document.createElement('pre'); error.textContent = `${response.status}: ${response.body.error}`; return error; }
item = { ...item, bytes: response.body, contentType: response.contentType };
}
const bytes = new Uint8Array(item.bytes);
const type = item.contentType.split(';')[0];
const text = new TextDecoder().decode(bytes);
@@ -13,6 +19,7 @@ export function preview(item) {
if (type === 'text/html' || type === 'image/svg+xml') {
const inert = document.implementation.createHTMLDocument('');
const template = inert.createElement('template'); template.innerHTML = text;
const scripts = [...template.content.querySelectorAll('script')].filter(s => !s.src).map(s => s.textContent);
const clean = parent => {
for (const child of [...parent.children]) {
if (!tags.has(child.localName.toLowerCase())) { child.remove(); continue; }
@@ -21,6 +28,14 @@ export function preview(item) {
}
};
clean(template.content);
if (api && item.id && scripts.length) {
node = document.createElement('iframe'); node.setAttribute('sandbox', 'allow-scripts');
node.title = `${type} recursive preview`; node.referrerPolicy = 'no-referrer';
const payload = recursivePayload(template.innerHTML, scripts, item.id);
node.onload = () => { node.cleanup?.(); node.cleanup = connectRecursion(node, api, payload); };
node.src = '/recursive-preview.html'; node.dataset.previewUrl = ''; node.className = 'inscription-preview';
return node;
}
const csp = "default-src 'none'; style-src 'unsafe-inline'; script-src 'none'; form-action 'none'; base-uri 'none'";
blob = new Blob([`<!doctype html><meta http-equiv="Content-Security-Policy" content="${csp}">`, template.innerHTML], { type: 'text/html' });
node = document.createElement('iframe'); node.setAttribute('sandbox', ''); node.title = `${type} safe preview`;
@@ -32,5 +47,5 @@ export function preview(item) {
node.className = 'inscription-preview'; return node;
}
export function clearPreviews(root) {
root.querySelectorAll('[data-preview-url]').forEach(node => URL.revokeObjectURL(node.dataset.previewUrl));
root.querySelectorAll('[data-preview-url]').forEach(node => { node.cleanup?.(); URL.revokeObjectURL(node.dataset.previewUrl); });
}
+44
View File
@@ -0,0 +1,44 @@
// Deliberately exact paths: no URLs, query strings, escapes, traversal or local
// mutation endpoints. Both sides validate; the parent is the authority.
export function recursivePath(path) {
return typeof path === 'string' && /^(?:\/content\/[0-9a-f]{64}i(?:0|[1-9][0-9]*)|\/r\/(?:inscription|metadata|parents|children)\/[0-9a-f]{64}i(?:0|[1-9][0-9]*)|\/r\/tx\/[0-9a-f]{64}|\/r\/blockheight|\/r\/blockhash\/(?:0|[1-9][0-9]*)|\/r\/blockinfo\/(?:0|[1-9][0-9]*|[0-9a-f]{64}))$/.test(path);
}
export const RECURSIVE_DEMO = `<h2>Recursive local inscription</h2><p>Live data from this browser's chain:</p>
<script>
const height = await (await fetch('/r/blockheight')).json();
const me = await (await fetch('/r/inscription/' + ord.id)).json();
const metadata = await (await fetch('/r/metadata/' + ord.id)).json();
ord.render(JSON.stringify({ id: ord.id, height, sat: me.sat, metadata }, null, 2));
</script>`;
// This function is serialized into a worker, never executed in the parent DOM.
function workerBootstrap(id, allowed) {
let next = 0; const pending = new Map();
self.onmessage = ({ data }) => {
const resolve = pending.get(data.token); if (!resolve) return;
pending.delete(data.token); resolve(data);
};
self.fetch = async (path, options = {}) => {
if (!allowed(path) || (options.method && options.method !== 'GET')) throw new TypeError('Only whitelisted local GET paths are allowed.');
const token = ++next;
const response = await new Promise(resolve => { pending.set(token, resolve); postMessage({ type: 'fetch', token, path }); });
if (response.error) throw new TypeError(response.error);
const body = response.body instanceof Uint8Array ? response.body : JSON.stringify(response.body);
return new Response(body, { status: response.status, headers: { 'Content-Type': response.contentType } });
};
self.ord = Object.freeze({ id, contentPath: '/content/' + id, render: text => postMessage({ type: 'render', text: String(text).slice(0, 65536) }) });
}
export function recursivePayload(markup, scripts, id) {
const worker = `const recursivePath = ${recursivePath.toString()}; (${workerBootstrap.toString()})(${JSON.stringify(id)}, recursivePath); (async () => {${scripts.join('\n;\n')}\n})().catch(e => ord.render(e.message));`;
return { markup, worker };
}
export function connectRecursion(frame, api, payload) {
const channel = new MessageChannel();
channel.port1.onmessage = ({data}) => {
if (!data || data.type !== 'fetch') return;
const result = recursivePath(data.path) ? api.request(data.path) : { error: 'Blocked local path.' };
channel.port1.postMessage({ ...result, token: data.token });
};
frame.contentWindow.postMessage({ type: 'local-ord', ...payload }, '*', [channel.port2]);
return () => channel.port1.close();
}