From 744c5946aa7fffec31a51d88d6df82f2b6501ea9 Mon Sep 17 00:00:00 2001 From: teahubxyz Date: Wed, 14 Oct 2026 18:22:22 -0700 Subject: [PATCH] Orgchain v1.1: lineage's keys, from the tree branch 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 Claude-Session: https://claude.ai/code/session_01H4upF5LVwmPDDqrhdUfnXh --- AGENTS.md | 2 + README.md | 17 ++++-- index.html | 5 +- package-lock.json | 41 ++++++++++++- package.json | 5 +- src/adapters/handoff.js | 61 +++++++++++++++++++ src/adapters/lineage-keys.js | 55 +++++++++++++++++ src/main.js | 24 +++++++- src/orgchain.css | 2 + src/orgchain/actions.js | 81 ++++++++++++++++++++++--- src/orgchain/keys.js | 55 +++++++++++++++++ src/orgchain/store.js | 19 +++++- tests/keys.test.js | 113 +++++++++++++++++++++++++++++++++++ tests/orgchain.spec.js | 6 ++ 14 files changed, 467 insertions(+), 19 deletions(-) create mode 100644 src/adapters/handoff.js create mode 100644 src/adapters/lineage-keys.js create mode 100644 src/orgchain/keys.js create mode 100644 tests/keys.test.js diff --git a/AGENTS.md b/AGENTS.md index 6c55bf4..11eee89 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,5 +7,7 @@ Orgchain is lineage's app for creating and operating lineage holdings on Super T is vetted by that reducer before it is signed. A new op means a reducer change, its tests and the README's table. - 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. +- Keys follow the tree (`src/orgchain/keys.js`): store roots only (one per holding, one per graduated org) and derive + every other key from its position. Every act is signed by whichever key holds the authorizing inscription. - The page makes no network requests; keep it that way (the deployed CSP sets `connect-src 'none'`). - Before shipping: `npm test`, `npm run build`, `npm run test:e2e`. diff --git a/README.md b/README.md index 3d50cd7..e9ab8d3 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,16 @@ chain itself records who did what, and when. stood at any beat. - **One interpretation.** `src/orgchain/lineage.js` folds `lineage/v0` events into trees, and vets every act before anything is signed. Events it rejects stay on the chain and show as rejected; they just don't change the tree. +- **Keys.** Each holding gets its own 24-word lab root, and every node has lineage's keys at its place in the tree + (`src/orgchain/keys.js`): an org's seed is BIP-85 from its parent's, or its own root once it graduates; agents are + accounts 1..n of their org's seed; each key is BIP-86 (the wallet that holds the node's inscription and signs for it) + and NIP-06 (its npub). Every act is signed by whichever key holds the authorizing inscription, and a birth hands the + child its own. 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. -Test coins only: one test wallet and one local chain, kept in this browser (`localStorage`, key `orgchain:lab:v1`). -Nothing here is a real key; minting a real root is the operator's call. +Test coins and test keys only: a local chain, the lab wallet (the faucet that funds every key) and each holding's lab +root, kept in this browser (`localStorage`, key `orgchain:lab:v1`). Nothing here is a real key; minting a real root is +the operator's call. ## Events @@ -28,7 +35,7 @@ Nothing here is a real key; minting a real root is the operator's call. | `birth` | the parent | `name`, `kind` (`org` or `agent`) | | `award` | the issuing org or holding | `to`, `skill`, `belt` (`white`, `green`, `black`) | | `decide` | the deciding node | `title`, `why` | -| `graduate` | the child's parent | `of` | +| `graduate` | the child's parent (then the handoff to the child's fresh root) | `of` | | `close` | the node itself | none | Names are domain labels (lowercase letters, digits, hyphens). Org and agent indices count separately and are never @@ -52,5 +59,7 @@ Deploy: copy `dist/` to `ventures/lineage/orgchain/` on `teahubxyz/time` `gh-pag 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, unchanged and with their tests -(`tests/adapters.test.js`). See `PROVENANCE.md` and `public/licenses/`. The full lab (explorer, delegates, recursion and +(`tests/adapters.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 the Inscriboor extension) lives at supertestnet.lol. diff --git a/index.html b/index.html index b5f23bc..3e35cb4 100644 --- a/index.html +++ b/index.html @@ -54,7 +54,7 @@
-

Names become domains: lowercase letters, digits and hyphens.

+

Names become domains: lowercase letters, digits and hyphens. Each holding gets its own 24-word lab root (test keys only).

@@ -77,6 +77,7 @@ -

Everything here lives in this browser: a test wallet, a private toy chain, pretend money. Nothing is a real key, and minting a real root is the operator's call.

+

Everything here lives in this browser: test keys (a lab root per holding), a private toy chain, pretend money. Nothing is a real key, and minting a real root is the operator's call.

Orgchain is lineage's app, built on Super Testnet's chain and inscriptions, where the full lab lives. Source: lineage/orgchain. Licenses: Super Testnet (CC0), tapscript, noble-curves, noble-hashes.

diff --git a/package-lock.json b/package-lock.json index 33e50d2..545c277 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,10 @@ "dependencies": { "@cmdcode/tapscript": "1.5.3", "@noble/curves": "1.9.7", - "@noble/hashes": "1.8.0" + "@noble/hashes": "1.8.0", + "@scure/base": "1.2.6", + "@scure/bip32": "1.7.0", + "@scure/bip39": "1.6.0" }, "devDependencies": { "@playwright/test": "1.56.1", @@ -878,6 +881,42 @@ "win32" ] }, + "node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", + "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.9.0", + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", + "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", diff --git a/package.json b/package.json index d7984ce..e53c03f 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,10 @@ "dependencies": { "@cmdcode/tapscript": "1.5.3", "@noble/curves": "1.9.7", - "@noble/hashes": "1.8.0" + "@noble/hashes": "1.8.0", + "@scure/base": "1.2.6", + "@scure/bip32": "1.7.0", + "@scure/bip39": "1.6.0" }, "devDependencies": { "@playwright/test": "1.56.1", diff --git a/src/adapters/handoff.js b/src/adapters/handoff.js new file mode 100644 index 0000000..caa9657 --- /dev/null +++ b/src/adapters/handoff.js @@ -0,0 +1,61 @@ +// A bound handoff, the buy-order direction of OpenOrdex: the recipient builds the transaction and signs +// its own fee input first, so its signature already fixes where the inscription goes; the holder then +// countersigns the inscription input. Two inputs, two outputs, default (64-byte) signatures, so the +// local chain accepts it unchanged. Free handoffs only: a price would need a third output. +import { Script, Tx } from '@cmdcode/tapscript'; +import { getVin, getVout } from '../upstream/primitives.js'; +import { FEE } from './inscription.js'; + +export const CLAIM_KIND = 'lineage-claim'; +const DUST = 330; +const scriptHex = script => Script.encode(script, false).hex; +const outpoint = coin => ({ txid: coin.txid, vout: coin.vout, value: coin.value, script: scriptHex(coin.scriptPubKey) }); +const HEX64 = /^[0-9a-f]{64}$/, TAPROOT = /^5120[0-9a-f]{64}$/; + +export function holderCoin(chain, inscriptionId) { + for (const coin of chain.utxos.values()) if (coin.inscriptions?.some(i => i.id === inscriptionId)) return coin; + return null; +} +function build(claim) { + return Tx.create({ + vin: [getVin(claim.holder.txid, claim.holder.vout, claim.holder.value, claim.holder.script), + getVin(claim.payer.txid, claim.payer.vout, claim.payer.value, claim.payer.script)], + vout: [getVout(claim.holder.value, ['OP_1', claim.to]), getVout(claim.payer.value - FEE, ['OP_1', claim.to])], + }); +} +export function makeClaim(chain, inscriptionId, recipient, now = Date.now()) { + if (recipient?.kind !== 'test-wallet') throw new Error('Claims are signed by local test wallets.'); + const held = holderCoin(chain, inscriptionId); + if (!held) throw new Error('That inscription is not in an unspent output.'); + if (scriptHex(held.scriptPubKey) === scriptHex(recipient.script)) throw new Error('The recipient already holds that inscription.'); + const fee = chain.coins(recipient.script).find(c => !c.inscriptions?.length && c.value >= FEE + DUST); + if (!fee) throw new Error('The recipient needs a plain coin for the 500-sat fee. Mine a block to it first.'); + const claim = { kind: CLAIM_KIND, version: 1, inscription: inscriptionId, carried: held.inscriptions.map(i => i.id), + holder: outpoint(held), payer: outpoint(fee), to: recipient.pubkey, at: now }; + const tx = build(claim); + claim.txid = Tx.util.getTxid(tx); + claim.sig = recipient.sign(tx, undefined, 1); + return claim; +} +export function countersign(chain, claim, holder) { + const valid = decodeClaim(claim); + if (holder?.kind !== 'test-wallet') throw new Error('Countersigning needs a local test wallet.'); + if (scriptHex(holder.script) !== valid.holder.script) throw new Error('Only the current holder can countersign.'); + if (claimStatus(chain, valid) !== 'open') throw new Error('This claim is stale: an input it spends was already spent.'); + const tx = build(valid); + tx.vin[1].witness = [valid.sig]; + tx.vin[0].witness = [holder.sign(tx, undefined, 0)]; + chain.submitPackage([Tx.encode(tx).hex]); + return Tx.util.getTxid(tx); +} +// open: both inputs still unspent; stale: one was spent elsewhere, so the claim can never confirm. +export function claimStatus(chain, claim) { + return chain.utxos.has(`${claim.holder.txid}:${claim.holder.vout}`) && chain.utxos.has(`${claim.payer.txid}:${claim.payer.vout}`) ? 'open' : 'stale'; +} +export function decodeClaim(data) { + const point = p => p && HEX64.test(p.txid) && Number.isSafeInteger(p.vout) && p.vout >= 0 && Number.isSafeInteger(p.value) && p.value >= DUST && TAPROOT.test(p.script); + if (data?.kind !== CLAIM_KIND || data.version !== 1 || !/^[0-9a-f]{64}i\d+$/.test(data.inscription) || + !Array.isArray(data.carried) || !point(data.holder) || !point(data.payer) || data.payer.value < FEE + DUST || + !HEX64.test(data.to) || !HEX64.test(data.txid) || !/^[0-9a-f]{128}$/.test(data.sig) || !Number.isSafeInteger(data.at)) throw new Error('Invalid claim.'); + return data; +} diff --git a/src/adapters/lineage-keys.js b/src/adapters/lineage-keys.js new file mode 100644 index 0000000..1554aee --- /dev/null +++ b/src/adapters/lineage-keys.js @@ -0,0 +1,55 @@ +// Lineage lab keys (docs/lineage.md): a BIP-85 seed per org, BIP-86 Taproot keys and NIP-06 +// Nostr keys per account. Lab only: the real root is the operator's call; nothing here is a real key. +import { HDKey } from '@scure/bip32'; +import { generateMnemonic, mnemonicToSeedSync, entropyToMnemonic, validateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; +import { bech32 } from '@scure/base'; +import { hmac } from '@noble/hashes/hmac'; +import { sha512 } from '@noble/hashes/sha2'; +import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils'; +import { schnorr } from '@noble/curves/secp256k1'; + +export const PATHS = { + // BIP-85, application 39 (BIP-39), English, 24 words: lineage spec v0.1's org seed. + orgSeed: index => `m/83696968'/39'/0'/24'/${index}'`, + // BIP-86 single-key Taproot. Coin type 1' (test networks) in the lab. + wallet: (account, coin = 1) => `m/86'/${coin}'/${account}'/0/0`, + // NIP-06, as on the lineage key tree page. + nostr: account => `m/44'/1237'/${account}'/0/0`, +}; +const MAX_INDEX = 2 ** 31 - 1; +const masters = new Map(), children = new Map(); + +export const newMnemonic = () => generateMnemonic(wordlist, 256); +export function assertMnemonic(mnemonic, words = 0) { + if (typeof mnemonic !== 'string' || !validateMnemonic(mnemonic, wordlist) || + (words && mnemonic.split(' ').length !== words)) throw new Error('Invalid lab mnemonic.'); + return mnemonic; +} +function assertIndex(value, what) { + if (!Number.isSafeInteger(value) || value < 0 || value > MAX_INDEX) throw new Error(`Invalid ${what}.`); + return value; +} +// PBKDF2 is the slow step, so each mnemonic's master key is computed once per page. +function master(mnemonic) { + if (!masters.has(mnemonic)) masters.set(mnemonic, HDKey.fromMasterSeed(mnemonicToSeedSync(assertMnemonic(mnemonic)))); + return masters.get(mnemonic); +} +// BIP-85: entropy = HMAC-SHA512(key = "bip-entropy-from-k", msg = k) for the hardened child key k. +export function bip85Entropy(root, path) { + const key = root.derive(path).privateKey; + if (!key) throw new Error('BIP-85 path produced no private key.'); + return hmac(sha512, utf8ToBytes('bip-entropy-from-k'), key); +} +export function childMnemonic(parentMnemonic, index) { + const id = `${parentMnemonic}|${assertIndex(index, 'child index')}`; + if (!children.has(id)) children.set(id, entropyToMnemonic(bip85Entropy(master(parentMnemonic), PATHS.orgSeed(index)).slice(0, 32), wordlist)); + return children.get(id); +} +export function walletSecret(mnemonic, account = 0, coin = 1) { + return bytesToHex(master(mnemonic).derive(PATHS.wallet(assertIndex(account, 'account'), coin)).privateKey); +} +export function nostrKey(mnemonic, account = 0) { + const pubkey = schnorr.getPublicKey(master(mnemonic).derive(PATHS.nostr(assertIndex(account, 'account'))).privateKey); + return { pubkey: bytesToHex(pubkey), npub: bech32.encode('npub', bech32.toWords(pubkey)) }; +} diff --git a/src/main.js b/src/main.js index 4c953bf..6bb2e15 100644 --- a/src/main.js +++ b/src/main.js @@ -5,6 +5,8 @@ import { MetadataIndex } from './adapters/metadata-index.js'; import { census, lineageEvents, reduce, rows, slug, summary, trail } from './orgchain/lineage.js'; import { LAB_KEY, beat, decodeLab, emptyLab, encodeLab, mutate, readLab, setTempo, tick } from './orgchain/store.js'; import { SAMPLE, act, seedSample } from './orgchain/actions.js'; +import { holderOf, keyOf } from './orgchain/keys.js'; +import { readEnvelope } from './adapters/inscription.js'; const $ = id => document.getElementById(id); function h(tag, attrs = {}, ...kids) { @@ -180,6 +182,8 @@ function renderAct() { const sig = list => list.map(o => `${o.value}=${o.textContent}`).join('|'); if (sig(options) !== sig([...to.options])) { to.replaceChildren(...options); if (options.some(o => o.value === keep)) to.value = keep; } $('graduate-go').textContent = `Graduate ${n.name}`; + const signer = ui.verb === 'graduate' ? parent : n, k = signer && keyOf(lab.keys, live, signer); + $('act-key').textContent = !signer || why[ui.verb] ? '' : k ? `Signs as ${signer.name}: ${k.path}` : 'Signs with the lab wallet: this holding predates lab keys.'; const below = derivedBelow(n), arming = ui.closing === n.id; $('close-note').textContent = below ? `Closing ${n.name} spends it for the last time, and its ${plural(below, 'derived descendant')} close with it. Graduated children hold their own roots and stay open.` @@ -207,6 +211,7 @@ function nodeView(n) { parent && ['Edge', n.edge === 'adopted' ? `adopted at ${at(n.graduated)}: it holds its own root` : `derived: ${parent.name} holds its seed`], n.closed !== null && ['Closed', at(n.closed)], ['Children', n.children.length ? n.children.map(id => node(id, view)?.name).join(', ') : 'none yet'], + ...keyFacts(n), ['Inscription', n.id], ].filter(Boolean); return [ @@ -220,12 +225,29 @@ function nodeView(n) { h('li', {}, h('b', { text: d.title }), d.why && ` · ${d.why}`, h('span', { class: 'when', text: ` (${at(d.beat)})` })))))), ]; } +// Lineage's keys for a node: its derivation, npub and address as of the viewed beat, and who holds its inscription now. +const HELD = { own: 'its own key', lab: 'the lab wallet', parent: "its parent's key (its handoff is cued)", other: 'a key outside this lab' }; +function keyFacts(n) { + const k = keyOf(lab.keys, view, n), holder = holderOf(lab, live, n.id); + return [['Key', k ? k.path : 'the lab wallet: this holding predates lab keys'], k && ['npub', k.npub], k && ['Address', k.wallet.address], + ['Held now by', holder ? HELD[holder.who] : 'nobody: its inscription has been spent']]; +} +// The key that signed an event's reveal, named when it belongs to the node that authorized it. +function signerOf(e) { + const witness = lab.chain.transactions.get(e.id.slice(0, -2))?.tx.vin.find(i => i.witness?.length === 3)?.witness; + if (!witness) return '?'; + const pubkey = readEnvelope(witness).pubkey, by = node(e.parent), own = e.op === 'found' ? node(e.node) : null; + const named = [[own && keyOf(lab.keys, live, own), `${own?.name}'s root`], [by && keyOf(lab.keys, live, by), `${by?.name}'s key`], + [by?.edge === 'adopted' && keyOf(lab.keys, live, { ...by, edge: 'derived' }), `${by?.name}'s derived key, from before it graduated`]]; + for (const [k, label] of named) if (k && k.wallet.pubkey === pubkey) return label; + return lab.wallet?.pubkey === pubkey ? 'the lab wallet' : `a key outside this lab (${pubkey.slice(0, 12)}…)`; +} function eventView(e) { const raw = index.events.find(x => x.id === e.id), body = lab.chain.transactions.get(e.id.slice(0, -2))?.inscription?.text; const facts = [ ['When', e.pending ? 'cued for the next beat' : `beat ${e.beat}`], ['Authorized by', e.parent ? `${node(e.parent)?.name ?? 'an unknown inscription'}, whose inscription this event spent` : 'nothing: a founding has no parent'], - ['Inscription', e.id], body && ['Body', body], raw?.block && ['Block', raw.block], + ['Signed by', signerOf(e)], ['Inscription', e.id], body && ['Body', body], raw?.block && ['Block', raw.block], ].filter(Boolean); const entry = raw && { id: raw.id, status: raw.status, height: raw.height, block: raw.block, parent: raw.parent ?? null, metaprotocol: raw.metaprotocol ?? null, contentType: raw.contentType, sat: raw.sat, location: raw.location }; diff --git a/src/orgchain.css b/src/orgchain.css index 1081461..ecf22af 100644 --- a/src/orgchain.css +++ b/src/orgchain.css @@ -174,6 +174,8 @@ textarea { resize: vertical; line-height: 1.5; } .verbs button:hover:not([aria-pressed="true"]) { color: var(--ink); text-decoration: underline; text-underline-offset: 3px; } .verbs button[aria-pressed="true"] { background: var(--ink); color: var(--slab); } .why { margin: 0; font-size: var(--t-s); color: var(--ink-2); } +.keyline { margin: 0; padding: 10px 18px; font-size: var(--t-xs); color: var(--ink-2); overflow-wrap: anywhere; border-bottom: 1px dashed var(--ink-3); } +.keyline:empty { display: none; } .why:empty { display: none; } .vf { display: grid; gap: 6px; } .vf .lab { margin-top: 8px; } diff --git a/src/orgchain/actions.js b/src/orgchain/actions.js index 238f5b9..ea3b0ec 100644 --- a/src/orgchain/actions.js +++ b/src/orgchain/actions.js @@ -1,9 +1,16 @@ // Orgchain's verbs as lineage/v0 inscriptions: found, birth, award, decide, graduate, close. +// Each act is signed by the key that holds its authorizing node's inscription (lineage's key tree, ./keys.js). +// The lab wallet is the faucet: beats pay it, and it funds every other key and pays for handoffs. +import { Tx } from '@cmdcode/tapscript'; import { InscriptionAdapter, POSTAGE, FEE } from '../adapters/inscription.js'; import { MetadataIndex } from '../adapters/metadata-index.js'; import { TestWallet } from '../adapters/wallet.js'; +import { getVin, getVout } from '../upstream/primitives.js'; +import { newMnemonic, walletSecret } from '../adapters/lineage-keys.js'; +import { holderCoin, makeClaim, countersign } from '../adapters/handoff.js'; import { PROTOCOL, lineageEvents, reduce } from './lineage.js'; import { armClock, beat } from './store.js'; +import { derivedSubtree, emptyKeys, holderOf, keyOf } from './keys.js'; const FIELDS = { found: ['name'], birth: ['name', 'kind'], award: ['to', 'skill', 'belt'], decide: ['title', 'why'], graduate: ['of'], close: [] }; const BODY = { found: m => `found ${m.name}`, birth: m => `birth ${m.name}`, award: m => `award ${m.skill} ${m.belt}`, @@ -16,6 +23,8 @@ export function metadataFor(op, fields = {}) { return metadata; } +export const lineageState = lab => reduce(lineageEvents(new MetadataIndex(lab.chain)), { pending: true }); + // The reducer is the validator: the would-be event must fold cleanly onto everything so far, cued acts included. export function check(lab, op, by, fields) { const metadata = metadataFor(op, fields); @@ -26,22 +35,80 @@ export function check(lab, op, by, fields) { return metadata; } -const spendable = lab => lab.chain.coins(lab.wallet.script).some(c => !c.inscription && c.value >= POSTAGE + 2 * FEE + 330); -// The lab's first beat pays its test wallet 50 test BTC, enough for millions of acts. +const NEED = POSTAGE + 2 * FEE + 330; // what one inscription costs a key, with dust to spare +const GRANT = 100_000; // what the faucet gives a key that runs short: about fifty acts +const plain = (lab, wallet, min) => lab.chain.coins(wallet.script).find(c => !c.inscriptions?.length && c.value >= min); +const vin = coin => getVin(coin.txid, coin.vout, coin.value, coin.scriptPubKey); +const signed = (tx, signers) => { signers.forEach((wallet, i) => { tx.vin[i].witness = [wallet.sign(tx, undefined, i)]; }); return Tx.encode(tx).hex; }; + +// The faucet's first beat pays it 50 test BTC, enough for a very long time. export function fund(lab) { + lab.keys ??= emptyKeys(); if (!lab.wallet) lab.wallet = TestWallet.create(); - if (!spendable(lab)) beat(lab); + if (!plain(lab, lab.wallet, GRANT + FEE + 330)) beat(lab); +} +// Give a key a plain coin when it runs short, so it can sign its own acts. +function supply(lab, wallet, min = NEED) { + if (wallet === lab.wallet || plain(lab, wallet, min)) return; + const coin = plain(lab, lab.wallet, GRANT + FEE + 330); + const tx = Tx.create({ vin: [vin(coin)], vout: [getVout(GRANT, wallet.script), getVout(coin.value - GRANT - FEE, lab.wallet.script)] }); + lab.chain.submitPackage([signed(tx, [lab.wallet])]); +} +// Move an inscription from the key that holds it to another; the faucet pays the fee. +function hand(lab, id, from, to) { + const held = holderCoin(lab.chain, id), fee = plain(lab, lab.wallet, FEE + 330); + const tx = Tx.create({ vin: [vin(held), vin(fee)], vout: [getVout(held.value, to.script), getVout(fee.value - FEE, lab.wallet.script)] }); + lab.chain.submitPackage([signed(tx, [from, lab.wallet])]); +} +function inscribe(lab, wallet, op, by, metadata) { + return new InscriptionAdapter(lab.chain, wallet).inscribe(BODY[op](metadata), by ?? null, metadata, { metaprotocol: PROTOCOL }); +} +// Who signs for a node: whichever key holds its inscription. +function signerFor(lab, state, id) { + const holder = holderOf(lab, state, id); + if (!holder?.wallet) throw new Error(`No key in this lab holds ${state.nodes.get(id)?.name ?? 'that node'}'s inscription.`); + return holder.wallet; } -function inscribe(lab, op, by, metadata) { - return new InscriptionAdapter(lab.chain, lab.wallet).inscribe(BODY[op](metadata), by ?? null, metadata, { metaprotocol: PROTOCOL }); +// One act. Found mints a holding's own root; everything else is signed by the authorizing node's key. A birth hands +// the child its own inscription at its own key. A graduation hands the org to a fresh root by claim and countersign, +// and its derived subtree re-keys under that root, so the parent's line can no longer sign for it. +function perform(lab, op, by, metadata) { + if (op === 'found') { + const mnemonic = newMnemonic(), root = new TestWallet(walletSecret(mnemonic, 0)); + supply(lab, root); + const id = inscribe(lab, root, op, null, metadata); + lab.keys.roots[id] = mnemonic; + return id; + } + const before = lineageState(lab), signer = signerFor(lab, before, by); + const org = op === 'graduate' ? before.nodes.get(metadata.of) : null; + const moving = org && keyOf(lab.keys, before, org) ? derivedSubtree(before, org.id).map(n => [n.id, signerFor(lab, before, n.id)]) : []; + supply(lab, signer); + const id = inscribe(lab, signer, op, by, metadata); + if (op === 'birth') { + const after = lineageState(lab), child = keyOf(lab.keys, after, after.nodes.get(id)); + if (child) hand(lab, id, signer, child.wallet); + } + if (moving.length) { + lab.keys.adopted[org.id] = newMnemonic(); + const after = lineageState(lab); + for (const [nodeId, from] of moving) { + const to = keyOf(lab.keys, after, after.nodes.get(nodeId)).wallet; + if (nodeId !== org.id) { hand(lab, nodeId, from, to); continue; } + // The fresh root signs its fee input first, which fixes where the org goes; the holder then countersigns. + supply(lab, to, FEE + 330); + countersign(lab.chain, makeClaim(lab.chain, nodeId, to), from); + } + } + return id; } // One act is one inscription whose parent is the authorizing node. At the 'each' tempo it lands at once; otherwise it is cued. export function act(lab, op, by, fields, now = Date.now()) { const metadata = check(lab, op, by, fields); fund(lab); - const id = inscribe(lab, op, by, metadata); + const id = perform(lab, op, by ?? null, metadata); if (lab.clock.tempo === 'each') beat(lab); else armClock(lab, now); return id; } @@ -67,7 +134,7 @@ export function seedSample(lab) { for (const group of PLAN) { for (const [op, by, fields] of group) { const resolved = Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, k === 'to' || k === 'of' ? ids[v] : v])); - const parent = by ? ids[by] : null, metadata = check(lab, op, parent, resolved), id = inscribe(lab, op, parent, metadata); + const parent = by ? ids[by] : null, id = perform(lab, op, parent, check(lab, op, parent, resolved)); if (op === 'found' || op === 'birth') ids[fields.name] = id; } beat(lab); diff --git a/src/orgchain/keys.js b/src/orgchain/keys.js new file mode 100644 index 0000000..cc589b9 --- /dev/null +++ b/src/orgchain/keys.js @@ -0,0 +1,55 @@ +// Lineage's key tree over Orgchain's holdings (docs/lineage.md), using the tree branch's lineage-keys.js. +// Each holding has its own 24-word lab root. An org's seed is BIP-85 from its parent's seed at its index, or its own +// root once it graduates. Agents (seats) are accounts 1..n of their org's seed. Every key is BIP-86 (the wallet that +// holds the node's inscription and signs for it) and NIP-06 (its npub). Lab only: nothing here is a real key. +import { Script } from '@cmdcode/tapscript'; +import { PATHS, childMnemonic, nostrKey, walletSecret } from '../adapters/lineage-keys.js'; +import { holderCoin } from '../adapters/handoff.js'; +import { TestWallet } from '../adapters/wallet.js'; + +export const emptyKeys = () => ({ roots: {}, adopted: {} }); +export const scriptHex = script => Script.encode(script, false).hex; + +// Where a node's keys come from, or null for a holding that predates lab keys (one test wallet signs for it). +export function seedOf(keys, state, node) { + if (!node) return null; + const own = mnemonic => (mnemonic ? { mnemonic, account: 0, origin: `${node.name} root`, steps: [] } : null); + if (node.kind === 'holding') return own(keys.roots[node.id]); + const parent = state.nodes.get(node.parent); + if (node.kind === 'agent') { + const org = seedOf(keys, state, parent); + return org && { ...org, account: node.index + 1 }; + } + if (node.edge === 'adopted') return own(keys.adopted[node.id]); + const up = seedOf(keys, state, parent); + return up && { mnemonic: childMnemonic(up.mnemonic, node.index), account: 0, origin: up.origin, steps: [...up.steps, `${node.index}'`] }; +} + +export function keyOf(keys, state, node) { + const seed = seedOf(keys, state, node); + if (!seed) return null; + const wallet = new TestWallet(walletSecret(seed.mnemonic, seed.account)); + return { wallet, npub: nostrKey(seed.mnemonic, seed.account).npub, path: `${[seed.origin, ...seed.steps].join(' › ')} · ${PATHS.wallet(seed.account)}` }; +} + +// The wallet that holds an inscription now: the node's own key, its parent's (a handoff still cued), or the lab wallet. +export function holderOf(lab, state, id) { + const coin = holderCoin(lab.chain, id); + if (!coin) return null; + const script = scriptHex(coin.scriptPubKey), node = state.nodes.get(id); + const own = keyOf(lab.keys, state, node), parent = node && keyOf(lab.keys, state, state.nodes.get(node.parent)); + if (own && scriptHex(own.wallet.script) === script) return { wallet: own.wallet, who: 'own' }; + if (lab.wallet && scriptHex(lab.wallet.script) === script) return { wallet: lab.wallet, who: 'lab' }; + if (parent && scriptHex(parent.wallet.script) === script) return { wallet: parent.wallet, who: 'parent' }; + return { wallet: null, who: 'other', script }; +} + +// A node and its derived descendants: what re-keys when the node graduates. Adopted nodes keep their own roots. +export function derivedSubtree(state, id) { + const node = state.nodes.get(id), out = [node]; + for (const child of node.children) { + const c = state.nodes.get(child); + if (c.status === 'active' && c.edge === 'derived') out.push(...derivedSubtree(state, child)); + } + return out; +} diff --git a/src/orgchain/store.js b/src/orgchain/store.js index ac376f7..e5fcaf3 100644 --- a/src/orgchain/store.js +++ b/src/orgchain/store.js @@ -1,6 +1,8 @@ // Orgchain's lab: one test wallet, one local chain and the clock, kept in this browser under its own key. import { LocalChain } from '../adapters/chain.js'; import { NETWORK, TestWallet } from '../adapters/wallet.js'; +import { assertMnemonic } from '../adapters/lineage-keys.js'; +import { emptyKeys } from './keys.js'; export const LAB_KEY = 'orgchain:lab:v1'; // 'each': every act is its own beat. 10/30/60: acts are cued and land together on the next beat. 'manual': they wait for Beat now. @@ -12,16 +14,27 @@ export function clockPreference(value = { tempo: 'each', nextAt: null }) { if (!TEMPOS.includes(tempo) || !(nextAt === null || (timed && Number.isSafeInteger(nextAt) && nextAt > 0))) throw new Error('Invalid clock.'); return { tempo, nextAt }; } -export const emptyLab = () => ({ wallet: null, chain: new LocalChain(), clock: clockPreference() }); +// Lab keys: a 24-word root per holding, and the fresh root each graduated org adopted, by inscription id. +// Optional, so labs saved before keys still load (their holdings sign with the one lab wallet). +export function keysPreference(value) { + const keys = emptyKeys(); + for (const part of ['roots', 'adopted']) for (const [id, mnemonic] of Object.entries(value?.[part] ?? {})) { + if (!/^[0-9a-f]{64}i0$/.test(id)) throw new Error('Invalid lab keys.'); + keys[part][id] = assertMnemonic(mnemonic, 24); + } + return keys; +} +export const emptyLab = () => ({ wallet: null, chain: new LocalChain(), clock: clockPreference(), keys: emptyKeys() }); export function decodeLab(raw) { if (!raw) return emptyLab(); const data = JSON.parse(raw); if (data?.kind !== 'orgchain-lab' || data.network !== NETWORK || data.version !== 1) throw new Error('That is not an Orgchain v1 lab.'); - return { wallet: data.wallet ? TestWallet.load(data.wallet) : null, chain: new LocalChain(data.chain), clock: clockPreference(data.clock) }; + return { wallet: data.wallet ? TestWallet.load(data.wallet) : null, chain: new LocalChain(data.chain), clock: clockPreference(data.clock), + keys: keysPreference(data.keys) }; } export const encodeLab = lab => JSON.stringify({ kind: 'orgchain-lab', network: NETWORK, version: 1, - wallet: lab.wallet?.backup() ?? null, chain: lab.chain.snapshot(), clock: clockPreference(lab.clock) }); + wallet: lab.wallet?.backup() ?? null, chain: lab.chain.snapshot(), clock: clockPreference(lab.clock), keys: keysPreference(lab.keys) }); export const readLab = () => decodeLab(localStorage.getItem(LAB_KEY)); export function saveLab(lab) { diff --git a/tests/keys.test.js b/tests/keys.test.js new file mode 100644 index 0000000..319d141 --- /dev/null +++ b/tests/keys.test.js @@ -0,0 +1,113 @@ +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/); +}); diff --git a/tests/orgchain.spec.js b/tests/orgchain.spec.js index 94bd562..54d5b1a 100644 --- a/tests/orgchain.spec.js +++ b/tests/orgchain.spec.js @@ -99,6 +99,12 @@ test('the sample replays, any beat can be inspected, and the lab survives a relo await expect(page.locator('#beat-label')).toHaveText('Now · beat 7', { timeout: 15_000 }); await page.locator('#timeline-list .ev', { hasText: 'graduate lineage from well-known' }).click(); 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('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"); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); await page.reload(); await expect(page.locator('#tree-rows .sel')).toHaveCount(12);