Orgchain v1.9: lineage/v0 as an ordinals metaprotocol, rehearsed on regtest

SPEC.md writes lineage/v0 down as an ordinals metaprotocol: the envelope
fields, chain order, authority by verified parents, every rule, ownership by
sat. bin/lineage-index.mjs reads lineage events from a real ord server and
folds them with the same reducer the lab uses.

tools/regtest rehearses the sample on Bitcoin Core regtest with standardness
on: the lab's own action code signs on a chain funded only by coins imported
from regtest (LocalChain.importCoin), each beat's transactions are broadcast
byte for byte and mined, ord indexes them, and --compare checks the same
events in the same beats, the same metadata and parents, every inscription
in the same output and the same trees. tests/indexer.test.js replays a
recorded run.

The first rehearsal found that reveals committed to tapscript's re-encoded
tapleaf, which real Bitcoin rejects. They now commit to the exact script
bytes, and the local chain checks script paths per BIP-341/342, still
loading reveals made before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4upF5LVwmPDDqrhdUfnXh
This commit is contained in:
teahubxyzandClaude Opus 5.5 committed 2026-10-15 02:04:35 -07:00
1 parent 7d981341ff
commit 7c9306402d
21 files changed
+602 -21

No files matched your search

+50 -7
View File
@@ -1,9 +1,9 @@
import { outputRanges, satAt, offsetOf } from './sats.js';
import { importRanges, outputRanges, satAt, offsetOf } from './sats.js';
import { Script, Signer, Tx } from '@cmdcode/tapscript';
import { sha256 } from '@noble/hashes/sha2';
import { ripemd160 } from '@noble/hashes/legacy';
import { secp256k1 } from '@noble/curves/secp256k1';
import { bytesToHex, hexToBytes } from '@noble/curves/abstract/utils';
import { schnorr, secp256k1 } from '@noble/curves/secp256k1';
import { bytesToHex, bytesToNumberBE, concatBytes, hexToBytes } from '@noble/curves/abstract/utils';
import { getVout } from '../upstream/primitives.js';
import { readEnvelope, POSTAGE, FEE } from './inscription.js';
import { NETWORK } from './wallet.js';
@@ -29,19 +29,58 @@ function segwitValid(tx, i) {
return secp256k1.verify(sig.slice(0, -2), Signer.segwit.hash(tx, i, { pubkey, sigflag: 0x01 }), pubkey, { lowS: true, format: 'der' });
}
// BIP-341 and BIP-342 for a reveal's script path: the control block commits the script's exact bytes to the spent
// output's key, and the script's key signs for that leaf. Orgchain v1.9, after real Bitcoin rejected reveals that
// tapscript's own check had passed: it hashes a re-encoded script, not the bytes in the witness.
const tagged = (tag, ...parts) => { const t = sha256(new TextEncoder().encode(tag)); return sha256(concatBytes(t, t, ...parts)); };
const compactSize = n => (n < 0xfd ? Uint8Array.of(n) : Uint8Array.of(0xfd, n & 255, n >> 8));
export function tapscriptValid(tx, i) {
const [sig, script, control] = tx.vin[i].witness, cb = hexToBytes(control), bytes = hexToBytes(script);
if (cb.length < 33 || (cb.length - 33) % 32 || bytes.length > 0xffff || !/^20[0-9a-f]{64}ac/.test(script)) return false;
const leaf = tagged('TapLeaf', Uint8Array.of(cb[0] & 0xfe), compactSize(bytes.length), bytes);
let node = leaf;
for (let j = 33; j < cb.length; j += 32) {
const other = cb.slice(j, j + 32), first = bytesToHex(node) <= bytesToHex(other);
node = tagged('TapBranch', first ? node : other, first ? other : node);
}
const tweak = bytesToNumberBE(tagged('TapTweak', cb.slice(1, 33), node));
if (tweak >= secp256k1.CURVE.n) return false;
const key = secp256k1.ProjectivePoint.fromHex(`02${bytesToHex(cb.slice(1, 33))}`).add(secp256k1.ProjectivePoint.BASE.multiply(tweak)).toAffine();
if (`5120${key.x.toString(16).padStart(64, '0')}` !== scriptHex(tx.vin[i].prevout.scriptPubKey) || Number(key.y & 1n) !== (cb[0] & 1)) return false;
return schnorr.verify(sig, Signer.taproot.hash(tx, i, { extension: bytesToHex(leaf) }), script.slice(2, 66));
}
// Original, intentionally narrow implementation of testnet_generator's local UTXO/mining model.
// This is NOT a Bitcoin consensus engine. Only our key-path and ord MIME templates, and P2WPKH payments, are accepted.
export class LocalChain {
constructor(snapshot) {
this.blocks = []; this.mempool = []; this.utxos = new Map(); this.transactions = new Map();
this.blocks = []; this.mempool = []; this.utxos = new Map(); this.transactions = new Map(); this.imports = [];
if (snapshot) {
assert(snapshot.network === NETWORK && snapshot.version === 1, 'Unsupported local chain version/network.');
assert(Array.isArray(snapshot.blocks) && Array.isArray(snapshot.mempool), 'Invalid chain snapshot.');
assert(Array.isArray(snapshot.blocks) && Array.isArray(snapshot.mempool) && Array.isArray(snapshot.imports ?? []), 'Invalid chain snapshot.');
for (const coin of snapshot.imports ?? []) this.importCoin(coin);
for (const block of snapshot.blocks) this.acceptBlock(structuredClone(block));
this.submitPackage(snapshot.mempool);
}
}
snapshot() { return { network: NETWORK, version: 1, blocks: structuredClone(this.blocks), mempool: [...this.mempool] }; }
snapshot() {
return { network: NETWORK, version: 1, blocks: structuredClone(this.blocks), mempool: [...this.mempool],
...(this.imports.length ? { imports: structuredClone(this.imports) } : {}) };
}
// Orgchain: a coin that already exists on a real chain (regtest, say), taken as given like a genesis allocation, so
// the lab's own transactions can spend it and then be broadcast there byte for byte. Nothing here checks that chain.
importCoin({ txid, vout, value, script }) {
assert(/^[0-9a-f]{64}$/.test(txid) && Number.isSafeInteger(vout) && vout >= 0 && Number.isSafeInteger(value) && value >= 330 && value <= REWARD &&
(TAPROOT.test(script) || P2WPKH.test(script)), 'Invalid imported coin.');
assert(!this.utxos.has(key(txid, vout)) && !this.transactions.has(txid) && !this.imported(txid, vout), 'That coin is already in this chain.');
this.utxos.set(key(txid, vout), { txid, vout, value, scriptPubKey: script, height: 0, ranges: importRanges(this.imports.length, value), inscriptions: [] });
this.imports.push({ txid, vout, value, script });
}
// What an imported coin was, as a spent output reads: { value, scriptPubKey }.
imported(txid, vout) {
const coin = this.imports.find(c => c.txid === txid && c.vout === vout);
return coin ? { value: coin.value, scriptPubKey: coin.script } : null;
}
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 }; }
@@ -115,7 +154,11 @@ export class LocalChain {
}
tx.vin.forEach((input, i) => {
let valid = false;
try { valid = P2WPKH.test(scriptHex(coins[i].scriptPubKey)) ? segwitValid(tx, i) : Signer.taproot.verify(tx, i, i === scriptInput ? config : {}); } catch { /* Normalize malformed signatures. */ }
// A reveal from before v1.9 committed to tapscript's re-encoded leaf; the lab still loads it, and makes BIP-341 ones.
try {
valid = P2WPKH.test(scriptHex(coins[i].scriptPubKey)) ? segwitValid(tx, i)
: i === scriptInput ? tapscriptValid(tx, i) || Signer.taproot.verify(tx, i, config) : Signer.taproot.verify(tx, i, {});
} catch { /* Normalize malformed signatures. */ }
assert(valid, 'Invalid signature or Taproot commitment.');
});
+5 -1
View File
@@ -62,6 +62,10 @@ export function envelope(pubkey, value, parent = null, metadata = undefined, opt
push('05') + push(hex(metadataBytes.slice(i * 520, (i + 1) * 520)))).join('')) +
(metaprotocolBytes ? push('07') + push(hex(metaprotocolBytes)) : '') + '00' + chunks.map(push).join('') + '68';
}
// BIP-341: a tapleaf commits to its script's exact bytes (leaf version, compact size, script). tapscript's encodeScript
// re-encodes a hex script and turns one-byte pushes into numbers, so it committed to bytes the witness never shows, and
// real Bitcoin rejected those reveals (found by the regtest rehearsal; the local chain still loads the ones made so).
export const tapleafOf = script => Tap.tree.getLeaf(Script.encode(script, true));
export function readEnvelope(witness) {
if (!Array.isArray(witness) || witness.length !== 3) throw new Error('Unsupported script-path witness.');
const parts = Script.decode(witness[1]);
@@ -120,7 +124,7 @@ export class InscriptionAdapter {
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 leaf = tapleafOf(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)],
+3 -1
View File
@@ -1,4 +1,4 @@
import { outputRanges, satAt, offsetOf } from './sats.js';
import { importRanges, outputRanges, satAt, offsetOf } from './sats.js';
import { Tx } from '@cmdcode/tapscript';
import { readEnvelope } from './inscription.js';
@@ -8,6 +8,8 @@ export class MetadataIndex {
constructor(chain) {
this.events = [];
this.utxos = new Map();
// Coins imported from a real chain start out known, with the same sat numbers the chain gave them.
(chain.imports ?? []).forEach((c, i) => this.utxos.set(`${c.txid}:${c.vout}`, { txid: c.txid, vout: c.vout, value: c.value, ranges: importRanges(i, c.value) }));
const visit = (raw, height, block) => {
const tx = Tx.decode(raw);
const txid = Tx.util.getTxid(tx), coinbase = tx.vin[0].txid === '0'.repeat(64);
+4
View File
@@ -1,6 +1,10 @@
// 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;
// Orgchain: coins imported from a real chain (LocalChain.importCoin) number their sats from 2^52 up, one 5e9 block of
// numbers each, well clear of every local coinbase's.
export const IMPORT_BASE = 2 ** 52;
export const importRanges = (index, value) => [[IMPORT_BASE + index * LOCAL_REWARD, IMPORT_BASE + index * LOCAL_REWARD + value]];
export function sliceRanges(ranges, offset, length) {
const result = [];
for (const [start, end] of ranges) {
+33
View File
@@ -0,0 +1,33 @@
// Does an ord index tell the same lineage story as a lab? The same events, each in the same beat, with the same
// metadata and parent; the same folded state; and every inscription in the same output. Within a block the miner sets
// the order, so independent events (different authorities) may land in another order than the lab cued them; events
// that share an authority are chained by spending it, so theirs is fixed. The comparison therefore keys everything by
// inscription id and counts reorderings rather than failing on them. Used by bin/lineage-index.mjs --compare.
import { MetadataIndex } from '../adapters/metadata-index.js';
import { holderCoin } from '../adapters/handoff.js';
import { lineageEvents, reduce } from '../orgchain/lineage.js';
import { asBeats } from './ord.js';
const byId = list => [...list].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
const folded = state => JSON.stringify({
roots: state.roots.map(r => r.id).sort(), nodes: byId(state.nodes.values()), items: byId(state.items.values()),
log: byId(state.log.map(e => ({ id: e.id, op: e.op, ok: e.ok, why: e.why, node: e.node, root: e.root, roots: e.roots, also: e.also }))),
});
export function compareWithLab(lab, events) {
const mine = asBeats(lineageEvents(new MetadataIndex(lab.chain)).filter(e => e.status === 'confirmed')), theirs = asBeats(events);
const labById = new Map(mine.map(e => [e.id, e])), problems = [];
for (const e of theirs) {
const l = labById.get(e.id);
if (!l) { problems.push(`${e.id}: on ord, not in the lab`); continue; }
if (l.height !== e.height) problems.push(`${e.id}: beat ${e.height} on ord, ${l.height} in the lab`);
if (JSON.stringify(l.metadata) !== JSON.stringify(e.metadata)) problems.push(`${e.id}: its metadata differs`);
if ((l.parent ?? null) !== e.parent || e.parents > 1) problems.push(`${e.id}: its parent differs (lab ${l.parent ?? 'none'}, ord ${e.parent ?? 'none'})`);
const coin = holderCoin(lab.chain, e.id), at = coin && `${coin.txid}:${coin.vout}`;
if (at !== e.location) problems.push(`${e.id}: it sits at ${e.location} on ord and ${at} in the lab`);
}
for (const l of mine) if (!theirs.some(e => e.id === l.id)) problems.push(`${l.id}: in the lab, not on ord`);
if (folded(reduce(mine)) !== folded(reduce(theirs))) problems.push('the folded states differ');
const reordered = theirs.filter((e, i) => mine[i]?.id !== e.id).length, state = reduce(events);
return { problems, reordered, events: events.length, nodes: state.nodes.size, items: state.items.size };
}
+56
View File
@@ -0,0 +1,56 @@
// lineage/v0 over a real ord index: the inscriptions ord has seen become events for the same reducer the lab uses
// (../orgchain/lineage.js). Meaning comes from each inscription's metaprotocol (tag 7) and CBOR metadata (tag 5);
// authority from its parents as ord recorded them, and ord records a parent only when the inscribing transaction spent
// it (docs.ordinals.com, provenance). ord indexes confirmed blocks only, so nothing here is provisional. SPEC.md has
// the rules; this file only reads. Endpoints: /blockheight, /inscriptions/block/<h>/<page>, /inscription/<id>,
// /r/metadata/<id>, all from ord's JSON and recursive APIs.
import { hexToBytes } from '@noble/hashes/utils';
import { decodeMetadata } from '../adapters/metadata.js';
import { PROTOCOL, reduce } from '../orgchain/lineage.js';
// A GET against an ord server; null for 404. fetchImpl lets tests replay recorded answers.
export function ordClient(base, fetchImpl = globalThis.fetch) {
const root = String(base).replace(/\/+$/, '');
return async (path, { json = true } = {}) => {
const res = await fetchImpl(`${root}${path}`, { headers: { Accept: json ? 'application/json' : 'text/plain' } });
if (res.status === 404) return null;
if (!res.ok) throw new Error(`ord ${path}: HTTP ${res.status}`);
return json ? res.json() : res.text();
};
}
// One inscription as a lineage event, or null when it isn't lineage's. Where it sits now rides along, for holders.
export async function ordEvent(get, id) {
const ins = await get(`/inscription/${id}`);
if (!ins || ins.metaprotocol !== PROTOCOL) return null;
const hex = await get(`/r/metadata/${id}`);
return {
id, number: ins.number, status: 'confirmed', height: ins.height, protocol: PROTOCOL,
metadata: hex ? decodeMetadata(hexToBytes(hex)) : null,
parent: ins.parents?.[0] ?? null, parents: ins.parents?.length ?? 0,
location: ins.satpoint ? ins.satpoint.split(':').slice(0, 2).join(':') : null, address: ins.address ?? null,
};
}
// Every lineage/v0 event ord has indexed from one height up, in chain order (inscription numbers follow it).
export async function ordEvents(get, { from = 0, to = null } = {}) {
const top = to ?? Number(await get('/blockheight', { json: false }));
const events = [];
for (let height = from; height <= top; height++) {
for (let page = 0; ; page++) {
const found = await get(`/inscriptions/block/${height}/${page}`);
for (const id of found?.ids ?? []) { const e = await ordEvent(get, id); if (e) events.push(e); }
if (!found?.more) break;
}
}
return events.sort((a, b) => a.number - b.number);
}
// Heights as beat ordinals (1, 2, 3…), so two chains that carried the same history in the same blocks fold alike.
export function asBeats(events) {
const beats = new Map([...new Set(events.map(e => e.height))].sort((a, b) => a - b).map((h, i) => [h, i + 1]));
return events.map(e => ({ ...e, height: beats.get(e.height) }));
}
// The lineage state ord's index implies. Holders are read off the chain like everything else.
export const ordState = events => reduce(events);
+2 -1
View File
@@ -116,7 +116,8 @@ export function lastHolding(lab) {
if (record?.inscription) { const root = state.log.find(e => e.id === record.inscription.id)?.root; if (root) return root; continue; }
// The first input a node signed: a send's sender, or a deal's holder, who accepted it.
for (const input of record?.tx.vin ?? []) {
const prev = lab.chain.transactions.get(input.txid)?.tx.vout[input.vout], root = prev && byScript.get(scriptHex(prev.scriptPubKey));
const prev = lab.chain.transactions.get(input.txid)?.tx.vout[input.vout] ?? lab.chain.imported(input.txid, input.vout);
const root = prev && byScript.get(scriptHex(prev.scriptPubKey));
if (root) return root;
}
}
+1 -1
View File
@@ -217,7 +217,7 @@ export function ledger(chain, owner, tank = new Map(), deals = new Map()) {
continue;
}
const ins = tx.vin.map(i => {
const prev = chain.transactions.get(i.txid)?.tx.vout[i.vout];
const prev = chain.transactions.get(i.txid)?.tx.vout[i.vout] ?? chain.imported?.(i.txid, i.vout);
return prev ? { node: who(prev.scriptPubKey), value: Number(prev.value) } : null;
});
const deal = deals.get(txid);