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>
89 lines
6.7 KiB
JavaScript
89 lines
6.7 KiB
JavaScript
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, []);
|
||
});
|