Orgchain v1.2: tapkeys and lineage's leaves

A node's chain key is now its tapkey: its NIP-06 npub with the BIP-86
tweak, so one secret signs its nostr events and its chain spends, and
its address follows from its npub. Its leaves hang off the same tree,
byte for byte as on lineage's key tree page: an agent's ssh, git, dkim
and wg keys from its nostr secret, an org's ssh-ca, mtls and noise keys
from its seed. Inspect shows the tapkey, npub, derivation and leaves,
with the public lines to paste. Labs keyed by v1.1 keep their separate
m/86' chain keys until they are reset.

PROVENANCE.md and public/licenses now carry @scure/base, bip32 and
bip39, which came in with v1.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4upF5LVwmPDDqrhdUfnXh
This commit is contained in:
teahubxyzandClaude Opus 5.5 committed 2026-10-14 19:11:08 -07:00
1 parent 744c5946aa
commit 00a35613c5
15 files changed
+283 -39

No files matched your search

+69 -10
View File
@@ -5,14 +5,17 @@ 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 { base64, bech32 } from '@scure/base';
import { Address } from '@cmdcode/tapscript';
import { PATHS, bip85Entropy, childMnemonic, nostrKey, nostrSecret, seedBytes, 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';
import { keyOf, leavesOf, rootWallet, scriptHex, tapkeyOf } from '../src/orgchain/keys.js';
import { agentLeaves, agentRecords, nodeLeaves, nodeRecords, sshFingerprint } from '../src/orgchain/leaves.js';
// Spec vectors and the handoff test come from the tree branch (2ea7884), which wrote lineage-keys.js and handoff.js.
const XPRV = 'xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb';
@@ -53,10 +56,35 @@ test('handoff: the recipient claims, the holder countersigns, the inscription mo
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', () => {
// Values read off the lineage key tree page (lineage.yolopreneur.lol) for this mnemonic: agent account 1, lineage.llc.
const LEADER = 'leader monkey parrot ring guide accident before fence cannon height naive bean';
test('tapkey: the BIP-86 tweak turns the spec internal key into the spec output key', () => {
const tapkey = tapkeyOf('cc8a4bc64d897bddc5fbc2f670f7a8ba0b386779106cf1223c6fc5d7cd6fc115');
assert.equal(tapkey, 'a60869f0dbcf1dc659c9cecbaf8050135ea9e8cdc487053f1dc6880949dc684c');
assert.equal(Address.p2tr.fromPubKey(tapkey, 'main'), 'bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr');
});
test('leaves match the lineage key tree page byte for byte', () => {
const agent = agentLeaves(nostrSecret(LEADER, 1));
assert.equal(nostrKey(LEADER, 1).npub, 'npub1m9m6dnc0svwugus8sz6l29rqatmdegywxtgld6ymvq6y6ca0fczq88tsjr');
assert.deepEqual([sshFingerprint(agent.ssh), sshFingerprint(agent.git), base64.encode(agent.dkim), base64.encode(agent.wg)], [
'SHA256:Svt6sRSfHEYSQf9+QX+XDPg9eXlEsgcy+OAcJIzeyQU', 'SHA256:fBH0g7huqXVWQHa6GiDsMMzg2PTnWwyTgJdrH3L5bo4',
'A7j292wHd4jUXhs4dMJcKh0nDG1/cwoo4aG1NUn/x2g=', '0HVH6534UBMEVWDz8BA+NkI73Z28bHBBQNFEONCkmWI=']);
assert.deepEqual(nodeRecords(nodeLeaves(seedBytes(LEADER)), 'lineage.llc').map(([, value]) => value), [
'SHA256:XOvpZ24mpcsFhi0mDxyZDqSEpkVKsQkhnLCOR5mlxY4', 'ObnNHsWk0TI5evUH+wbuMXtyPtEXInA6Y0KJbFu8S7A=',
'mkey:e35077cb9d3e78ad67898407caa1a059453fcec698f00f3585c6d87e7950e676']);
const lines = Object.fromEntries(agentRecords(agent, 'sol@lineage.llc', 'sol', 'lineage.llc').map(([label, , line]) => [label, line]));
assert.match(lines.ssh, /^ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI[A-Za-z0-9+/]{43} sol@lineage\.llc$/);
assert.match(lines.git, /^sol@lineage\.llc namespaces="git" ssh-ed25519 \S+ sol@lineage\.llc$/);
assert.equal(lines.dkim, 'sol._domainkey.lineage.llc. IN TXT "v=DKIM1; k=ed25519; p=A7j292wHd4jUXhs4dMJcKh0nDG1/cwoo4aG1NUn/x2g="');
assert.equal(lines.wg, '[Peer]\n# sol@lineage.llc\nPublicKey = 0HVH6534UBMEVWDz8BA+NkI73Z28bHBBQNFEONCkmWI=');
});
const at = (lab, id) => scriptHex(holderCoin(lab.chain, id).scriptPubKey);
const key = (mnemonic, account) => `5120${tapkeyOf(nostrKey(mnemonic, account).pubkey)}`;
const npubHex = npub => bytesToHex(Uint8Array.from(bech32.fromWords(bech32.decode(npub).words)));
test('each holding has its own root, and every node holds its inscription at its tapkey', () => {
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' });
@@ -64,14 +92,28 @@ test('each holding has its own root, and every node holds its inscription at its
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}$/);
assert.equal(k.path, "acme root › 0' · m/44'/1237'/1'/0/0");
assert.equal(k.npub, nostrKey(opsSeed, 1).npub);
assert.equal(k.wallet.pubkey, tapkeyOf(npubHex(k.npub)), 'the tapkey is the npub, tweaked: anyone with the npub finds the coins');
assert.equal(k.wallet.address, Address.p2tr.fromPubKey(k.wallet.pubkey, 'regtest'));
assert.ok(k.tapkey);
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('leaves hang off the same tree: agents from their nostr secret, orgs from their seed', () => {
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' });
const opsSeed = childMnemonic(lab.keys.roots[root], 0), s = lineageState(lab), node = id => s.nodes.get(id);
assert.deepEqual(leavesOf(lab.keys, s, node(bot)), agentRecords(agentLeaves(nostrSecret(opsSeed, 1)), 'bot@ops.acme', 'bot', 'ops.acme'));
assert.deepEqual(leavesOf(lab.keys, s, node(ops)), nodeRecords(nodeLeaves(seedBytes(opsSeed)), 'ops.acme'));
assert.deepEqual(leavesOf(lab.keys, s, node(root)).map(([label]) => label), ['ssh-ca', 'mtls', 'noise']);
assert.match(leavesOf(lab.keys, s, node(root))[0][2], / lineage-ca@acme$/);
});
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' });
@@ -85,15 +127,31 @@ test('graduation: a fresh root claims the org, the derived key countersigns, and
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));
const leftovers = lab.chain.coins(rootWallet(lab.keys, old).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.equal(keyOf(lab.keys, s, s.nodes.get(seat)).path, "studio root · m/44'/1237'/1'/0/0");
assert.match(leavesOf(lab.keys, s, s.nodes.get(seat))[0][2], / seat@studio\.acme$/, 'names still nest after graduation');
assert.deepEqual(decodeLab(encodeLab(lab)).keys, lab.keys);
});
test('a lab keyed by v1.1 keeps its separate, untweaked chain keys', () => {
const lab = emptyLab();
lab.keys.scheme = 'wallet';
const root = act(lab, 'found', null, { name: 'old' }), seat = act(lab, 'birth', root, { name: 'seat', kind: 'agent' });
const m = lab.keys.roots[root];
assert.deepEqual([root, seat].map(id => at(lab, id)), [0, 1].map(a => `5120${new TestWallet(walletSecret(m, a)).pubkey}`));
const s = lineageState(lab), k = keyOf(lab.keys, s, s.nodes.get(seat));
assert.equal(k.path, "old root · m/86'/1'/1'/0/0");
assert.equal(k.tapkey, false);
const saved = JSON.parse(encodeLab(lab));
delete saved.keys.scheme; // v1.1 saved roots and no scheme
assert.equal(decodeLab(JSON.stringify(saved)).keys.scheme, 'wallet');
assert.throws(() => keysPreference({ scheme: 'nope' }), /Invalid lab keys/);
});
test('holdings from before lab keys still act, with the one lab wallet', () => {
const lab = emptyLab();
fund(lab);
@@ -105,9 +163,10 @@ test('holdings from before lab keys still act, with the one lab wallet', () => {
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(leavesOf(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.deepEqual(decodeLab(JSON.stringify(saved)).keys, { scheme: 'tapkey', roots: {}, adopted: {} });
assert.throws(() => keysPreference({ roots: { [`${'a'.repeat(64)}i0`]: 'not a mnemonic' } }), /Invalid lab mnemonic/);
});
+12 -2
View File
@@ -101,10 +101,20 @@ test('the sample replays, any beat can be inspected, and the lab survives a relo
await expect(page.locator('#inspect-body')).toContainText('"op": "graduate"');
await expect(page.locator('#inspect-body')).toContainText("well-known's key");
await row(page, 'lineage').click();
await expect(page.locator('#inspect-body')).toContainText("lineage root · m/86'/1'/0'/0/0");
await expect(page.locator('#inspect-body')).toContainText("lineage root · m/44'/1237'/0'/0/0");
await expect(page.locator('#inspect-body')).toContainText('its own key');
await row(page, 'time').click();
await expect(page.locator('#act-key')).toHaveText("Signs as time: yolopreneur root › 1' · m/86'/1'/0'/0/0");
await expect(page.locator('#act-key')).toHaveText("Signs as time: yolopreneur root › 1' · m/44'/1237'/0'/0/0");
const inspect = page.locator('#inspect-body');
for (const text of ['ssh-ca', 'lineage-ca@time.yolopreneur', 'mkey:']) await expect(inspect).toContainText(text);
await row(page, 'sol').click();
const fact = name => inspect.locator('.f').filter({ has: page.locator('dt', { hasText: new RegExp(`^${name}$`) }) });
await expect(fact('Tapkey')).toContainText('bcrt1p');
await expect(fact('npub')).toContainText('npub1');
await expect(fact('Derived')).toContainText("yolopreneur root › 1' · m/44'/1237'/2'/0/0");
for (const text of ['ssh-ed25519 AAAAC3NzaC1lZDI1NTE5', 'sol@time.yolopreneur namespaces="git"', 'sol._domainkey.time.yolopreneur. IN TXT "v=DKIM1; k=ed25519; p=', 'PublicKey = ']) {
await expect(inspect.locator('pre')).toContainText(text);
}
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
await page.reload();
await expect(page.locator('#tree-rows .sel')).toHaveCount(12);