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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4upF5LVwmPDDqrhdUfnXh
This commit is contained in:
1 parent
6acc319ee7
commit
744c5946aa
14 files changed
+467
-19
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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)) };
|
||||
}
|
||||
+23
-1
@@ -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 };
|
||||
|
||||
@@ -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; }
|
||||
|
||||
+74
-7
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+16
-3
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user