Add synthetic sat tracking and inscription pointers

This commit is contained in:
recovery committed 2026-10-05 03:56:52 +00:00
1 parent aa39db3879
commit 4190cedca9
3 files changed
+107 -38

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