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:
1 parent
7d981341ff
commit
7c9306402d
21 files changed
+602
-21
No files matched your search
+50
-7
@@ -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.');
|
||||
});
|
||||
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user