Folds in the tree branch's key layer (2ea7884): lineage-keys.js and
handoff.js come over as written, with their spec vectors; the off-chain
keyring and the old UI stay behind, because v1's tree comes from the chain.
- Each holding gets its own 24-word lab root. Every node has lineage's
keys at its place in the tree: BIP-85 org seeds, agents as accounts
1..n of their org's seed, BIP-86 wallets and NIP-06 npubs.
- Every act is signed by whichever key holds the authorizing inscription;
a birth hands the child its own; the lab wallet is the faucet.
- Graduation is a claim by the org's fresh root, countersigned by its
derived key, and the derived subtree re-keys under the new root, so the
parent's line can no longer sign for it. Adopted descendants keep theirs.
- Inspect shows each node's key path, npub, address and current holder,
and which key signed each event; the act panel names the signing key.
- Labs saved before keys still load; their holdings sign with the lab
wallet.
- 49 unit tests (6 new), Playwright 6/6 with key checks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4upF5LVwmPDDqrhdUfnXh
114 lines
7.8 KiB
JavaScript
114 lines
7.8 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 { PATHS, bip85Entropy, childMnemonic, nostrKey, walletSecret } from '../src/adapters/lineage-keys.js';
|
||
import { claimStatus, countersign, decodeClaim, holderCoin, makeClaim } from '../src/adapters/handoff.js';
|
||
import { LocalChain } from '../src/adapters/chain.js';
|
||
import { InscriptionAdapter } from '../src/adapters/inscription.js';
|
||
import { TestWallet } from '../src/adapters/wallet.js';
|
||
import { beat, decodeLab, emptyLab, encodeLab, keysPreference } from '../src/orgchain/store.js';
|
||
import { act, fund, lineageState } from '../src/orgchain/actions.js';
|
||
import { keyOf, scriptHex } from '../src/orgchain/keys.js';
|
||
|
||
// Spec vectors and the handoff test come from the tree branch (2ea7884), which wrote lineage-keys.js and handoff.js.
|
||
const XPRV = 'xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb';
|
||
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('handoff: the recipient claims, the holder countersigns, the inscription moves', () => {
|
||
const chain = new LocalChain(), holder = TestWallet.create(), own = TestWallet.create();
|
||
chain.mine(holder.script);
|
||
const ins = new InscriptionAdapter(chain, holder);
|
||
const top = ins.inscribe('time', null, { op: 'found', name: 'time' }, { metaprotocol: 'lineage/v0' });
|
||
const doc = ins.inscribe('docscope', top, { op: 'birth', name: 'docscope' }, { metaprotocol: 'lineage/v0' });
|
||
chain.mine(holder.script);
|
||
assert.throws(() => makeClaim(chain, doc, own), /plain coin/);
|
||
chain.mine(own.script);
|
||
assert.throws(() => makeClaim(chain, doc, holder), /already holds/);
|
||
const claim = makeClaim(chain, doc, 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: holder.pubkey }, holder), /signature/i, 'a claim edited after signing fails');
|
||
assert.equal(countersign(chain, claim, holder), claim.txid);
|
||
chain.mine(holder.script);
|
||
assert.ok(new InscriptionAdapter(chain, own).list().some(i => i.id === doc && i.parent === top));
|
||
assert.ok(new InscriptionAdapter(chain, holder).list().some(i => i.id === top), 'the parent keeps its own inscription');
|
||
assert.equal(claimStatus(chain, claim), 'stale');
|
||
assert.throws(() => countersign(chain, claim, holder), /spent/);
|
||
});
|
||
|
||
const at = (lab, id) => scriptHex(holderCoin(lab.chain, id).scriptPubKey);
|
||
const key = (mnemonic, account) => `5120${new TestWallet(walletSecret(mnemonic, account)).pubkey}`;
|
||
|
||
test('each holding has its own root, and every node holds its inscription at its lineage key', () => {
|
||
const lab = emptyLab();
|
||
const root = act(lab, 'found', null, { name: 'acme' }), ops = act(lab, 'birth', root, { name: 'ops' });
|
||
const bot = act(lab, 'birth', ops, { name: 'bot', kind: 'agent' }), lead = act(lab, 'birth', root, { name: 'lead', kind: 'agent' });
|
||
const m = lab.keys.roots[root], opsSeed = childMnemonic(m, 0);
|
||
assert.equal(m.split(' ').length, 24);
|
||
assert.deepEqual([root, ops, bot, lead].map(id => at(lab, id)), [key(m, 0), key(opsSeed, 0), key(opsSeed, 1), key(m, 1)]);
|
||
const s = lineageState(lab), k = keyOf(lab.keys, s, s.nodes.get(bot));
|
||
assert.equal(k.path, "acme root › 0' · m/86'/1'/1'/0/0");
|
||
assert.match(k.npub, /^npub1[02-9ac-hj-np-z]{58}$/);
|
||
const award = act(lab, 'award', ops, { to: bot, skill: 'launch', belt: 'green' });
|
||
assert.equal(at(lab, award), key(opsSeed, 0), 'an act is signed by its authorizer, and its record stays with that key');
|
||
const other = act(lab, 'found', null, { name: 'other' });
|
||
assert.notEqual(lab.keys.roots[other], m);
|
||
});
|
||
|
||
test('graduation: a fresh root claims the org, the derived key countersigns, and the subtree re-keys', () => {
|
||
const lab = emptyLab();
|
||
const root = act(lab, 'found', null, { name: 'acme' }), studio = act(lab, 'birth', root, { name: 'studio' });
|
||
const kid = act(lab, 'birth', studio, { name: 'kid' }), deep = act(lab, 'birth', kid, { name: 'deep' });
|
||
const seat = act(lab, 'birth', studio, { name: 'seat', kind: 'agent' }), solo = act(lab, 'birth', studio, { name: 'solo' });
|
||
const old = childMnemonic(lab.keys.roots[root], 0);
|
||
act(lab, 'graduate', studio, { of: solo }); // solo graduates first and keeps its own root through what follows
|
||
const soloRoot = lab.keys.adopted[solo];
|
||
act(lab, 'graduate', root, { of: studio });
|
||
const fresh = lab.keys.adopted[studio];
|
||
assert.ok(fresh && fresh !== old);
|
||
assert.deepEqual([studio, kid, deep, seat, solo].map(id => at(lab, id)),
|
||
[key(fresh, 0), key(childMnemonic(fresh, 0), 0), key(childMnemonic(childMnemonic(fresh, 0), 0), 0), key(fresh, 1), key(soloRoot, 0)]);
|
||
const leftovers = lab.chain.coins(new TestWallet(walletSecret(old, 0)).script).flatMap(c => (c.inscriptions ?? []).map(i => i.id));
|
||
assert.deepEqual(leftovers.filter(id => [studio, kid, deep, seat].includes(id)), []);
|
||
const decision = act(lab, 'decide', studio, { title: 'Our own root now' });
|
||
assert.equal(at(lab, decision), key(fresh, 0), 'the org now signs with its own root');
|
||
const s = lineageState(lab);
|
||
assert.equal(keyOf(lab.keys, s, s.nodes.get(seat)).path, "studio root · m/86'/1'/1'/0/0");
|
||
assert.deepEqual(decodeLab(encodeLab(lab)).keys, lab.keys);
|
||
});
|
||
|
||
test('holdings from before lab keys still act, with the one lab wallet', () => {
|
||
const lab = emptyLab();
|
||
fund(lab);
|
||
const legacy = new InscriptionAdapter(lab.chain, lab.wallet).inscribe('found old', null, { op: 'found', name: 'old' }, { metaprotocol: 'lineage/v0' });
|
||
beat(lab);
|
||
const kid = act(lab, 'birth', legacy, { name: 'kid' });
|
||
assert.equal(at(lab, kid), `5120${lab.wallet.pubkey}`);
|
||
act(lab, 'graduate', legacy, { of: kid });
|
||
assert.equal(at(lab, kid), `5120${lab.wallet.pubkey}`, 'without a root there is nothing to re-key; the event is still recorded');
|
||
const s = lineageState(lab);
|
||
assert.equal(keyOf(lab.keys, s, s.nodes.get(kid)), null);
|
||
assert.equal(s.nodes.get(kid).edge, 'adopted');
|
||
const saved = JSON.parse(encodeLab(lab));
|
||
delete saved.keys;
|
||
assert.deepEqual(decodeLab(JSON.stringify(saved)).keys, { roots: {}, adopted: {} });
|
||
assert.throws(() => keysPreference({ roots: { [`${'a'.repeat(64)}i0`]: 'not a mnemonic' } }), /Invalid lab mnemonic/);
|
||
});
|