Files
orgchain/tests/lineage.test.js
T
teahubxyzandClaude Opus 5.5 2ea7884688 orgchain: the tree (lineage lab keys, act as any node, graduation by claim + countersign)
Start the tree: a fresh 24-word lab root and docs/lineage.md's drawing as keys. Orgs get BIP-85 seeds from their
parent (m/83696968'/39'/0'/24'/i'), wallets are BIP-86 m/86'/1'/account'/0/0, npubs are NIP-06; seats are accounts
1..n of their org's seed; indices are never reused. Act as any node to mine, inscribe or sign with its key.

Graduation retires a leaf org on a derived edge and adopts a fresh root in its place. The handoff is OpenOrdex's
buy-order direction: the recipient builds a 2-in/2-out transaction and signs its fee input first, so its signature
fixes where the inscription goes; the holder then countersigns the inscription input. Both are default signatures,
so chain.js is unchanged. The keyring and open claims save with the lab (optional fields, same storage key).

Tests: BIP-85, BIP-86 and NIP-06 spec vectors plus the handoff and its failure cases (36/36 unit).
tests/tree.spec.js rehearses a full graduation in Chromium at desktop and phone sizes, with layout checks;
tests/flow.spec.js now follows this branch's copy and theme, and waits for a mined block before reloading
(28/28 e2e).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:47:56 -07:00

89 lines
6.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test';
import assert from 'node:assert/strict';
import { HDKey } from '@scure/bip32';
import { entropyToMnemonic } from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english';
import { bytesToHex } from '@noble/hashes/utils';
import { schnorr } from '@noble/curves/secp256k1';
import { bip85Entropy, childMnemonic, walletSecret, nostrKey, PATHS, newMnemonic } from '../src/adapters/lineage-keys.js';
import { createKeyring, addChild, graduate, walletOf, keysOf, nodeOf, mnemonicOf, pathOf, encodeKeyring, decodeKeyring } from '../src/adapters/keyring.js';
import { makeClaim, countersign, claimStatus, decodeClaim } from '../src/adapters/handoff.js';
import { TestWallet } from '../src/adapters/wallet.js';
import { LocalChain } from '../src/adapters/chain.js';
import { InscriptionAdapter } from '../src/adapters/inscription.js';
import { encodeLab, decodeLab } from '../src/storage.js';
const XPRV = 'xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb';
const named = (ring, name) => ring.nodes.find(n => n.name === name && !n.retired);
test('BIP-85 spec vectors: core entropy and the 24-word BIP-39 application', () => {
const root = HDKey.fromExtendedKey(XPRV);
assert.equal(bytesToHex(root.derive("m/83696968'/0'/0'").privateKey), 'cca20ccb0e9a90feb0912870c3323b24874b0ca3d8018c4b96d0b97c0e82ded0');
assert.equal(bytesToHex(bip85Entropy(root, "m/83696968'/0'/0'")), 'efecfbccffea313214232d29e71563d941229afb4338c21f9517c41aaa0d16f00b83d2a09ef747e7a64e8e2bd5a14869e693da66ce94ac2da570ab7ee48618f7');
const entropy = bip85Entropy(root, PATHS.orgSeed(0)).slice(0, 32);
assert.equal(bytesToHex(entropy), 'ae131e2312cdc61331542efe0d1077bac5ea803adf24b313a4f0e48e9c51f37f');
assert.equal(entropyToMnemonic(entropy, wordlist), 'puppy ocean match cereal symbol another shed magic wrap hammer bulb intact gadget divorce twin tonight reason outdoor destroy simple truth cigar social volcano');
});
test('BIP-86 and NIP-06 spec vectors', () => {
const abandon = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
assert.equal(bytesToHex(schnorr.getPublicKey(walletSecret(abandon, 0, 0))), 'cc8a4bc64d897bddc5fbc2f670f7a8ba0b386779106cf1223c6fc5d7cd6fc115');
assert.deepEqual(nostrKey('leader monkey parrot ring guide accident before fence cannon height naive bean', 0), {
pubkey: '17162c921dc4d2518f9a101db33695df1afb56ab82f5ff3e5da6eec3ca5cd917', npub: 'npub1zutzeysacnf9rru6zqwmxd54mud0k44tst6l70ja5mhv8jjumytsd2x7nu' });
});
test('the drawing as keys: deterministic, indices from the doc, seats as accounts, never reused', () => {
const mnemonic = newMnemonic(), ring = createKeyring({ mnemonic }), again = createKeyring({ mnemonic });
const root = ring.nodes[0], wk = named(ring, 'well-known'), time = named(ring, 'time'), luna = named(ring, 'luna'), astra = named(ring, 'astra');
assert.deepEqual([wk.index, time.index, named(ring, 'docscope').index, named(ring, 'dollhouse').index, named(ring, 'lineage').index], [0, 1, 0, 1, 1]);
assert.deepEqual([luna.account, astra.account], [1, 3]);
assert.equal(mnemonicOf(ring, time), childMnemonic(mnemonic, 1));
assert.equal(walletOf(ring, time).address, walletOf(again, named(again, 'time')).address);
assert.equal(keysOf(ring, luna).npub, nostrKey(mnemonicOf(ring, time), 1).npub);
assert.notEqual(walletOf(ring, luna).address, walletOf(ring, time).address);
assert.equal(new Set(ring.nodes.map(n => walletOf(ring, n).address)).size, ring.nodes.length);
assert.match(pathOf(ring, named(ring, 'docscope')), /^lab root › 1' › 0' · m\/86'\/1'\/0'\/0\/0$/);
assert.throws(() => addChild(ring, time.id, 'docscope'), /already exists/);
assert.throws(() => addChild(ring, luna.id, 'x'), /Only orgs/);
assert.throws(() => addChild(ring, root.id, 'Bad Name'), /lowercase/);
const own = graduate(ring, named(ring, 'dollhouse').id);
assert.equal(addChild(ring, time.id, 'phly').index, 2, 'the retired index 1 is not reused');
assert.equal(own.edge, 'adopted'); assert.equal(nodeOf(ring, own.from).retired, true);
assert.match(pathOf(ring, own), /^own root \(dollhouse\) · /);
assert.throws(() => graduate(ring, time.id), /leaves first/);
assert.throws(() => graduate(ring, root.id), /derived edge/);
assert.deepEqual(decodeKeyring(JSON.parse(JSON.stringify(encodeKeyring(ring)))), ring);
assert.throws(() => decodeKeyring({ ...ring, root: 'not words' }), /mnemonic/);
assert.throws(() => decodeKeyring({ ...ring, nodes: [...ring.nodes, { ...ring.nodes[1] }] }), /Duplicate/);
const legacy = TestWallet.create(), withLegacy = createKeyring({ imported: legacy.secret, drawing: false });
assert.equal(walletOf(withLegacy, withLegacy.nodes[1]).address, legacy.address);
});
test('graduation handoff: the venture claims, the parent countersigns, the inscription moves', () => {
const ring = createKeyring(), chain = new LocalChain();
const time = walletOf(ring, named(ring, 'time'));
chain.mine(time.script);
const ins = new InscriptionAdapter(chain, time);
const timeId = ins.inscribe('time', null, { lineage: 'root', name: 'time' }, { metaprotocol: 'lineage/v0' });
chain.mine(time.script);
const docId = ins.inscribe('docscope', timeId, { lineage: 'birth', name: 'docscope' }, { metaprotocol: 'lineage/v0' });
chain.mine(time.script);
const own = walletOf(ring, graduate(ring, named(ring, 'docscope').id));
assert.throws(() => makeClaim(chain, docId, own), /plain coin/);
chain.mine(own.script);
assert.throws(() => makeClaim(chain, docId, time), /already holds/);
const claim = makeClaim(chain, docId, own);
assert.equal(claimStatus(chain, claim), 'open');
assert.deepEqual(decodeClaim(JSON.parse(JSON.stringify(claim))), claim);
assert.throws(() => countersign(chain, claim, own), /current holder/);
assert.throws(() => countersign(chain, { ...claim, to: time.pubkey }, time), /signature/i, 'a claim edited after signing fails');
assert.throws(() => countersign(chain, { ...claim, sig: '00'.repeat(64) }, time), /signature/i);
const restored = decodeLab(encodeLab({ wallet: time, chain, keyring: ring, claims: [claim] }));
assert.deepEqual(restored.claims, [claim]); assert.deepEqual(restored.keyring, ring);
assert.equal(countersign(chain, claim, time), claim.txid);
chain.mine(time.script);
assert.ok(new InscriptionAdapter(chain, own).list().some(i => i.id === docId && i.parent === timeId));
assert.ok(!new InscriptionAdapter(chain, time).list().some(i => i.id === docId));
assert.ok(new InscriptionAdapter(chain, time).list().some(i => i.id === timeId), 'the parent keeps its own inscription');
assert.equal(claimStatus(chain, claim), 'stale');
assert.throws(() => countersign(chain, claim, time), /spent/);
assert.deepEqual(decodeLab(encodeLab({ wallet: time, chain })).claims, []);
});