Files
app/src/adapters/inscription.js
T

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); }
}