recover-ord-pass
This commit is contained in:
1 parent
aa39db3879
commit
cdd5723977
15 files changed
+486
-76
No files matched your search
+43
-23
@@ -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
@@ -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();
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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(); }
|
||||
|
||||
@@ -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
@@ -1,3 +1,4 @@
|
||||
import { RECURSIVE_DEMO } from './recursion.js';
|
||||
import './style.css';
|
||||
import { encodeMetadata, parseMetadataJSON } from './adapters/metadata.js';
|
||||
import { MetadataIndex, deriveProtocol } from './adapters/metadata-index.js';
|
||||
@@ -57,10 +58,18 @@ const composerBody = () => ({ contentType: $('mime-type').value, bytes: fileByte
|
||||
function updateCount() { $('byte-count').textContent = `${composerBody().bytes.length} / ${MAX_BYTES} bytes`; }
|
||||
function resetComposer() {
|
||||
fileRevision++; fileBytes = null; $('body-file').value = ''; $('payload').value = '';
|
||||
$('metadata').value = ''; updateMetadataCount(); $('parent').value = ''; closeReview(); updateCount();
|
||||
$('delegate').value = ''; $('pointer').value = ''; $('reinscribe').value = ''; $('metadata').value = ''; updateMetadataCount(); $('parent').value = ''; closeReview(); updateCount();
|
||||
}
|
||||
function lineage(item, chain) {
|
||||
const p = el('p', undefined, 'tx-links');
|
||||
if (item.delegate) p.append(link(`Delegate ${short(item.delegate)}`, `#inscription/${item.delegate}`));
|
||||
for (const record of chain.transactions.values()) if (record.inscription?.delegate === item.id)
|
||||
p.append(link(`Delegated by ${short(record.inscription.id)}`, `#inscription/${record.inscription.id}`));
|
||||
if (item.sat !== undefined) {
|
||||
p.append(el('span', `Synthetic sat ${item.sat} · stack position ${item.reinscriptionPosition} · ${JSON.stringify(chain.satLocation(item.sat))}`));
|
||||
for (const record of chain.transactions.values()) if (record.inscription?.sat === item.sat)
|
||||
p.append(link(`Sat history #${record.inscription.reinscriptionPosition}`, `#inscription/${record.inscription.id}`));
|
||||
}
|
||||
if (item.parent) p.append(link(`Parent ${short(item.parent)}`, `#inscription/${item.parent}`));
|
||||
for (const record of chain.transactions.values()) if (record.inscription?.parent === item.id)
|
||||
p.append(link(`Child ${short(record.inscription.id)}`, `#inscription/${record.inscription.id}`));
|
||||
@@ -163,7 +172,7 @@ function render() {
|
||||
$('miner-interval').value = String(lab?.miner?.interval ?? 0);
|
||||
$('miner-status').textContent = minerStatus(lab);
|
||||
$('payload').disabled = disabled || !wallet || !!fileBytes || fileLoading || $('mime-type').value.startsWith('image/') && $('mime-type').value !== 'image/svg+xml';
|
||||
for (const id of ['mime-type', 'body-file', 'parent', 'metadata']) $(id).disabled = disabled || !wallet || fileLoading;
|
||||
for (const id of ['mime-type', 'body-file', 'parent', 'metadata', 'delegate', 'pointer', 'reinscribe', 'recursive-demo']) $(id).disabled = disabled || !wallet || fileLoading;
|
||||
$('review').disabled = disabled || !wallet || !chain?.coins(wallet.script).some(c => !c.inscription) || fileLoading || !composerBody().bytes.length;
|
||||
$('sign').disabled = disabled;
|
||||
$('export-wallet').disabled = disabled; $('export-lab').disabled = busy;
|
||||
@@ -180,6 +189,10 @@ function render() {
|
||||
$('parent').replaceChildren(new Option('No parent', ''));
|
||||
items.forEach(item => $('parent').append(new Option(short(item.id), item.id)));
|
||||
$('parent').value = selectedParent;
|
||||
const selectedSat = $('reinscribe').value;
|
||||
$('reinscribe').replaceChildren(new Option('New sat', ''));
|
||||
items.forEach(item => $('reinscribe').append(new Option(`Sat ${item.sat}: ${short(item.id)}`, item.id)));
|
||||
$('reinscribe').value = selectedSat;
|
||||
$('collection-count').textContent = items.length;
|
||||
clearPreviews($('collectibles'));
|
||||
$('collectibles').replaceChildren();
|
||||
@@ -190,7 +203,7 @@ function render() {
|
||||
}
|
||||
for (const item of items.toReversed()) {
|
||||
const card = el('article', undefined, 'collectible');
|
||||
card.append(el('span', item.height ? `CONFIRMED · BLOCK ${item.height}` : 'PENDING · MINE TO CONFIRM', 'tag'), preview(item));
|
||||
card.append(el('span', item.height ? `CONFIRMED · BLOCK ${item.height}` : 'PENDING · MINE TO CONFIRM', 'tag'), preview(item, new LocalOrdAPI(chain)));
|
||||
card.append(link(`Inscription ${short(item.id)}`, `#inscription/${item.id}`));
|
||||
const trail = el('p', undefined, 'tx-links');
|
||||
trail.append(link('View reveal transaction ↗', `#tx/${item.txid}`));
|
||||
@@ -231,7 +244,12 @@ setInterval(() => {
|
||||
$('metadata').oninput = () => { closeReview(); updateMetadataCount(); };
|
||||
MIME_TYPES.forEach(type => $('mime-type').append(new Option(type, type)));
|
||||
$('payload').oninput = () => { closeReview(); updateCount(); render(); };
|
||||
$('parent').onchange = closeReview;
|
||||
$('parent').onchange = $('reinscribe').onchange = closeReview;
|
||||
$('delegate').oninput = $('pointer').oninput = closeReview;
|
||||
$('recursive-demo').onclick = () => {
|
||||
fileRevision++; fileBytes = null; $('body-file').value = ''; $('mime-type').value = 'text/html';
|
||||
$('payload').value = RECURSIVE_DEMO; closeReview(); updateCount(); render();
|
||||
};
|
||||
$('mime-type').onchange = () => {
|
||||
fileRevision++; fileBytes = null; $('body-file').value = ''; closeReview(); updateCount(); render();
|
||||
};
|
||||
@@ -253,12 +271,14 @@ $('body-file').onchange = async event => {
|
||||
$('review').onclick = () => {
|
||||
try {
|
||||
const payload = composerBody(), parent = $('parent').value || null, metadata = parseMetadataJSON($('metadata').value);
|
||||
new InscriptionAdapter(lab.chain, lab.wallet).prepare(payload, parent, metadata);
|
||||
const options = { delegate: $('delegate').value.trim() || null, reinscribe: $('reinscribe').value || null, ...($('pointer').value.trim() ? { pointer: $('pointer').value.trim() } : {}) };
|
||||
new InscriptionAdapter(lab.chain, lab.wallet).prepare(payload, parent, metadata, options);
|
||||
closeReview();
|
||||
reviewed = { payload, parent, metadata, address: lab.wallet.address };
|
||||
reviewed = { payload, parent, metadata, options, address: lab.wallet.address };
|
||||
$('review-text').textContent = ['text/plain', 'application/json'].includes(payload.contentType) ? new TextDecoder().decode(payload.bytes) : `${payload.contentType} · ${payload.bytes.length} bytes`;
|
||||
if (!['text/plain', 'application/json'].includes(payload.contentType)) $('review-preview').append(preview(payload));
|
||||
$('review-parent').textContent = parent ? `Parent: ${parent}. Its output will be spent and its full value returned to this wallet. This relationship is immutable.` : 'No parent.';
|
||||
$('review-parent').textContent += ` Delegate: ${options.delegate ?? 'none'}. Reinscribe: ${options.reinscribe ?? 'none'}. Pointer: ${options.pointer ?? 'automatic'}.`;
|
||||
$('review-metadata').textContent = metadata === undefined ? 'No metadata.' : `Metadata (CBOR, tag 5): ${JSON.stringify(metadata, null, 2)}`;
|
||||
$('review-panel').hidden = false;
|
||||
$('sign').focus(); message('Review the payload and fake-sat costs. Nothing has been signed yet.');
|
||||
@@ -270,7 +290,7 @@ $('sign').onclick = () => {
|
||||
if (!consent) return;
|
||||
mutate(next => {
|
||||
if (next.wallet?.address !== consent.address) throw new Error('Wallet changed. Review again before signing.');
|
||||
new InscriptionAdapter(next.chain, next.wallet).inscribe(consent.payload, consent.parent, consent.metadata);
|
||||
new InscriptionAdapter(next.chain, next.wallet).inscribe(consent.payload, consent.parent, consent.metadata, consent.options);
|
||||
}, 'Inscription submitted. Its commit + reveal are now visible in the mempool; mine a block to confirm them.', resetComposer);
|
||||
};
|
||||
$('export-wallet').onclick = () => download('super-testnet-wallet.json', JSON.stringify(lab.wallet.backup(), null, 2));
|
||||
@@ -336,7 +356,7 @@ function inspect(scroll = true) {
|
||||
if (record.inscription) {
|
||||
add('Inscription ID', record.inscription.id); add('Content type', record.inscription.contentType);
|
||||
add('Body size', `${record.inscription.bytes.length} bytes`);
|
||||
pane.append(preview(record.inscription), metadataView(record.inscription), lineage(record.inscription, chain));
|
||||
pane.append(preview(record.inscription, new LocalOrdAPI(chain)), metadataView(record.inscription), lineage(record.inscription, chain));
|
||||
pane.append(link('Inspect commit transaction ↗', `#tx/${record.inscription.commitTxid}`));
|
||||
}
|
||||
pane.append(el('h3', 'Inputs → outputs'));
|
||||
@@ -350,7 +370,7 @@ function inspect(scroll = true) {
|
||||
tx.vout.forEach((out, i) => add(`Output ${i}`, `${Number(out.value).toLocaleString()} sats`));
|
||||
add('Fee', record.coinbase ? '0 (local reward)' : '500 fake sats');
|
||||
pane.append(el('h3', 'Raw Bitcoin transaction'), el('pre', rpc.call('getrawtransaction', [txid])));
|
||||
if (!record.coinbase) { pane.append(el('h3', 'Witness'), el('pre', JSON.stringify(tx.vin[0].witness, null, 2))); }
|
||||
if (!record.coinbase) { pane.append(el('h3', 'Witness'), el('pre', JSON.stringify(tx.vin.map(input => input.witness), null, 2))); }
|
||||
} else throw new Error('Unknown explorer view.');
|
||||
} catch (error) { pane.append(el('p', error.message)); }
|
||||
if (scroll) { pane.scrollIntoView({ behavior: 'smooth', block: 'start' }); pane.focus({ preventScroll: true }); }
|
||||
|
||||
+17
-2
@@ -1,8 +1,14 @@
|
||||
import { recursivePayload, connectRecursion } from './recursion.js';
|
||||
// Inert parsing only; inscription markup is never attached to the parent DOM.
|
||||
// The deliberately small allowlist also removes navigation (including meta refresh).
|
||||
const tags = new Set('p div span h1 h2 h3 h4 h5 h6 br hr pre code b strong i em u s ul ol li table thead tbody tr td th blockquote svg g path rect circle ellipse line polyline polygon text tspan defs lineargradient radialgradient stop'.split(' '));
|
||||
const attrs = new Set('style title width height viewbox fill stroke stroke-width d x y x1 y1 x2 y2 cx cy r rx ry points transform opacity offset stop-color font-size text-anchor'.split(' '));
|
||||
export function preview(item) {
|
||||
export function preview(item, api = null) {
|
||||
if (api && item.id) {
|
||||
const response = api.request(`/content/${item.id}`);
|
||||
if (response.status !== 200) { const error = document.createElement('pre'); error.textContent = `${response.status}: ${response.body.error}`; return error; }
|
||||
item = { ...item, bytes: response.body, contentType: response.contentType };
|
||||
}
|
||||
const bytes = new Uint8Array(item.bytes);
|
||||
const type = item.contentType.split(';')[0];
|
||||
const text = new TextDecoder().decode(bytes);
|
||||
@@ -13,6 +19,7 @@ export function preview(item) {
|
||||
if (type === 'text/html' || type === 'image/svg+xml') {
|
||||
const inert = document.implementation.createHTMLDocument('');
|
||||
const template = inert.createElement('template'); template.innerHTML = text;
|
||||
const scripts = [...template.content.querySelectorAll('script')].filter(s => !s.src).map(s => s.textContent);
|
||||
const clean = parent => {
|
||||
for (const child of [...parent.children]) {
|
||||
if (!tags.has(child.localName.toLowerCase())) { child.remove(); continue; }
|
||||
@@ -21,6 +28,14 @@ export function preview(item) {
|
||||
}
|
||||
};
|
||||
clean(template.content);
|
||||
if (api && item.id && scripts.length) {
|
||||
node = document.createElement('iframe'); node.setAttribute('sandbox', 'allow-scripts');
|
||||
node.title = `${type} recursive preview`; node.referrerPolicy = 'no-referrer';
|
||||
const payload = recursivePayload(template.innerHTML, scripts, item.id);
|
||||
node.onload = () => { node.cleanup?.(); node.cleanup = connectRecursion(node, api, payload); };
|
||||
node.src = '/recursive-preview.html'; node.dataset.previewUrl = ''; node.className = 'inscription-preview';
|
||||
return node;
|
||||
}
|
||||
const csp = "default-src 'none'; style-src 'unsafe-inline'; script-src 'none'; form-action 'none'; base-uri 'none'";
|
||||
blob = new Blob([`<!doctype html><meta http-equiv="Content-Security-Policy" content="${csp}">`, template.innerHTML], { type: 'text/html' });
|
||||
node = document.createElement('iframe'); node.setAttribute('sandbox', ''); node.title = `${type} safe preview`;
|
||||
@@ -32,5 +47,5 @@ export function preview(item) {
|
||||
node.className = 'inscription-preview'; return node;
|
||||
}
|
||||
export function clearPreviews(root) {
|
||||
root.querySelectorAll('[data-preview-url]').forEach(node => URL.revokeObjectURL(node.dataset.previewUrl));
|
||||
root.querySelectorAll('[data-preview-url]').forEach(node => { node.cleanup?.(); URL.revokeObjectURL(node.dataset.previewUrl); });
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Deliberately exact paths: no URLs, query strings, escapes, traversal or local
|
||||
// mutation endpoints. Both sides validate; the parent is the authority.
|
||||
export function recursivePath(path) {
|
||||
return typeof path === 'string' && /^(?:\/content\/[0-9a-f]{64}i(?:0|[1-9][0-9]*)|\/r\/(?:inscription|metadata|parents|children)\/[0-9a-f]{64}i(?:0|[1-9][0-9]*)|\/r\/tx\/[0-9a-f]{64}|\/r\/blockheight|\/r\/blockhash\/(?:0|[1-9][0-9]*)|\/r\/blockinfo\/(?:0|[1-9][0-9]*|[0-9a-f]{64}))$/.test(path);
|
||||
}
|
||||
export const RECURSIVE_DEMO = `<h2>Recursive local inscription</h2><p>Live data from this browser's chain:</p>
|
||||
<script>
|
||||
const height = await (await fetch('/r/blockheight')).json();
|
||||
const me = await (await fetch('/r/inscription/' + ord.id)).json();
|
||||
const metadata = await (await fetch('/r/metadata/' + ord.id)).json();
|
||||
ord.render(JSON.stringify({ id: ord.id, height, sat: me.sat, metadata }, null, 2));
|
||||
</script>`;
|
||||
|
||||
// This function is serialized into a worker, never executed in the parent DOM.
|
||||
function workerBootstrap(id, allowed) {
|
||||
let next = 0; const pending = new Map();
|
||||
self.onmessage = ({ data }) => {
|
||||
const resolve = pending.get(data.token); if (!resolve) return;
|
||||
pending.delete(data.token); resolve(data);
|
||||
};
|
||||
self.fetch = async (path, options = {}) => {
|
||||
if (!allowed(path) || (options.method && options.method !== 'GET')) throw new TypeError('Only whitelisted local GET paths are allowed.');
|
||||
const token = ++next;
|
||||
const response = await new Promise(resolve => { pending.set(token, resolve); postMessage({ type: 'fetch', token, path }); });
|
||||
if (response.error) throw new TypeError(response.error);
|
||||
const body = response.body instanceof Uint8Array ? response.body : JSON.stringify(response.body);
|
||||
return new Response(body, { status: response.status, headers: { 'Content-Type': response.contentType } });
|
||||
};
|
||||
self.ord = Object.freeze({ id, contentPath: '/content/' + id, render: text => postMessage({ type: 'render', text: String(text).slice(0, 65536) }) });
|
||||
}
|
||||
export function recursivePayload(markup, scripts, id) {
|
||||
const worker = `const recursivePath = ${recursivePath.toString()}; (${workerBootstrap.toString()})(${JSON.stringify(id)}, recursivePath); (async () => {${scripts.join('\n;\n')}\n})().catch(e => ord.render(e.message));`;
|
||||
return { markup, worker };
|
||||
}
|
||||
export function connectRecursion(frame, api, payload) {
|
||||
const channel = new MessageChannel();
|
||||
channel.port1.onmessage = ({data}) => {
|
||||
if (!data || data.type !== 'fetch') return;
|
||||
const result = recursivePath(data.path) ? api.request(data.path) : { error: 'Blocked local path.' };
|
||||
channel.port1.postMessage({ ...result, token: data.token });
|
||||
};
|
||||
frame.contentWindow.postMessage({ type: 'local-ord', ...payload }, '*', [channel.port2]);
|
||||
return () => channel.port1.close();
|
||||
}
|
||||
Reference in new issue
Block a user