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
@@ -3,3 +3,4 @@ dist/
|
||||
test-results/
|
||||
playwright-report/
|
||||
.env*
|
||||
tools/regtest/out/
|
||||
@@ -3,13 +3,16 @@
|
||||
Orgchain is lineage's app for creating and operating lineage holdings on Super Testnet's toy chain.
|
||||
|
||||
- Test-only, always: no mainnet funds, no hidden signing, no custody, and nothing presented as a real key.
|
||||
- Lineage semantics live in one place, `src/orgchain/lineage.js`. The UI never interprets events itself, and every act
|
||||
is vetted by that reducer before it is signed. A new op means a reducer change, its tests and the README's table.
|
||||
- Lineage semantics live in one place, `src/orgchain/lineage.js`, and `SPEC.md` says the same in words. The UI never
|
||||
interprets events itself, and every act is vetted by that reducer before it is signed. A new op or rule means a
|
||||
reducer change, its tests, the README's table and `SPEC.md`.
|
||||
- Whatever signs must stay valid on real Bitcoin. After changing how transactions or envelopes are built, rerun the
|
||||
regtest rehearsal and its comparison (`tools/regtest/README.md`) and refresh the fixture it records.
|
||||
- The engine (`src/adapters`, `src/upstream`, `src/storage.js`, `src/miner.js`) is Super Testnet's. Compose with it
|
||||
rather than rewriting it, and keep its tests passing. Preserve upstream licenses and provenance. Its local
|
||||
changes are P2WPKH payments, a coinbase split across up to 64 outputs, and ordinary spends of up to eight inputs
|
||||
and outputs whose key-path signatures may carry 0x83, all in `src/adapters/chain.js` (PROVENANCE.md); keep them
|
||||
small and tested.
|
||||
changes are P2WPKH payments, a coinbase split across up to 64 outputs, ordinary spends of up to eight inputs and
|
||||
outputs whose key-path signatures may carry 0x83, coins imported from a real chain, and BIP-341/342 checks of
|
||||
reveals' script paths, all in `src/adapters/chain.js` (PROVENANCE.md); keep them small and tested.
|
||||
- Money lives in `src/orgchain/money.js`: beats pay the holdings (split by hashrate, or all to the last act; under
|
||||
Mainnet none mine, and the lab wallet funds them), sends spend SegWit coins only, so an inscription can never leave
|
||||
as change, and balances are read off the chain, never stored. Gas (`src/orgchain/actions.js`): every act is paid by
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ Runtime dependencies are pinned in `package.json` and `package-lock.json`:
|
||||
- **@scure/bip32 1.7.0**, Patricio Palladino and Paul Miller, [upstream](https://github.com/paulmillr/scure-bip32), MIT. Used for HD derivation under the BIP-85, BIP-86, BIP-84 and NIP-06 paths. License retained at [`public/licenses/scure-bip32.txt`](public/licenses/scure-bip32.txt).
|
||||
- **@scure/bip39 1.6.0**, Patricio Palladino and Paul Miller, [upstream](https://github.com/paulmillr/scure-bip39), MIT. Used for the 24-word lab roots and their seeds. License retained at [`public/licenses/scure-bip39.txt`](public/licenses/scure-bip39.txt).
|
||||
|
||||
Three local changes to the engine, all original code in `src/adapters/chain.js`, tested in `tests/money.test.js`, with Super Testnet's tests (`tests/adapters.test.js`) passing unchanged. Orgchain v1.5: the chain also accepts native SegWit (P2WPKH) outputs and SIGHASH_ALL P2WPKH spends, verified per BIP-143, so lineage accounts can hold and send test BTC at their BIP-84 addresses; everything about inscriptions stays Taproot-only. Orgchain v1.6: a coinbase may split the block reward across up to 64 outputs (`mineTo`; `mine` still pays one script), so one beat can pay several holdings. Orgchain v1.7: ordinary (non-reveal) spends may take up to eight inputs and outputs (`MAX_IO`), and their key-path Taproot signatures may carry SIGHASH_SINGLE|ANYONECANPAY (0x83, the sell-order flag), so one transaction can carry a deal: an item, the buyer's coins, the price and change. Reveals keep their two-in, two-out shape and default signatures.
|
||||
Five local changes to the engine, all original code in `src/adapters/chain.js`, tested in `tests/money.test.js`, with Super Testnet's tests (`tests/adapters.test.js`) passing unchanged. Orgchain v1.5: the chain also accepts native SegWit (P2WPKH) outputs and SIGHASH_ALL P2WPKH spends, verified per BIP-143, so lineage accounts can hold and send test BTC at their BIP-84 addresses; everything about inscriptions stays Taproot-only. Orgchain v1.6: a coinbase may split the block reward across up to 64 outputs (`mineTo`; `mine` still pays one script), so one beat can pay several holdings. Orgchain v1.7: ordinary (non-reveal) spends may take up to eight inputs and outputs (`MAX_IO`), and their key-path Taproot signatures may carry SIGHASH_SINGLE|ANYONECANPAY (0x83, the sell-order flag), so one transaction can carry a deal: an item, the buyer's coins, the price and change. Reveals keep their two-in, two-out shape and default signatures. Orgchain v1.9: a chain may start from coins imported from a real chain (`importCoin`; `MetadataIndex` numbers their sats the same way, `importRanges` in `src/adapters/sats.js`), so the regtest rehearsal can broadcast the lab's transactions byte for byte; and a reveal's script path is checked per BIP-341 and BIP-342 against the exact script bytes (`tapscriptValid`), with tapscript's own check kept as a fallback so labs saved earlier still load. That rehearsal also found that the inscription adapter (`src/adapters/inscription.js`, adapted from inscriptions-online) committed its reveals to tapscript's `encodeScript` of a hex script, which re-encodes one-byte pushes, so real Bitcoin rejected them; it now commits to the exact bytes (`tapleafOf`). Tested in `tests/indexer.test.js`.
|
||||
|
||||
Vite and Playwright are build/test dependencies, not runtime network services. Their installed package notices remain in the locked dependency installation. No unlicensed upstream code was copied.
|
||||
|
||||
|
||||
@@ -82,11 +82,26 @@ reused. The metadata says what an event means; the parent it spends proves who w
|
||||
item afterwards is the chain's to say, never the metadata's. Metadata is public and permanent: an item's note is for a
|
||||
hash or a link, never a secret or a document.
|
||||
|
||||
## Lineage as a metaprotocol
|
||||
|
||||
`SPEC.md` defines `lineage/v0` as an ordinals metaprotocol: what each field of the envelope means, the order events
|
||||
fold in, why a parent proves authority, and every rule the reducer applies. The lab is one implementation. The other is
|
||||
`bin/lineage-index.mjs`, which reads the events from a real `ord` server and folds them with the same reducer:
|
||||
|
||||
```
|
||||
node bin/lineage-index.mjs --ord http://127.0.0.1:8088 [--from <height>] [--compare <lab backup>]
|
||||
```
|
||||
|
||||
`tools/regtest/` rehearses the sample on Bitcoin Core regtest: the lab's own transactions, byte for byte, confirmed by
|
||||
Bitcoin Core and indexed by `ord`, which then tell the same story as the lab (`tests/indexer.test.js` replays a
|
||||
recorded run). The first rehearsal found that the lab's reveals committed to the wrong tapleaf, which real Bitcoin
|
||||
rejects; reveals are now checked the way Bitcoin checks them (`SPEC.md`, section 1).
|
||||
|
||||
## Build and test
|
||||
|
||||
```
|
||||
npm ci
|
||||
npm test # engine, reducer, lab and money (node --test)
|
||||
npm test # engine, reducer, lab, money and the ord indexer (node --test)
|
||||
npm run build # dist/ is the site; relative paths, so it works at any sub-path
|
||||
npm run test:e2e # Playwright on vite preview; PW_CHANNEL=chrome uses installed Chrome
|
||||
```
|
||||
@@ -99,11 +114,12 @@ Deploy: copy `dist/` to `ventures/lineage/orgchain/` on `teahubxyz/time` `gh-pag
|
||||
## Built on Super Testnet
|
||||
|
||||
The chain, wallet, inscriptions, metadata and index (`src/adapters`, `src/upstream`, with `src/storage.js` and
|
||||
`src/miner.js`) are [Super Testnet](https://supertestnet.lol)'s, with their tests (`tests/adapters.test.js`) and three
|
||||
`src/miner.js`) are [Super Testnet](https://supertestnet.lol)'s, with their tests (`tests/adapters.test.js`) and five
|
||||
additions: the chain also moves native SegWit (P2WPKH) payments, BIP-143 signatures included; a coinbase may split
|
||||
its reward across up to 64 outputs; and ordinary spends take up to eight inputs and outputs, whose key-path
|
||||
its reward across up to 64 outputs; ordinary spends take up to eight inputs and outputs, whose key-path
|
||||
signatures may carry SIGHASH_SINGLE|ANYONECANPAY (0x83, the sell-order flag), while reveals keep their two-in,
|
||||
two-out shape (`tests/money.test.js`).
|
||||
two-out shape (`tests/money.test.js`); a chain may start from coins imported from a real chain; and reveals'
|
||||
script paths are checked per BIP-341 and BIP-342 (`tests/indexer.test.js`).
|
||||
See `PROVENANCE.md` and `public/licenses/`. Lineage's key derivation and the claim and
|
||||
countersign handoff (`src/adapters/lineage-keys.js`, `src/adapters/handoff.js`, with their spec vectors in
|
||||
`tests/keys.test.js`) come from the `tree` branch. The full lab (explorer, delegates, recursion and
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
# lineage/v0
|
||||
|
||||
lineage/v0 is an ordinals metaprotocol for lineage holdings: LLCs, the orgs and agents they birth, the belts and
|
||||
decisions on their record, and the items they make. Every event is an inscription. The chain orders the events and
|
||||
proves who was allowed to make each one; the state (the trees) is stored nowhere, and every indexer that applies these
|
||||
rules to the same inscriptions gets the same trees.
|
||||
|
||||
Status: test networks only (Orgchain's in-browser chain and Bitcoin Core regtest). The reference implementation is
|
||||
`reduce()` in `src/orgchain/lineage.js`; `src/indexer/ord.js` and `bin/lineage-index.mjs` run it over a real ord index.
|
||||
|
||||
## 1. Inscriptions
|
||||
|
||||
lineage/v0 uses these fields of the ord envelope ([inscriptions](https://docs.ordinals.com/inscriptions.html)):
|
||||
|
||||
| tag | field | use |
|
||||
| --- | --- | --- |
|
||||
| 7 | metaprotocol | exactly `lineage/v0`; inscriptions without it are not lineage's |
|
||||
| 5 | metadata | a CBOR map: `op` and its fields (section 4), all strings, in 520-byte pushes (Orgchain keeps it under 2048 bytes) |
|
||||
| 3 | parent | the inscription id of the node that authorizes the event; exactly one, except for `found` |
|
||||
| 1 | content type | `text/plain;charset=utf-8`; the body is a short label such as `birth time`, ignored by the rules |
|
||||
| 2 | pointer | puts a child's inscription in its own output when the parent is spent first |
|
||||
|
||||
The body, delegate (tag 11) and properties (tag 17) carry no meaning here; properties may later carry display traits
|
||||
such as a belt's colour, never authority. Unknown odd tags are ignored. Never use unknown even tags: ord treats such an
|
||||
inscription as unbound, so it has no location.
|
||||
|
||||
A reveal is a script-path spend whose tapleaf commits to the envelope script's exact bytes (BIP-341). Orgchain's reveals
|
||||
before v1.9 committed to a re-encoded script; real Bitcoin rejects those, and Orgchain's local chain still loads them.
|
||||
|
||||
## 2. Order
|
||||
|
||||
Events fold in chain order: block height, then transaction order in the block, then envelope order in the transaction.
|
||||
ord's inscription numbers follow the same order. Unconfirmed events are provisional, and ord indexes none.
|
||||
|
||||
Within a block, the miner sets the order. Events that share an authority are chained, because each one spends that
|
||||
authority's inscription, so their order is fixed. Independent events may land in any order, and outside the conflicts
|
||||
in section 6 that changes nothing.
|
||||
|
||||
## 3. Nodes and authority
|
||||
|
||||
A node is created by an accepted `found` (a holding) or `birth` (an org or an agent), and its id is that event's
|
||||
inscription id. Each node has a kind (holding, org or agent), a status (active or closed), and, below a holding, an
|
||||
edge: derived (the parent holds the child's seed) or adopted (it graduated and holds its own root). A child's index
|
||||
counts its parent's earlier children of the same kind, orgs and agents separately, and is never reused.
|
||||
|
||||
An event's parent is the node that authorizes it, and it counts only if the inscribing transaction spends that node's
|
||||
inscription: ord records a parent only then ([provenance](https://docs.ordinals.com/inscriptions/provenance.html)).
|
||||
Indexers use ord's recorded parents, never the raw tag. So acting for a node means holding its inscription, and moving
|
||||
the inscription moves the authority.
|
||||
|
||||
## 4. Events
|
||||
|
||||
| op | parent | metadata | effect |
|
||||
| --- | --- | --- | --- |
|
||||
| `found` | none | `name` | a holding; no active holding may already have that name |
|
||||
| `birth` | an active holding or org | `name`, `kind` (`org` or `agent`, default `org`) | a child; the parent may not already have an active child of that name |
|
||||
| `award` | an active holding or org | `to`, `skill`, `belt` (`white`, `green` or `black`) | `to` (any other active node, in any holding) holds that belt in that skill, replacing its earlier belt in it |
|
||||
| `decide` | an active node | `title` (required), `why` (optional) | a decision on the node's record |
|
||||
| `make` | an active node | `name`, `note` (optional) | an item, made and first held by the node; a node makes one item per name |
|
||||
| `graduate` | the child's parent, active | `of` (an active org child, not yet adopted) | the edge becomes adopted, and the org becomes a holding of its own (section 5) |
|
||||
| `close` | the node itself, active | none | the node closes, with every active derived descendant; adopted ones stay open |
|
||||
|
||||
Names (holdings, children, skills, items) are 1 to 40 lowercase letters, digits or hyphens, starting and ending with a
|
||||
letter or digit, because they become domains. Text fields are trimmed, and only their first characters count: 140
|
||||
for a title, 400 for a why, 280 for a note. Agents do not birth or award. An event that
|
||||
breaks a rule is rejected: it stays on the chain and changes nothing. Any other `op` is rejected.
|
||||
|
||||
## 5. Holdings and graduation
|
||||
|
||||
A holding is a root: a `found`, or an org that graduated. When an org graduates, it and its descendants still in the
|
||||
old holding re-root under it, and it joins the holdings. Its parent stays its parent, so where it came from stays on the
|
||||
record, and its old holding's tree keeps a row for it. A graduation event belongs to both holdings.
|
||||
|
||||
In Orgchain a graduation is followed by a handoff: the org's fresh root claims its inscription, and the derived key that
|
||||
held it countersigns. After that, only the new root can act for the org.
|
||||
|
||||
## 6. Order-sensitive conflicts
|
||||
|
||||
Where independent events conflict within one block, the block's order decides. These are the cases: a close and an act
|
||||
by one of its derived descendants; an award and its recipient's close; two foundings with the same name.
|
||||
|
||||
## 7. Ownership, items and deals
|
||||
|
||||
Whoever controls the output that holds an inscription's sat holds that node or item; for indexers that is ord's
|
||||
satpoint. Items move like any inscription. A deal is not an event: it is one transaction that carries the item and the
|
||||
buyer's coins in, and the item to the buyer, the price to the holder and the buyer's change out. The buyer signs its
|
||||
inputs first (SIGHASH_ALL) and the holder countersigns the item's. ord sees the item move, and nothing else needs
|
||||
indexing.
|
||||
|
||||
## 8. Keys
|
||||
|
||||
Indexers need no keys. Keys say where a node's inscription is expected to live, and who can sign for it. Each holding
|
||||
has a 24-word root (BIP-39), and an org's seed is BIP-85 from its parent's (`m/83696968'/39'/0'/24'/i'`) until it
|
||||
graduates. An org is account 0 of its seed and its agents are accounts 1 to n. Each account holds its inscription at its
|
||||
BIP-86 Taproot address (`m/86'/coin'/account'/0/0`), keeps its money at BIP-84 SegWit, and has a NIP-06 npub.
|
||||
|
||||
## 9. Versions and other chains
|
||||
|
||||
Tag 7 carries the version. A new version is a new string with its own rules, which v0 indexers ignore. The events and
|
||||
the rules are chain-agnostic. What another chain must supply is its own proof of order and of authority (a signature
|
||||
by the node's key where nothing is spent). Each holding's events would live on one home chain, and an org-signed
|
||||
keyring would bind a node's keys across chains.
|
||||
|
||||
## 10. Conformance
|
||||
|
||||
`tools/regtest` rehearses Orgchain's sample on Bitcoin Core regtest, byte for byte, and `bin/lineage-index.mjs
|
||||
--compare` checks that ord and the lab tell the same story: the same events in the same beats, the same metadata and
|
||||
parents, the same trees, and every inscription in the same output. `tests/indexer.test.js` replays a recorded run.
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env node
|
||||
// lineage-index: read lineage/v0 events from an ord server and print the trees they imply, with the same reducer the
|
||||
// lab uses. --compare <lab.json> checks that ord and a lab backup tell the same story (tools/regtest/README.md).
|
||||
// node bin/lineage-index.mjs [--ord http://127.0.0.1:8088] [--from <height>] [--compare <lab.json>]
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { parseArgs } from 'node:util';
|
||||
import { decodeLab } from '../src/orgchain/store.js';
|
||||
import { census, rows, summary } from '../src/orgchain/lineage.js';
|
||||
import { ordClient, ordEvents, ordState } from '../src/indexer/ord.js';
|
||||
import { compareWithLab } from '../src/indexer/compare.js';
|
||||
|
||||
const { values } = parseArgs({ options: { ord: { type: 'string', default: 'http://127.0.0.1:8088' }, from: { type: 'string', default: '0' },
|
||||
compare: { type: 'string' } } });
|
||||
const events = await ordEvents(ordClient(values.ord), { from: Number(values.from) });
|
||||
const state = ordState(events), plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`;
|
||||
for (const root of state.roots) {
|
||||
const c = census(state, root.id);
|
||||
console.log(`\n${root.name} · ${plural(c.orgs, 'org')}, ${plural(c.agents, 'agent')}, ${plural(c.belts, 'belt')}, ${plural(c.decisions, 'decision')}`);
|
||||
console.log(rows(state, root.id).map(r => (r.type === 'group' ? `${r.guide}${r.label}` : `${r.guide}${r.path} ${r.node.name}${r.away ? ' · graduated' : ''}`)).join('\n'));
|
||||
}
|
||||
const rejected = state.log.filter(e => !e.ok);
|
||||
console.log(`\n${plural(events.length, 'lineage/v0 event')} on ord, ${plural(state.items.size, 'item')}${rejected.length ? `, ${rejected.length} rejected:` : ''}`);
|
||||
for (const e of rejected) console.log(` ${summary(e, state)} (${e.id})`);
|
||||
if (values.compare) {
|
||||
const { problems, ...n } = compareWithLab(decodeLab(readFileSync(values.compare, 'utf8')), events);
|
||||
console.log(problems.length ? `\nord and the lab differ:\n${problems.map(p => ` ${p}`).join('\n')}`
|
||||
: `\nSame story as ${values.compare}: ${plural(n.events, "event")}, ${plural(n.nodes, "node")} and ${plural(n.items, "item")}, each event in the same beat and every inscription in the same output.${n.reordered ? ` The miner ordered ${n.reordered} independent events differently within their blocks, which changes nothing.` : ""}`);
|
||||
process.exitCode = problems.length ? 1 : 0;
|
||||
}
|
||||
+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);
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"from":120,"ord":{"/blockheight":{"status":200,"body":"131"},"/inscriptions/block/120/0":{"status":200,"body":"{\"ids\":[],\"more\":false,\"page_index\":0}"},"/inscriptions/block/121/0":{"status":200,"body":"{\"ids\":[],\"more\":false,\"page_index\":0}"},"/inscriptions/block/122/0":{"status":200,"body":"{\"ids\":[],\"more\":false,\"page_index\":0}"},"/inscriptions/block/123/0":{"status":200,"body":"{\"ids\":[\"ee6cabd275c53011248727e064c0be3841799b7391bc337520d9aac1ebdc60d1i0\"],\"more\":false,\"page_index\":0}"},"/inscription/ee6cabd275c53011248727e064c0be3841799b7391bc337520d9aac1ebdc60d1i0":{"status":200,"body":"{\"address\":\"bcrt1pmhndmcwyd3qt3lzq7x2fyxa62r8fpj9rn4gr05un6fxt35ft2uzs3tdaf7\",\"charms\":[],\"child_count\":3,\"children\":[\"ac1d4d0cfaeb19d99bc4c6e829d64100b598f2a3cfa538348f230ae3fe09e685i0\",\"4375d999401ea1996d031bddfc2192170e2f8519ada1ff2a5bc77a5eb3b8a514i0\",\"c7d79e81fb104160f3843649d958ab9f56d49df4390fb02b93828021d8dd34dci0\"],\"content_length\":17,\"content_type\":\"text/plain;charset=utf-8\",\"effective_content_type\":\"text/plain;charset=utf-8\",\"fee\":500,\"height\":123,\"id\":\"ee6cabd275c53011248727e064c0be3841799b7391bc337520d9aac1ebdc60d1i0\",\"metaprotocol\":\"lineage/v0\",\"next\":\"ac1d4d0cfaeb19d99bc4c6e829d64100b598f2a3cfa538348f230ae3fe09e685i0\",\"number\":0,\"parents\":[],\"previous\":null,\"properties\":{\"gallery\":[],\"attributes\":{\"title\":null,\"traits\":{}}},\"rune\":null,\"sat\":null,\"satpoint\":\"c7d79e81fb104160f3843649d958ab9f56d49df4390fb02b93828021d8dd34dc:0:0\",\"timestamp\":1792054032,\"value\":1000}"},"/r/metadata/ee6cabd275c53011248727e064c0be3841799b7391bc337520d9aac1ebdc60d1i0":{"status":200,"body":"\"a2626f7065666f756e64646e616d656b796f6c6f7072656e657572\""},"/inscriptions/block/124/0":{"status":200,"body":"{\"ids\":[\"ac1d4d0cfaeb19d99bc4c6e829d64100b598f2a3cfa538348f230ae3fe09e685i0\",\"4375d999401ea1996d031bddfc2192170e2f8519ada1ff2a5bc77a5eb3b8a514i0\"],\"more\":false,\"page_index\":0}"},"/inscription/ac1d4d0cfaeb19d99bc4c6e829d64100b598f2a3cfa538348f230ae3fe09e685i0":{"status":200,"body":"{\"address\":\"bcrt1p436wl2jp50ceq825lm0em2mtvyafeqvf09ggsa9ku6z9rusqat7q40jzp5\",\"charms\":[\"vindicated\"],\"child_count\":4,\"children\":[\"e29b26c17d48c09e72109ae42a833fb24e000cf89790ebbaebea6d3a884fc1bdi0\",\"3d9b514edc1956d5f4fef3edec7267c4920ce81449b8c239e317a53fe0a81885i0\",\"1c8f2b1f187d04c6fd68cb8d2075739253a0ec8642cf6a21e6dfa89a04c61f45i0\",\"e31369d6146a043c538280f0db13d8301a49269835a78ef5dd1f74fbd3769ffei0\"],\"content_length\":16,\"content_type\":\"text/plain;charset=utf-8\",\"effective_content_type\":\"text/plain;charset=utf-8\",\"fee\":500,\"height\":124,\"id\":\"ac1d4d0cfaeb19d99bc4c6e829d64100b598f2a3cfa538348f230ae3fe09e685i0\",\"metaprotocol\":\"lineage/v0\",\"next\":\"4375d999401ea1996d031bddfc2192170e2f8519ada1ff2a5bc77a5eb3b8a514i0\",\"number\":1,\"parents\":[\"ee6cabd275c53011248727e064c0be3841799b7391bc337520d9aac1ebdc60d1i0\"],\"previous\":\"ee6cabd275c53011248727e064c0be3841799b7391bc337520d9aac1ebdc60d1i0\",\"properties\":{\"gallery\":[],\"attributes\":{\"title\":null,\"traits\":{}}},\"rune\":null,\"sat\":null,\"satpoint\":\"e31369d6146a043c538280f0db13d8301a49269835a78ef5dd1f74fbd3769ffe:0:0\",\"timestamp\":1792054033,\"value\":1000}"},"/r/metadata/ac1d4d0cfaeb19d99bc4c6e829d64100b598f2a3cfa538348f230ae3fe09e685i0":{"status":200,"body":"\"a3626f70656269727468646e616d656a77656c6c2d6b6e6f776e646b696e64636f7267\""},"/inscription/4375d999401ea1996d031bddfc2192170e2f8519ada1ff2a5bc77a5eb3b8a514i0":{"status":200,"body":"{\"address\":\"bcrt1ps07srr40g8v2t4e9larkeecct56xj67ue5ead0tuq50lj8888cms3d2xx6\",\"charms\":[\"vindicated\"],\"child_count\":9,\"children\":[\"82d9ba7ae7dd4252fd5bae99d04c70feafcc4f93b0e716e8f8738f914bbea1f6i0\",\"97cd35c5cb52333b987dcea052cd82a7bc9c64a5d2a5fb8c831b42fa63565c8fi0\",\"9414b00451ba60700c236e36c4ead1b86e73d9a76b1e96a46e2f851cc07c6e5fi0\",\"1b48e841f2dbee9cd49a59872ba3ad880d2498c29fed351f54870379982bd10ai0\"],\"content_length\":10,\"content_type\":\"text/plain;charset=utf-8\",\"effective_content_type\":\"text/plain;charset=utf-8\",\"fee\":500,\"height\":124,\"id\":\"4375d999401ea1996d031bddfc2192170e2f8519ada1ff2a5bc77a5eb3b8a514i0\",\"metaprotocol\":\"lineage/v0\",\"next\":\"82d9ba7ae7dd4252fd5bae99d04c70feafcc4f93b0e716e8f8738f914bbea1f6i0\",\"number\":2,\"parents\":[\"ee6cabd275c53011248727e064c0be3841799b7391bc337520d9aac1ebdc60d1i0\"],\"previous\":\"ac1d4d0cfaeb19d99bc4c6e829d64100b598f2a3cfa538348f230ae3fe09e685i0\",\"properties\":{\"gallery\":[],\"attributes\":{\"title\":null,\"traits\":{}}},\"rune\":null,\"sat\":null,\"satpoint\":\"5eaebae86b7957b427121d471de2ff14099467eaa16fded137f7b641ca55788f:0:0\",\"timestamp\":1792054033,\"value\":1000}"},"/r/metadata/4375d999401ea1996d031bddfc2192170e2f8519ada1ff2a5bc77a5eb3b8a514i0":{"status":200,"body":"\"a3626f70656269727468646e616d656474696d65646b696e64636f7267\""},"/inscriptions/block/125/0":{"status":200,"body":"{\"ids\":[\"82d9ba7ae7dd4252fd5bae99Line truncated
|
||||
@@ -0,0 +1,78 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { Script, Tap, Tx } from '@cmdcode/tapscript';
|
||||
import { LocalChain, tapscriptValid } from '../src/adapters/chain.js';
|
||||
import { TestWallet } from '../src/adapters/wallet.js';
|
||||
import { InscriptionAdapter, POSTAGE, FEE, envelope } from '../src/adapters/inscription.js';
|
||||
import { MetadataIndex } from '../src/adapters/metadata-index.js';
|
||||
import { getVin, getVout } from '../src/upstream/primitives.js';
|
||||
import { census, rows } from '../src/orgchain/lineage.js';
|
||||
import { ordClient, ordEvents, ordState } from '../src/indexer/ord.js';
|
||||
import { compareWithLab } from '../src/indexer/compare.js';
|
||||
|
||||
// What ord answered after tools/regtest/rehearse.mjs, and the lab's public chain (tools/regtest/record.mjs).
|
||||
const fixture = JSON.parse(readFileSync(new URL('./fixtures/regtest-rehearsal.json', import.meta.url), 'utf8'));
|
||||
const replay = async url => { const hit = fixture.ord[new URL(url).pathname]; return new Response(hit?.body ?? '', { status: hit?.status ?? 404 }); };
|
||||
const draw = (s, root) => rows(s, root).map(r => (r.type === 'group' ? `${r.guide}${r.label}` : `${r.guide}${r.path} ${r.node.name}${r.away ? ' · graduated' : ''}`));
|
||||
const NUMS = ['OP_1', '50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0'];
|
||||
const hex = script => Script.encode(script, false).hex;
|
||||
|
||||
test('real ord indexed the regtest rehearsal, and the same reducer folds it into the trees the lab drew', async () => {
|
||||
const events = await ordEvents(ordClient('http://ord.test', replay), { from: fixture.from });
|
||||
assert.equal(events.length, 21);
|
||||
assert.ok(events.every(e => e.protocol === 'lineage/v0' && e.parents <= 1 && /^[0-9a-f]{64}:\d+$/.test(e.location)));
|
||||
const s = ordState(events), id = name => [...s.nodes.values()].find(n => n.name === name).id;
|
||||
assert.ok(s.log.every(e => e.ok));
|
||||
assert.deepEqual(s.roots.map(r => r.name), ['yolopreneur', 'lineage', 'acme']);
|
||||
assert.deepEqual(draw(s, id('yolopreneur')), ['● yolopreneur', '└─ orgs', " ├─ 0' well-known", ' │ └─ orgs', " │ ├─ 0' mailr",
|
||||
" │ └─ 1' lineage · graduated", " └─ 1' time", ' ├─ agents', " │ ├─ 0' luna", " │ ├─ 1' sol", " │ └─ 2' astra",
|
||||
' └─ orgs', " ├─ 0' docscope", " ├─ 1' dollhouse", " ├─ 2' doodleloop", " └─ 3' hearthframe"]);
|
||||
assert.deepEqual(census(s, id('lineage')), { orgs: 0, agents: 0, belts: 0, decisions: 1, closed: 0 });
|
||||
assert.deepEqual(s.nodes.get(id('scout')).badges.map(b => [b.skill, b.belt, s.nodes.get(b.by).name]), [['listen', 'white', 'well-known']]);
|
||||
assert.deepEqual([...s.items.values()].map(i => [i.name, s.nodes.get(i.maker).name]), [['launch-kit', 'sol']]);
|
||||
// Byte for byte: the same events in the same beats, the same metadata and parents, every inscription in the same
|
||||
// output (the launch kit where lineage's deal put it). Only the miner's order within blocks differs.
|
||||
const { problems, reordered } = compareWithLab({ chain: new LocalChain(fixture.chain) }, events);
|
||||
assert.deepEqual(problems, []);
|
||||
assert.equal(reordered, 9);
|
||||
});
|
||||
|
||||
test('a reveal commits to its exact script (BIP-341), and the lab still loads reveals made the old way', () => {
|
||||
const chain = new LocalChain(), w = TestWallet.create();
|
||||
chain.mine(w.script);
|
||||
new InscriptionAdapter(chain, w).inscribe('found x', null, { op: 'found', name: 'x' }, { metaprotocol: 'lineage/v0' });
|
||||
const prevout = raw => { const out = Tx.decode(raw).vout[0]; return { value: Number(out.value), scriptPubKey: out.scriptPubKey }; };
|
||||
const reveal = Tx.decode(chain.mempool[1]);
|
||||
reveal.vin[0].prevout = prevout(chain.mempool[0]);
|
||||
assert.equal(tapscriptValid(reveal, 0), true);
|
||||
// Before v1.9 the leaf came from tapscript's encodeScript, which re-encodes the hex script; real Bitcoin rejected that.
|
||||
const old = new LocalChain();
|
||||
old.mine(w.script);
|
||||
const [coin] = old.coins(w.script), script = envelope(w.pubkey, 'found y', null, { op: 'found', name: 'y' }, { metaprotocol: 'lineage/v0' });
|
||||
const leaf = Tap.encodeScript(script), [tapkey, control] = Tap.getPubKey(w.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, w.script)] });
|
||||
commit.vin[0].witness = [w.sign(commit)];
|
||||
const legacy = Tx.create({ vin: [getVin(Tx.util.getTxid(commit), 0, POSTAGE + FEE, ['OP_1', tapkey])], vout: [getVout(POSTAGE, w.script)] });
|
||||
legacy.vin[0].witness = [w.sign(legacy, leaf, 0), Script.encode(script, false).hex, control];
|
||||
const raws = [Tx.encode(commit).hex, Tx.encode(legacy).hex], check = Tx.decode(raws[1]);
|
||||
check.vin[0].prevout = prevout(raws[0]);
|
||||
assert.equal(tapscriptValid(check, 0), false);
|
||||
old.submitPackage(raws); // still accepted, so labs saved before v1.9 load
|
||||
assert.equal(new MetadataIndex(old).events.length, 1);
|
||||
});
|
||||
|
||||
test('a chain can start from coins imported from a real chain, and keeps them through a rebuild', () => {
|
||||
const chain = new LocalChain(), w = TestWallet.create(), coin = { txid: 'ab'.repeat(32), vout: 1, value: 2e8, script: hex(w.script) };
|
||||
chain.importCoin(coin);
|
||||
assert.throws(() => chain.importCoin(coin), /already in this chain/);
|
||||
assert.throws(() => chain.importCoin({ ...coin, vout: 2, value: 51e8 }), /Invalid imported coin/);
|
||||
const id = new InscriptionAdapter(chain, w).inscribe('found x', null, { op: 'found', name: 'x' }, { metaprotocol: 'lineage/v0' });
|
||||
chain.mine(NUMS);
|
||||
const copy = new LocalChain(chain.snapshot());
|
||||
assert.deepEqual(copy.imports, [coin]);
|
||||
assert.deepEqual(copy.imported(coin.txid, 1), { value: 2e8, scriptPubKey: coin.script });
|
||||
assert.deepEqual(new MetadataIndex(copy).events.map(e => [e.id, e.status]), [[id, 'confirmed']]);
|
||||
assert.equal(copy.coins(w.script).length, 2); // the reveal's postage and the commit's change
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
# Regtest rehearsal
|
||||
|
||||
Lineage is an ordinals metaprotocol (`SPEC.md`). This folder checks that on real Bitcoin Core and real `ord`: the lab's
|
||||
own action code makes the yolopreneur sample, Bitcoin Core (regtest, standardness on) accepts its transactions byte
|
||||
for byte, `ord` indexes the inscriptions, and the same reducer folds ord's view back into the same trees.
|
||||
|
||||
Regtest is local and private: nothing is downloaded, blocks are mined on demand, and the data folders stay a few MB.
|
||||
Test coins only.
|
||||
|
||||
## Start
|
||||
|
||||
```
|
||||
brew install bitcoin ord
|
||||
mkdir -p ~/lineage-regtest/bitcoin ~/lineage-regtest/ord
|
||||
cp tools/regtest/bitcoin.conf ~/lineage-regtest/bitcoin/
|
||||
bitcoind -datadir=$HOME/lineage-regtest/bitcoin -daemon
|
||||
bitcoin-cli -datadir=$HOME/lineage-regtest/bitcoin createwallet funder
|
||||
ord --regtest --bitcoin-data-dir $HOME/lineage-regtest/bitcoin --data-dir $HOME/lineage-regtest/ord \
|
||||
server --address 127.0.0.1 --http-port 8088 --polling-interval 1s &
|
||||
```
|
||||
|
||||
`bitcoin.conf` keeps mainnet's standardness rules (`acceptnonstdtxn=0`) and lowers the minimum relay fee to 0.1 sat/vB,
|
||||
since the lab pays a fixed 500 sats per transaction.
|
||||
|
||||
## Rehearse, index, compare
|
||||
|
||||
```
|
||||
node tools/regtest/rehearse.mjs # nine beats; saves the lab to tools/regtest/out/lab.json
|
||||
node bin/lineage-index.mjs --from 100 --compare tools/regtest/out/lab.json
|
||||
node tools/regtest/record.mjs # refresh tests/fixtures/regtest-rehearsal.json (public data only)
|
||||
```
|
||||
|
||||
The rehearsal funds the lab wallet with real regtest coins (`LocalChain.importCoin`) and pays every local beat to an
|
||||
unspendable key, so nothing it signs spends a coin regtest doesn't have. After each beat it broadcasts that beat's
|
||||
transactions and mines one regtest block. `--compare` checks that every event landed in the same beat with the same
|
||||
metadata and parent, that every inscription sits in the same output, and that both fold into the same trees. Within a
|
||||
block the miner may order independent events differently from the lab; that changes nothing (`SPEC.md`, section 2).
|
||||
|
||||
`out/` holds the lab backup, test keys included, and is not committed.
|
||||
|
||||
## Stop
|
||||
|
||||
```
|
||||
bitcoin-cli -datadir=$HOME/lineage-regtest/bitcoin stop
|
||||
pkill -f "ord --regtest"
|
||||
```
|
||||
@@ -0,0 +1,13 @@
|
||||
regtest=1
|
||||
server=1
|
||||
txindex=1
|
||||
fallbackfee=0.0001
|
||||
[regtest]
|
||||
rpcbind=127.0.0.1
|
||||
rpcallowip=127.0.0.1
|
||||
rpcport=18443
|
||||
port=18444
|
||||
listen=0
|
||||
acceptnonstdtxn=0
|
||||
minrelaytxfee=0.000001
|
||||
blockmintxfee=0.000001
|
||||
@@ -0,0 +1,16 @@
|
||||
// Record what ord answered about a rehearsal, with the lab's public chain (no keys), as a fixture the unit tests replay
|
||||
// without bitcoind or ord: tests/fixtures/regtest-rehearsal.json. Usage: node tools/regtest/record.mjs [lab.json] [from]
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { decodeLab } from '../../src/orgchain/store.js';
|
||||
import { ordClient, ordEvents } from '../../src/indexer/ord.js';
|
||||
|
||||
const base = 'http://127.0.0.1:8088', from = Number(process.argv[3] ?? 120), ord = {};
|
||||
const lab = decodeLab(readFileSync(process.argv[2] ?? 'tools/regtest/out/lab.json', 'utf8'));
|
||||
const record = async (url, options) => {
|
||||
const res = await fetch(url, options), body = await res.text();
|
||||
ord[new URL(url).pathname] = { status: res.status, body };
|
||||
return new Response(body, { status: res.status });
|
||||
};
|
||||
const events = await ordEvents(ordClient(base, record), { from });
|
||||
writeFileSync('tests/fixtures/regtest-rehearsal.json', `${JSON.stringify({ from, ord, chain: lab.chain.snapshot() })}\n`);
|
||||
console.log(`recorded ${Object.keys(ord).length} ord answers for ${events.length} events, and ${lab.chain.blocks.length} local blocks`);
|
||||
@@ -0,0 +1,107 @@
|
||||
// Rehearse the yolopreneur sample on real Bitcoin Core (regtest). The lab's own action code signs every transaction on
|
||||
// a LocalChain whose only money is coins imported from regtest, so nothing it makes spends a local coinbase. After each
|
||||
// beat, that beat's transactions go to bitcoind byte for byte and are mined into a block; ord then indexes them like
|
||||
// any other inscriptions, and bin/lineage-index.mjs folds them back into trees. Test coins on a private chain only.
|
||||
// Usage: node tools/regtest/rehearse.mjs [out.json] (tools/regtest/README.md starts bitcoind and ord)
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import { Script, Tx } from '@cmdcode/tapscript';
|
||||
import { TestWallet } from '../../src/adapters/wallet.js';
|
||||
import { MetadataIndex } from '../../src/adapters/metadata-index.js';
|
||||
import { emptyLab, encodeLab, setRule, setTempo } from '../../src/orgchain/store.js';
|
||||
import { accept, act, deposit, offer, send } from '../../src/orgchain/actions.js';
|
||||
import { SAT } from '../../src/orgchain/money.js';
|
||||
import { lineageEvents, reduce, rows } from '../../src/orgchain/lineage.js';
|
||||
import { bitcoind } from './rpc.mjs';
|
||||
|
||||
const out = process.argv[2] ?? 'tools/regtest/out/lab.json';
|
||||
const NUMS = ['OP_1', '50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0']; // BIP-341's key that no one holds
|
||||
const hex = script => Script.encode(script, false).hex;
|
||||
const node = bitcoind(), funder = bitcoind({ wallet: 'funder' });
|
||||
|
||||
const lab = emptyLab();
|
||||
lab.wallet = TestWallet.create();
|
||||
setRule(lab, 'none'); // no holding mines: the only money is what regtest sends in
|
||||
setTempo(lab, 'manual'); // the rehearsal decides when a beat lands
|
||||
// Local beats pay the unspendable key, so no local coinbase is ever spent; anything else beating is a bug here.
|
||||
const mineTo = lab.chain.mineTo.bind(lab.chain);
|
||||
lab.chain.mineTo = vout => {
|
||||
if (vout.some(o => hex(o.scriptPubKey) !== hex(NUMS))) throw new Error('Out of lab money: only the rehearsal beats, and only to the unspendable key.');
|
||||
return mineTo(vout);
|
||||
};
|
||||
|
||||
const miner = await funder('getnewaddress', 'miner', 'bech32m');
|
||||
const mine = n => node('generatetoaddress', n, miner);
|
||||
const height = await node('getblockcount');
|
||||
if (height < 120) await mine(120 - height); // mature coins for the funder, and past ord's regtest jubilee
|
||||
const funding = [];
|
||||
for (const btc of [20, 20, 20, 10]) funding.push(await funder('sendtoaddress', lab.wallet.address, btc));
|
||||
await mine(1);
|
||||
for (const txid of funding) {
|
||||
const o = (await node('getrawtransaction', txid, true)).vout.find(v => v.scriptPubKey.hex === hex(lab.wallet.script));
|
||||
lab.chain.importCoin({ txid, vout: o.n, value: Math.round(o.value * SAT), script: o.scriptPubKey.hex });
|
||||
}
|
||||
console.log(`lab wallet ${lab.wallet.address} holds ${funding.length} coins from regtest`);
|
||||
|
||||
let beats = 0, sent = 0;
|
||||
async function beat(label) {
|
||||
lab.chain.mine(NUMS);
|
||||
const raws = lab.chain.blocks.at(-1).raws.slice(1);
|
||||
for (const raw of raws) {
|
||||
try { await node('sendrawtransaction', raw, 0); }
|
||||
catch (error) {
|
||||
if (!/cluster|too-long-mempool-chain/i.test(error.message)) throw new Error(`beat ${beats + 1} (${label}), ${Tx.util.getTxid(raw)}: ${error.message}`);
|
||||
await mine(1); // a long chain of cued spends: let regtest confirm what it has, then carry on
|
||||
await node('sendrawtransaction', raw, 0);
|
||||
}
|
||||
}
|
||||
const [hash] = await mine(1), block = await node('getblock', hash);
|
||||
for (const raw of raws) {
|
||||
if (!(await node('getrawtransaction', Tx.util.getTxid(raw), true)).confirmations) throw new Error(`${Tx.util.getTxid(raw)} did not confirm.`);
|
||||
}
|
||||
beats++; sent += raws.length;
|
||||
console.log(`beat ${beats}: ${label} · ${raws.length} transactions → regtest block ${block.height}`);
|
||||
}
|
||||
|
||||
const yolo = act(lab, 'found', null, { name: 'yolopreneur' });
|
||||
deposit(lab, yolo, 30 * SAT);
|
||||
deposit(lab, yolo, 30 * SAT);
|
||||
await beat('found yolopreneur and fund it');
|
||||
const wk = act(lab, 'birth', yolo, { name: 'well-known', kind: 'org' }), time = act(lab, 'birth', yolo, { name: 'time', kind: 'org' });
|
||||
await beat('well-known and time');
|
||||
send(lab, yolo, time, 40 * SAT);
|
||||
send(lab, yolo, wk, 10 * SAT);
|
||||
act(lab, 'birth', wk, { name: 'mailr', kind: 'org' });
|
||||
const lineage = act(lab, 'birth', wk, { name: 'lineage', kind: 'org' });
|
||||
const luna = act(lab, 'birth', time, { name: 'luna', kind: 'agent' }), sol = act(lab, 'birth', time, { name: 'sol', kind: 'agent' });
|
||||
act(lab, 'birth', time, { name: 'astra', kind: 'agent' });
|
||||
await beat('two sends and five births');
|
||||
send(lab, wk, lineage, 2 * SAT);
|
||||
for (const name of ['docscope', 'dollhouse', 'doodleloop', 'hearthframe']) act(lab, 'birth', time, { name, kind: 'org' });
|
||||
await beat("time's ventures");
|
||||
send(lab, time, sol, SAT);
|
||||
send(lab, time, luna, 0.5 * SAT);
|
||||
act(lab, 'award', time, { to: sol, skill: 'launch', belt: 'green' });
|
||||
act(lab, 'award', time, { to: luna, skill: 'post', belt: 'white' });
|
||||
act(lab, 'decide', yolo, { title: 'Every venture gets a page', why: '<venture>.yolopreneur.lol, served from one pages branch.' });
|
||||
const kit = act(lab, 'make', sol, { name: 'launch-kit', note: 'The page, the post and the checklist every venture launches with.' });
|
||||
await beat('belts, a decision and an item');
|
||||
act(lab, 'graduate', wk, { of: lineage });
|
||||
act(lab, 'decide', lineage, { title: 'Lineage gets its own org', why: 'Its own repo on Teahub now, lineage.llc later: adopted, not derived.' });
|
||||
accept(lab, offer(lab, kit, lineage, 0.1 * SAT).txid);
|
||||
await beat('lineage graduates and buys the launch kit');
|
||||
const acme = act(lab, 'found', null, { name: 'acme' });
|
||||
deposit(lab, acme, 5 * SAT);
|
||||
await beat('another holding');
|
||||
const scout = act(lab, 'birth', acme, { name: 'scout', kind: 'agent' });
|
||||
send(lab, acme, scout, SAT);
|
||||
await beat('an agent of acme');
|
||||
send(lab, scout, wk, 0.1 * SAT);
|
||||
act(lab, 'award', wk, { to: scout, skill: 'listen', belt: 'white' });
|
||||
await beat('scout pays well-known, which awards it a belt');
|
||||
|
||||
const live = reduce(lineageEvents(new MetadataIndex(lab.chain)), { pending: true });
|
||||
for (const root of live.roots) console.log(rows(live, root.id).map(r => (r.type === 'group' ? `${r.guide}${r.label}` : `${r.guide}${r.path} ${r.node.name}`)).join('\n'));
|
||||
mkdirSync(dirname(out), { recursive: true });
|
||||
writeFileSync(out, encodeLab(lab));
|
||||
console.log(`${beats} beats, ${sent} transactions confirmed on regtest (height ${await node('getblockcount')}); the lab is saved to ${out}`);
|
||||
@@ -0,0 +1,20 @@
|
||||
// A small JSON-RPC client for the local regtest bitcoind (tools/regtest/README.md), authenticated with its cookie.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export const DATADIR = process.env.LINEAGE_REGTEST ? join(process.env.LINEAGE_REGTEST, 'bitcoin') : join(homedir(), 'lineage-regtest', 'bitcoin');
|
||||
|
||||
export function bitcoind({ url = 'http://127.0.0.1:18443', datadir = DATADIR, wallet = null } = {}) {
|
||||
let id = 0;
|
||||
return async (method, ...params) => {
|
||||
const cookie = readFileSync(join(datadir, 'regtest', '.cookie'), 'utf8').trim();
|
||||
const res = await fetch(wallet ? `${url}/wallet/${wallet}` : url, {
|
||||
method: 'POST', headers: { Authorization: `Basic ${Buffer.from(cookie).toString('base64')}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '1.0', id: ++id, method, params }),
|
||||
});
|
||||
const body = await res.json().catch(() => null);
|
||||
if (!body || body.error) throw new Error(`${method}: ${body?.error?.message ?? `HTTP ${res.status}`}`);
|
||||
return body.result;
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user