105 lines
6.5 KiB
JavaScript
105 lines
6.5 KiB
JavaScript
import { Script, Tap, Tx } from '@cmdcode/tapscript';
|
|
import { getVin, getVout } from '../upstream/primitives.js';
|
|
|
|
export const MAX_BYTES = 16 * 1024;
|
|
export const POSTAGE = 1000;
|
|
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 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.');
|
|
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';
|
|
}
|
|
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 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 { ...value, parent, 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) {
|
|
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 { ...payload, coin, parent, totalFee: FEE * 2, postage: POSTAGE };
|
|
}
|
|
// Called only by the explicit UI “Sign & inscribe” action.
|
|
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({
|
|
vin: [getVin(coin.txid, coin.vout, coin.value, coin.scriptPubKey)],
|
|
vout: [getVout(POSTAGE + FEE, ['OP_1', tapkey]), getVout(coin.value - POSTAGE - 2 * FEE, this.wallet.script)],
|
|
});
|
|
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)] : [])],
|
|
});
|
|
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)];
|
|
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); }
|
|
}
|