// Orgchain: create and operate lineage holdings. Lineage gives the structure; the chain adds time. // Every change goes through the lab's lock (orgchain/store.js) and the reducer (orgchain/lineage.js); this file draws and asks. import './orgchain.css'; 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, setRigs, setRule, setTempo, tick } from './orgchain/store.js'; import { SAMPLE, act, gasCheck, gasCost, lastHolding, minerOf, send, seedSample } from './orgchain/actions.js'; import { addressesOf, derivedSubtree, holderOf, keyOf, leavesOf, secretsOf } from './orgchain/keys.js'; import { DUST, FEE, balances, brief, btc, coins, ledger, miners, most, owners, payerOf, rigsOf, satsOf, tankOf, tanks } from './orgchain/money.js'; import { readEnvelope } from './adapters/inscription.js'; const $ = id => document.getElementById(id); function h(tag, attrs = {}, ...kids) { const el = document.createElement(tag); for (const [k, v] of Object.entries(attrs)) { if (v === null || v === undefined || v === false) continue; if (k === 'class') el.className = v; else if (k === 'text') el.textContent = v; else if (k.startsWith('on')) el.addEventListener(k.slice(2), v); else el.setAttribute(k, v === true ? '' : String(v)); } for (const kid of kids.flat(Infinity)) if (kid !== null && kid !== undefined && kid !== false && kid !== '') el.append(kid instanceof Node ? kid : String(kid)); return el; } const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`; const at = when => (when === 'cued' ? 'cued' : `beat ${when}`); const clockTime = ms => new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); const json = value => JSON.stringify(value, (k, v) => (typeof v === 'bigint' ? String(v) : v), 2); // ui.beat: null means now (cued acts included); a number is that past beat, read-only. const ui = { holding: null, node: null, event: null, tx: null, beat: null, verb: 'birth', busy: false, replay: null, closing: null, reveal: null }; let lab = emptyLab(), index = new MetadataIndex(lab.chain), events = [], live = reduce([]), view = live; // The money (orgchain/money.js): whose each SegWit script is, every move in chain order, and balances now and as viewed. let money = { owner: new Map(), ledger: [], now: new Map(), view: new Map() }; const tip = () => lab.chain.blocks.length; const cuedActs = () => events.filter(e => e.status === 'pending').length; const cuedSends = () => money.ledger.filter(m => m.beat === null && m.kind === 'pay').length; const cued = () => cuedActs() + cuedSends(); const purse = id => money.view.get(id) ?? 0; // a balance at the viewed beat const held = id => money.now.get(id) ?? 0; // a balance now, cued sends included const node = (id, state = live) => (id ? state.nodes.get(id) ?? null : null); const select = next => Object.assign(ui, { event: null, tx: null, closing: null, reveal: null }, next); const pick = next => { select(next); render(); }; function say(message, bad = false) { const el = $('say'); el.textContent = message; el.classList.toggle('bad', bad); } function load() { try { lab = readLab(); } catch (error) { lab = emptyLab(); say(`This browser's saved lab could not be read (${error.message}) Load a backup, or reset to start over.`, true); } derive(); } function derive() { index = new MetadataIndex(lab.chain); events = lineageEvents(index); live = reduce(events, { pending: true }); if (ui.beat !== null && ui.beat >= tip()) ui.beat = null; if (!node(ui.holding)) ui.holding = live.roots.at(-1)?.id ?? null; if (!node(ui.node) || node(ui.node).root !== ui.holding) ui.node = ui.holding; if (ui.event && !live.log.some(e => e.id === ui.event)) ui.event = null; view = ui.beat === null ? live : reduce(events, { upTo: ui.beat }); const owner = owners(lab.keys, events, live); money = { owner, ledger: ledger(lab.chain, owner, tanks(lab.keys, events, live)), now: balances(lab.chain, owner) }; money.view = ui.beat === null ? money.now : balances(lab.chain, owner, ui.beat); if (ui.tx && !money.ledger.some(m => m.key === ui.tx)) ui.tx = null; render(); } function setBeat(value) { ui.beat = value >= tip() ? null : Math.max(0, value); view = ui.beat === null ? live : reduce(events, { upTo: ui.beat }); money.view = ui.beat === null ? money.now : balances(lab.chain, money.owner, ui.beat); render(); } // One path for every change: the lab's lock re-reads, acts and saves. A failure changes nothing and says why. async function run(action, options) { if (ui.busy) return false; ui.busy = true; document.body.classList.add('busy'); try { lab = (await mutate(action, options)).lab; return true; } catch (error) { say(error?.message || String(error), true); return false; } finally { ui.busy = false; document.body.classList.remove('busy'); derive(); } } // Act through a node, then say where it landed: at a beat, or cued for the next one. async function perform(op, by, fields, done) { let id = null; if (!(await run(l => { id = act(l, op, by, fields); }))) return false; const e = index.events.find(x => x.id === id); say(done(id, e?.status === 'confirmed' ? `at beat ${e.height}` : 'cued for the next beat')); render(); return true; } // Run something that may beat, then say what the beat landed and whom it paid. async function landCue(action) { const acts = cuedActs(), sends = cuedSends(), height = tip(); if (!(await run(action)) || tip() === height) return; const landed = [[acts - cuedActs(), 'cued act'], [sends - cuedSends(), 'cued send']].filter(([n]) => n > 0).map(([n, w]) => plural(n, w)); const earned = earnings(money.ledger.filter(x => x.kind === 'mine' && x.beat === tip())); say(`Beat ${tip()}: ${landed.length ? `${landed.join(' and ')} landed, and ${earned}` : earned}.`); } // "acme earned 50 tBTC", "acme and beta earned 25 tBTC each", "acme earned 37.5 tBTC, beta 12.5 tBTC" function earnings(paid) { if (!paid.length) return 'the lab wallet earned 50 tBTC'; const names = paid.map(m => node(m.to)?.name ?? '?'), amounts = paid.map(m => brief(m.amount)); if (paid.length === 1) return `${names[0]} earned ${btc(paid[0].amount)}`; if (amounts.every(a => a === amounts[0])) return `${names.slice(0, -1).join(', ')} and ${names.at(-1)} earned ${amounts[0]} each`; return paid.map((m, i) => `${names[i]}${i ? '' : ' earned'} ${amounts[i]}`).join(', '); } // Whom the next beat pays, in words. function paysWho() { if (lab.mining?.rule === 'last') return 'the holding that acts'; const list = miners(lab, live); return !list.length ? 'the lab wallet' : list.length === 1 ? list[0].name : `${list.length} holdings, split by hashrate`; } function render() { renderClock(); renderHoldings(); renderTree(); renderAct(); renderInspect(); renderTimeline(); } function renderClock() { const n = cued(), { tempo } = lab.clock; for (const b of document.querySelectorAll('[data-tempo]')) b.setAttribute('aria-pressed', String(String(tempo) === b.dataset.tempo)); $('clock-beat').textContent = String(tip()); $('clock-cued').textContent = n ? `${n} cued · ${cueNote()}` : 'nothing cued'; const who = paysWho(); $('clock-pays').textContent = `beats pay ${who}`; $('beat-now').title = `Beat now: ${n ? 'land what is cued, and ' : ''}pay ${who} 50 test BTC`; } function cueNote() { const { tempo, nextAt } = lab.clock; if (tempo === 'manual') return 'waiting for Beat now'; if (typeof tempo === 'number' && nextAt) return `next beat in ${Math.max(0, Math.ceil((nextAt - Date.now()) / 1000))}s`; return 'landing on the next beat'; } function renderHoldings() { const split = lab.mining?.rule !== 'last', mining = split ? miners(lab, live) : [], total = mining.reduce((sum, r) => sum + rigsOf(lab, r.id), 0); $('holdings-list').replaceChildren(...live.roots.map(r => { const c = census(live, r.id), on = r.id === ui.holding, active = r.status === 'active', share = mining.some(x => x.id === r.id) ? rigsOf(lab, r.id) / total : 0; const worth = [...live.nodes.values()].reduce((sum, x) => sum + (x.root === r.id ? held(x.id) : 0), 0); const facts = [at(r.born), plural(c.orgs, 'org'), plural(c.agents, 'agent'), plural(c.belts, 'belt'), worth > 0 && brief(worth), split && active && (share ? `${Math.round(share * 100)}% of each beat` : 'not mining'), !active && 'closed'].filter(Boolean); return h('li', {}, h('button', { type: 'button', class: `hold${on ? ' on' : ''}${active ? '' : ' gone'}`, 'aria-pressed': String(on), onclick: () => pick({ holding: r.id, node: r.id }) }, h('span', { class: 'hn', text: r.name }), ' ', h('span', { class: 'hm', text: facts.join(' · ') })), split && active && payerOf(lab.keys, live, r) && rigView(r, rigsOf(lab, r.id))); })); for (const b of document.querySelectorAll('[data-rule]')) b.setAttribute('aria-pressed', String((lab.mining?.rule ?? 'split') === b.dataset.rule)); $('mining-note').textContent = split ? "Every active holding mines: each beat's 50 tBTC splits by hashrate, set on each holding (0 sits out)." : "The holding behind the latest act or send takes the whole beat; with nothing cued, Beat now pays the one you're working in. At a timed tempo it's a race for the next beat."; $('holdings-empty').hidden = live.roots.length > 0; $('holdings-sub').textContent = live.roots.length ? String(live.roots.length) : ''; $('sample').disabled = live.roots.some(r => r.name === SAMPLE && r.status === 'active'); } function renderTree() { const box = $('tree-rows'), root = node(ui.holding); $('tree-sub').textContent = root ? `${root.name} · ${ui.beat === null ? 'now' : `beat ${ui.beat} of ${tip()}`}` : ''; if (!root) return box.replaceChildren(h('p', { class: 'wait', text: 'No holding yet. Found one, or load the yolopreneur sample.' })); if (!view.nodes.has(root.id)) return box.replaceChildren(h('p', { class: 'wait', text: `${root.name} is founded at ${at(root.born)}. Move the slider forward.` })); const focused = box.contains(document.activeElement) ? document.activeElement.dataset.id : null; box.replaceChildren(...rows(view, root.id).map(r => (r.type === 'group' ? h('div', { class: 'row grp' }, h('span', { class: 'g', 'aria-hidden': 'true', text: r.guide }), h('span', { class: 'gl', text: r.label })) : treeRow(r)))); if (focused) box.querySelector(`[data-id="${focused}"]`)?.focus(); } function treeRow({ node: n, guide, path }) { const on = n.id === ui.node; const cls = ['row', on && 'on', n.status === 'closed' && 'gone', n.born === 'cued' && 'cue'].filter(Boolean).join(' '); return h('div', { class: cls }, h('button', { type: 'button', class: 'sel', 'data-id': n.id, 'aria-pressed': String(on), onclick: () => pick({ node: n.id }) }, h('span', { class: 'g', 'aria-hidden': 'true', text: guide }), h('span', { class: 'p', text: `${path} ` }), h('span', { class: 'l', text: n.name }), ' ', h('span', { class: 'v' }, marks(n)))); } function marks(n) { const list = [ n.kind === 'agent' && h('span', { class: 'mk', text: 'agent' }), n.edge === 'adopted' && h('span', { class: 'mk ok', text: 'graduated' }), n.status === 'closed' && h('span', { class: 'mk bad', text: 'closed' }), n.born === 'cued' && h('span', { class: 'mk', text: 'cued' }), ...n.badges.map(belt), purse(n.id) > 0 && h('span', { class: 'mk sats', title: `${btc(purse(n.id))} at its SegWit address`, text: brief(purse(n.id)) }), ].filter(Boolean); return list.flatMap((mark, i) => (i ? [' ', mark] : [mark])); // spaces keep accessible names readable } const belt = b => h('span', { class: `belt ${b.belt}`, title: `${b.skill}: ${b.belt} belt` }, b.skill, h('span', { class: 'sr', text: ` (${b.belt} belt)` })); const below = (x, n) => { for (let p = node(x.parent); p; p = node(p.parent)) if (p.id === n.id) return true; return false; }; // Who n can award: anyone else active in the holding, its own seats first (a belt is one skill for one seat). const recipients = n => [...live.nodes.values()].filter(x => x.root === n.root && x.id !== n.id && x.status === 'active') .map(x => [x, (below(x, n) ? 0 : 2) + (x.kind === 'agent' ? 0 : 1)]).sort((a, b) => a[1] - b[1]).map(([x]) => x); const derivedBelow = n => n.children.reduce((sum, id) => { const c = node(id); return c && c.edge === 'derived' && c.status === 'active' ? sum + 1 + derivedBelow(c) : sum; }, 0); // A holding's hashrate dial: 0 to 9; each beat splits by it. function rigView(r, n) { const set = async next => { if (await run(l => setRigs(l, r.id, next))) say(next ? `${r.name} mines at hashrate ${next}.` : `${r.name} sits out of mining.`); }; return h('span', { class: 'rig' }, h('span', { class: 'rl', text: 'hashrate' }), h('span', { class: 'rn' }, h('button', { type: 'button', class: 'rb', 'aria-label': `Lower ${r.name}'s hashrate`, disabled: n <= 0, onclick: () => set(n - 1) }, '−'), h('b', { text: String(n) }), h('button', { type: 'button', class: 'rb', 'aria-label': `Raise ${r.name}'s hashrate`, disabled: n >= 9, onclick: () => set(n + 1) }, '+'))); } // Who n can send to: anyone active in the lab, its own holding first, its own subtree first within that. const everyone = n => [...live.nodes.values()].filter(x => x.id !== n.id && x.status === 'active') .map(x => [x, (x.root === n.root ? 0 : 4) + (below(x, n) ? 0 : 2) + (x.kind === 'agent' ? 0 : 1)]).sort((a, b) => a[1] - b[1]).map(([x]) => x); // Why n cannot send now ('' when it can). function sendBlock(n) { const wallet = payerOf(lab.keys, live, n), parent = node(n.parent); if (!wallet) return 'This holding predates lab keys, so it has no SegWit address to send from'; if (most(lab.chain.coins(wallet.script)) < DUST) { return parent ? `${n.name} has no test BTC to send yet. ${parent.name} can send some` : `${n.name} has no test BTC yet. It earns some each beat it mines`; } return everyone(n).length ? '' : 'There is nobody else to send to yet'; } // Why a verb is unavailable for this node ('' when it is available). The reducer has the final word. function blocked(n) { const parent = node(n.parent), closed = n.status === 'closed' ? `${n.name} is closed` : ''; const gas = (op, by = n.id, fields = {}) => gasCheck(lab, live, op, by, fields); return { birth: closed || (n.kind === 'agent' ? 'Agents do not give birth' : gas('birth')), award: closed || (n.kind === 'agent' ? 'Agents do not award belts' : recipients(n).length ? gas('award') : 'There is nobody else in this holding to award yet'), decide: closed || gas('decide'), send: closed || sendBlock(n), graduate: closed || (!parent ? 'A holding has no parent to graduate from' : n.kind !== 'org' ? 'Only orgs graduate' : n.edge === 'adopted' ? `${n.name} has already graduated` : parent.status !== 'active' ? `${parent.name} is closed` : gas('graduate', parent.id, { of: n.id })), close: closed || gas('close'), }; } function renderAct() { const n = node(ui.node), past = ui.beat !== null; $('act-none').hidden = !!n; $('act-past').hidden = !n || !past; $('act-on').hidden = !n || past; $('act-sub').textContent = n?.name ?? ''; $('past-beat').textContent = String(ui.beat ?? ''); if (!n || past) return; const why = blocked(n), parent = node(n.parent); for (const b of document.querySelectorAll('[data-verb]')) { b.setAttribute('aria-pressed', String(b.dataset.verb === ui.verb)); b.classList.toggle('off', !!why[b.dataset.verb]); } for (const form of document.querySelectorAll('form[data-form]')) form.hidden = form.dataset.form !== ui.verb || !!why[ui.verb]; $('act-why').textContent = why[ui.verb] ? `${why[ui.verb]}.` : ''; for (const el of document.querySelectorAll('#act-on [data-name]')) el.textContent = n.name; for (const el of document.querySelectorAll('#act-on [data-parent]')) el.textContent = parent?.name ?? ''; const sig = list => list.map(o => `${o.value}=${o.textContent}`).join('|'); for (const [to, list] of [[$('award-to'), recipients(n)], [$('send-to'), everyone(n)]]) { const keep = to.value, options = list.map(x => h('option', { value: x.id, text: `${trail(live, x.id)}${x.kind === 'agent' ? ' (agent)' : ''}` })); if (sig(options) !== sig([...to.options])) { to.replaceChildren(...options); if (options.some(o => o.value === keep)) to.value = keep; } } $('send-has').textContent = btc(held(n.id)); $('graduate-go').textContent = `Graduate ${n.name}`; const signer = ui.verb === 'graduate' ? parent : n, k = signer && keyOf(lab.keys, live, signer); const path = k && (ui.verb === 'send' ? `${k.from} · ${payerOf(lab.keys, live, signer).path}` : k.path); $('act-key').textContent = !signer || why[ui.verb] ? '' : k ? `Signs as ${signer.name}: ${path}` : 'Signs with the lab wallet: this holding predates lab keys.'; const moving = ui.verb === 'graduate' && keyOf(lab.keys, live, n) ? derivedSubtree(live, n.id).length : 0; $('act-gas').textContent = !signer || why[ui.verb] || !k ? '' : ui.verb === 'send' ? `Fee: ${FEE} sats, from ${n.name}'s balance.` : `Gas: ${gasCost(ui.verb, moving).toLocaleString('en-US')} sats from ${signer.name}'s tank (${btc(tankOf(lab.chain, k.wallet))}), which tops up from its balance.${moving ? ` ${n.name} pays ${FEE} more, from its own money, to claim its new root.` : ''}`; 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.` : `Closing ${n.name} spends its inscription for the last time. Nothing can act through it afterwards.`; $('close-go').textContent = arming ? `Yes, close ${n.name}` : `Close ${n.name}`; $('close-keep').hidden = !arming; } function renderInspect() { const box = $('inspect-body'), e = ui.event ? live.log.find(x => x.id === ui.event) : null; if (e) { $('inspect-sub').textContent = 'event'; return box.replaceChildren(...eventView(e)); } const m = ui.tx ? money.ledger.find(x => x.key === ui.tx) : null; if (m) { $('inspect-sub').textContent = 'money'; return box.replaceChildren(...txView(m)); } const known = node(ui.node); $('inspect-sub').textContent = known?.kind ?? ''; if (!known) return box.replaceChildren(h('p', { class: 'note pad', text: 'Select a node in the tree, or an event in the timeline.' })); const n = node(ui.node, view); if (!n) return box.replaceChildren(h('p', { class: 'note pad', text: `${known.name} is not born yet at beat ${ui.beat}.` })); box.replaceChildren(...nodeView(n)); } const fact = ([k, v, note]) => h('div', { class: 'f' }, h('dt', { text: k }), h('dd', {}, v, note && h('span', { class: 'fn', text: note }))); function nodeView(n) { const parent = node(n.parent, view); const facts = [ ['Born', n.born === 'cued' ? 'cued for the next beat' : `beat ${n.born}`], parent && ['Parent', `${parent.name} · ${n.kind} ${n.index}'`], 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'], payerOf(lab.keys, view, n) && ['Balance', btc(purse(n.id)), `Test BTC at its SegWit address${ui.beat === null ? '' : `, as of beat ${ui.beat}`}.`], ui.beat === null && keyOf(lab.keys, live, n) && ['Gas tank', btc(tankOf(lab.chain, keyOf(lab.keys, live, n).wallet)), 'Plain coins at its Taproot key. Its acts burn their fees from here, and the tank tops up from its balance, 0.0001 tBTC at a time.'], ['Inscription', n.id], ].filter(Boolean); return [ h('div', { class: 'ih' }, h('p', { class: 'eyebrow', text: trail(view, n.id) }), h('h3', { class: 'it', text: n.name }), h('div', { class: 'chips' }, h('span', { class: 'chip', text: n.kind }), h('span', { class: `chip ${n.status === 'active' ? 'ok' : 'bad'}`, text: n.status }), n.edge === 'adopted' && h('span', { class: 'chip ok', text: 'graduated' }), n.born === 'cued' && h('span', { class: 'chip', text: 'cued' }))), h('div', { class: 'ib' }, h('dl', { class: 'fl' }, facts.map(fact)), n.badges.length > 0 && h('div', {}, h('p', { class: 'lab', text: 'Belts' }), h('ul', { class: 'list' }, n.badges.map(b => h('li', {}, belt(b), h('span', { class: 'when', text: ` from ${node(b.by)?.name ?? '?'}, ${at(b.beat)}` }))))), n.decisions.length > 0 && h('div', {}, h('p', { class: 'lab', text: 'Decisions' }), h('ul', { class: 'list' }, n.decisions.map(d => h('li', {}, h('b', { text: d.title }), d.why && ` · ${d.why}`, h('span', { class: 'when', text: ` (${at(d.beat)})` }))))), moneyView(n), keysView(n)), ]; } // A node's latest money moves, as of the viewed beat. function moneyView(n) { const list = money.ledger.filter(m => (m.to === n.id || m.from === n.id) && (ui.beat === null || (m.beat !== null && m.beat <= ui.beat))).slice(-6).reverse(); return list.length > 0 && h('div', {}, h('p', { class: 'lab', text: 'Money' }), h('ul', { class: 'list' }, list.map(m => h('li', {}, h('b', { text: flow(m, n.id) }), h('span', { class: 'when', text: ` (${at(m.beat ?? 'cued')})` }))))); } // One money move as the node with this id sees it. function flow(m, id) { const name = x => node(x)?.name ?? '?'; if (m.kind === 'mine') return `+${btc(m.amount)}, a beat's reward`; if (m.kind === 'move') return `${btc(m.amount)} moved to its new key, after a ${m.fee}-sat fee`; if (m.kind === 'gas') return `−${btc(m.amount)} into its gas tank, and a ${m.fee}-sat fee`; return m.to === id ? `+${btc(m.amount)} from ${name(m.from)}` : `−${btc(m.amount)} to ${name(m.to)}, and a ${m.fee}-sat fee`; } // "40 tBTC yolopreneur → time" function moneyLine(m) { const name = id => { const x = node(id); return !x ? '?' : x.root === ui.holding ? x.name : trail(live, id); }; if (m.kind === 'mine') return `${btc(m.amount)} → ${name(m.to)}`; if (m.kind === 'move') return `${btc(m.amount)} → ${name(m.to)}'s new key`; if (m.kind === 'gas') return `${btc(m.amount)} → ${name(m.to)}'s gas tank`; return `${btc(m.amount)} ${name(m.from)} → ${name(m.to)}`; } // Lineage's keys for a node as of the viewed beat: its Taproot, SegWit and nostr addresses, where they come from, who // holds its inscription now, and its leaves with the public lines to paste (the ones the lineage key tree page derives). 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' }; const LEAF = { ssh: 'shell access, certified by the CA', git: 'commit signing', dkim: 'mail signing', wg: 'WireGuard peer key', 'ssh-ca': 'SSH certificate authority', mtls: 'TLS identity between nodes', noise: 'Headscale noise key' }; function keysView(n) { const k = keyOf(lab.keys, view, n), holder = holderOf(lab, live, n.id); const held = ['Held now by', holder ? HELD[holder.who] : 'nobody: its inscription has been spent']; if (!k) return h('div', { class: 'keys' }, h('p', { class: 'lab', text: 'Keys' }), h('dl', { class: 'fl' }, [['Signs with', 'the lab wallet: this holding predates lab keys, so it has no addresses, leaves or secrets of its own'], held].map(fact))); const agent = n.kind === 'agent', a = addressesOf(lab.keys, view, n), leaves = leavesOf(lab.keys, view, n); const lines = leaves.map(([, , line]) => line).filter(Boolean), reset = 'Reset the lab for standard BIP-86 keys.'; const holds = agent ? "Holds this seat's inscription, and inscriptions sent to it land here." : "Holds this org's inscription and signs its acts. Inscriptions sent to it land here."; const taproot = { bip86: `BIP-86 at ${a.taproot.path}. ${holds}`, tapkey: `Its npub's key with the BIP-86 tweak, the way v1.2 keyed this lab. ${holds} ${reset}`, wallet: `An untweaked key at ${a.taproot.path}, the way v1.1 keyed this lab. ${holds} ${reset}` }[k.scheme]; const facts = [['Taproot', a.taproot.address, taproot], ['SegWit', a.segwit.address, `BIP-84 at ${a.segwit.path}. Its money: ${n.kind === 'holding' ? 'the beats it earns land here, and ' : ''}sends come and go here.`], ['Tapkey', a.tapkey.address, 'Its npub as a Taproot address (its nostr key with the BIP-86 tweak), so the npub alone finds it. Nostr secrets get pasted into clients, so treat it as an inbox.'], ['npub', a.npub.address, `NIP-06 at ${a.npub.path}. Its nostr name; the leaves below hang off it.`], ['From', `${k.from} · account ${k.account}`], held]; return h('div', { class: 'keys' }, h('p', { class: 'lab', text: 'Keys' }), h('dl', { class: 'fl' }, facts.map(fact)), h('p', { class: 'lab' }, 'Leaves', h('span', { text: ` · HKDF-SHA256 from its ${agent ? 'nostr secret' : 'seed'}` })), h('ul', { class: 'list leaves' }, leaves.map(([label, value]) => h('li', {}, h('b', { text: label }), h('span', { class: 'when', text: ` ${LEAF[label]}` }), h('code', { text: value })))), copyBlock(agent ? 'Public lines to paste' : 'CA key for TrustedUserCAKeys', lines.join('\n\n')), secretsView(n, k)); } // A code block with a Copy button; shown can lay the text out differently from what is copied. function copyBlock(label, text, shown = text) { const pre = h('pre', { text: shown }); const button = h('button', { type: 'button', class: 'cp', 'aria-label': `Copy ${label}`, onclick: () => copy(text, button, pre) }, 'Copy'); return h('div', { class: 'code' }, h('div', { class: 'code-h cph' }, h('span', { text: label }), button), pre); } async function copy(text, button, pre) { const done = label => { button.textContent = label; setTimeout(() => { button.textContent = 'Copy'; }, 1400); }; try { await navigator.clipboard.writeText(text); done('Copied'); } catch { getSelection().selectAllChildren(pre); done('Selected'); } } // A node's secrets behind Reveal, as on the lineage key tree page: hidden again whenever the selection changes. function secretsView(n, k) { const open = ui.reveal === n.id, s = open && secretsOf(lab.keys, view, n), org = node(n.parent, view); const toggle = () => { ui.reveal = open ? null : n.id; renderInspect(); $('reveal')?.focus(); }; const words = s?.seed?.mnemonic.split(' '); return h('div', { class: 'secret' }, h('div', { class: 'hz' }, h('span', { class: 'hz-l', text: 'Secret · stays in this tab' }), h('button', { type: 'button', class: 'btn sm', id: 'reveal', 'aria-expanded': String(open), onclick: toggle }, open ? 'Hide' : 'Reveal')), s && h('div', { class: 'sb' }, s.seed ? copyBlock(`Seed · ${s.seed.from}`, s.seed.mnemonic, words.map((w, i) => `${String(i + 1).padStart(2, '0')} ${w}`).reduce((rows, w, i) => (i % 3 ? rows[rows.length - 1].push(w) : rows.push([w]), rows), []).map(r => r.join(' ')).join('\n')) : h('p', { class: 'note', text: `A seat has no seed of its own: these are account ${k.account} of ${org?.name ?? 'its org'}'s seed.` }), s.account.map(([label, kind, text]) => copyBlock(`${label} · ${kind}`, text)), s.leaves.map(([label, title, text]) => copyBlock(`${label} · ${title}`, text)))); } // 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'], ['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 }; return [ h('div', { class: 'ih' }, h('p', { class: 'eyebrow', text: `${e.op} · ${e.pending ? 'cued' : `beat ${e.beat}`}` }), h('h3', { class: 'it', text: summary(e, live) }), h('div', { class: 'chips' }, h('span', { class: 'chip', text: raw?.protocol ?? '?' }), h('span', { class: `chip ${e.ok ? 'ok' : 'bad'}`, text: e.ok ? 'applied' : 'rejected' })), !e.ok && h('p', { class: 'idesc', text: 'The reducer rejected this event. It stays on the chain, but it does not change the tree.' })), h('div', { class: 'ib' }, h('dl', { class: 'fl' }, facts.map(fact)), h('div', { class: 'code' }, h('p', { class: 'code-h', text: 'Metadata · CBOR, tag 5' }), h('pre', { text: json(e.meta) })), entry && h('div', { class: 'code' }, h('p', { class: 'code-h', text: 'Raw index entry' }), h('pre', { text: json(entry) }))), ]; } // A money move: a beat's reward, a send, or money following a node to its new keys. function txView(m) { const name = id => node(id)?.name ?? 'a key outside this lab', record = lab.chain.transactions.get(m.txid); const facts = [ ['When', m.beat === null ? 'cued for the next beat' : `beat ${m.beat}`], ['From', m.kind === 'mine' ? "its share of the beat's block reward (the coinbase)" : `${name(m.from)}'s SegWit address${m.kind === 'move' ? ', from before it graduated' : ''}`], ['To', m.kind === 'gas' ? `${name(m.to)}'s gas tank, at its Taproot key` : `${name(m.to)}'s ${m.kind === 'move' ? 'new ' : ''}SegWit address`], ['Amount', btc(m.amount)], m.kind !== 'mine' && ['Fee', `${m.fee} sats, retired: this toy chain pays no fees to miners`], m.change > 0 && ['Change', `${btc(m.change)} back to ${name(m.from)}`], ['Signed by', m.kind === 'mine' ? 'nobody: a coinbase spends nothing' : `${name(m.from)}'s SegWit key (BIP-143, SIGHASH_ALL)`], ['Transaction', m.txid], ].filter(Boolean); return [ h('div', { class: 'ih' }, h('p', { class: 'eyebrow', text: `${m.kind} · ${m.beat === null ? 'cued' : `beat ${m.beat}`}` }), h('h3', { class: 'it', text: moneyLine(m) }), h('div', { class: 'chips' }, h('span', { class: 'chip', text: 'test BTC' }), h('span', { class: `chip${m.beat === null ? '' : ' ok'}`, text: m.beat === null ? 'cued' : 'confirmed' }))), h('div', { class: 'ib' }, h('dl', { class: 'fl' }, facts.map(fact)), record && copyBlock('Raw transaction', record.raw)), ]; } function renderTimeline() { const t = tip(), slider = $('beat'), root = node(ui.holding); slider.max = String(t); slider.value = String(ui.beat ?? t); slider.disabled = t === 0; $('beat-label').textContent = ui.beat === null ? `Now · beat ${t}` : `Beat ${ui.beat} of ${t}`; $('replay').textContent = ui.replay ? 'Stop' : 'Replay'; $('replay').disabled = !root || t === 0; $('to-now').disabled = ui.beat === null; $('timeline-sub').textContent = root?.name ?? ''; const groups = new Map(), order = new Map([...lab.chain.transactions.keys()].map((txid, i) => [txid, i])); const add = (k, item) => groups.set(k, [...(groups.get(k) ?? []), item]); for (const e of live.log) if (e.root && e.root === ui.holding) add(e.pending ? 'cued' : e.beat, { e, at: order.get(e.id.slice(0, -2)) ?? 0 }); for (const m of money.ledger) if ([m.to, m.from].some(id => id && node(id)?.root === ui.holding)) add(m.beat ?? 'cued', { m, at: order.get(m.txid) ?? 0 }); const keys = [...groups.keys()].sort((a, b) => (a === 'cued' ? -1 : b === 'cued' ? 1 : b - a)); if (!keys.length) return $('timeline-list').replaceChildren(h('li', { class: 'wait', text: root ? 'Nothing yet.' : 'Found a holding to start its timeline.' })); $('timeline-list').replaceChildren(...keys.map(k => { const block = k === 'cued' ? null : lab.chain.blocks[k - 1]; const later = ui.beat !== null && (k === 'cued' || k > ui.beat), current = ui.beat !== null && k === ui.beat; return h('li', { class: `beat${later ? ' later' : ''}${current ? ' now' : ''}` }, h('p', { class: 'bh' }, h('span', { class: 'bn', text: k === 'cued' ? 'Cued' : `Beat ${k}` }), h('span', { class: 'bt', text: block ? clockTime(block.time) : lab.clock.tempo === 'manual' ? 'waiting for Beat now' : 'landing on the next beat' })), h('ol', { class: 'evs' }, groups.get(k).sort((a, b) => a.at - b.at).map(({ e, m }) => (m ? moneyItem(m) : eventItem(e))))); })); } function eventItem(e) { const [verb, ...rest] = summary(e, live).split(' '); return h('li', {}, h('button', { type: 'button', class: `ev${e.ok ? '' : ' bad'}${e.id === ui.event ? ' on' : ''}`, 'aria-pressed': String(e.id === ui.event), onclick: () => pick({ event: e.id, node: e.node ?? ui.node }) }, h('span', { class: 'op', text: e.ok ? verb : `✕ ${verb.replace(/:$/, '')}` }), ' ', h('span', { class: 'es', text: rest.join(' ') }))); } const moneyItem = m => h('li', {}, h('button', { type: 'button', class: `ev money${m.kind === 'gas' ? ' gas' : ''}${m.key === ui.tx ? ' on' : ''}`, 'aria-pressed': String(m.key === ui.tx), onclick: () => pick({ tx: m.key, node: node(m.to)?.root === ui.holding ? m.to : ui.node }) }, h('span', { class: 'op', text: m.kind }), ' ', h('span', { class: 'es', text: moneyLine(m) }))); // Verbs $('f-found').addEventListener('submit', async event => { event.preventDefault(); const form = event.currentTarget, name = slug(form.elements.name.value); if (!name) return say('Name the holding: lowercase letters, digits and hyphens.', true); if (await perform('found', null, { name }, (id, where) => { select({ holding: id, node: id }); return `${name} is founded, ${where}.`; })) form.reset(); }); $('sample').addEventListener('click', async () => { let id = null; say('Rehearsing the yolopreneur tree over six beats…'); if (!(await run(l => { id = seedSample(l); }))) return; pick({ holding: id, node: id }); say('Loaded the yolopreneur sample: six beats of history. Press Replay to watch it grow.'); }); $('f-birth').addEventListener('submit', async event => { event.preventDefault(); const form = event.currentTarget, n = node(ui.node), name = slug(form.elements.name.value), kind = form.elements.kind.value; if (!name) return say('Name the child: lowercase letters, digits and hyphens.', true); if (await perform('birth', n.id, { name, kind }, (id, where) => `${name} is born to ${n.name}, ${where}.`)) form.elements.name.value = ''; }); $('f-award').addEventListener('submit', async event => { event.preventDefault(); const form = event.currentTarget, n = node(ui.node), to = form.elements.to.value, skill = slug(form.elements.skill.value), level = form.elements.belt.value; if (!skill) return say('Name the skill, for example launch.', true); if (await perform('award', n.id, { to, skill, belt: level }, (id, where) => `${node(to)?.name} holds a ${level} belt in ${skill}, ${where}.`)) form.elements.skill.value = ''; }); $('f-decide').addEventListener('submit', async event => { event.preventDefault(); const form = event.currentTarget, n = node(ui.node), title = form.elements.title.value.trim(), why = form.elements.why.value.trim(); if (!title) return say('A decision needs a title.', true); if (await perform('decide', n.id, { title, why }, (id, where) => `${n.name} decided “${title}”, ${where}.`)) form.reset(); }); $('f-send').addEventListener('submit', async event => { event.preventDefault(); const form = event.currentTarget, n = node(ui.node), to = form.elements.to.value, amount = satsOf(form.elements.amount.value); if (amount === null) return say('Write an amount in test BTC, for example 1.5 (at most eight decimals).', true); let txid = null; if (!(await run(l => { txid = send(l, n.id, to, amount); }))) return; const m = money.ledger.find(x => x.txid === txid); say(`${n.name} sent ${btc(amount)} to ${node(to)?.root === n.root ? node(to).name : trail(live, to)}, ${m?.beat ? `at beat ${m.beat}` : 'cued for the next beat'}.`); form.elements.amount.value = ''; }); $('send-max').addEventListener('click', () => { const n = node(ui.node), wallet = n && payerOf(lab.keys, live, n); if (wallet) $('send-amount').value = coins(most(lab.chain.coins(wallet.script))); }); $('f-graduate').addEventListener('submit', async event => { event.preventDefault(); const n = node(ui.node), parent = node(n.parent); await perform('graduate', parent.id, { of: n.id }, (id, where) => `${n.name} graduates from ${parent.name}, ${where}. Its edge is adopted now.`); }); $('f-close').addEventListener('submit', async event => { event.preventDefault(); const n = node(ui.node); if (ui.closing !== n.id) { ui.closing = n.id; renderAct(); return $('close-go').focus(); } ui.closing = null; await perform('close', n.id, {}, (id, where) => `${n.name} is closed, ${where}.`); }); $('close-keep').addEventListener('click', () => { ui.closing = null; renderAct(); $('close-go').focus(); }); for (const b of document.querySelectorAll('[data-verb]')) b.addEventListener('click', () => { ui.verb = b.dataset.verb; ui.closing = null; renderAct(); }); // Mining: split by hashrate, or the last act takes the beat for (const b of document.querySelectorAll('[data-rule]')) b.addEventListener('click', async () => { const rule = b.dataset.rule; if (await run(l => setRule(l, rule))) say(rule === 'split' ? 'Each beat splits across the holdings that mine, by hashrate.' : 'Each beat goes whole to the holding that acts.'); }); // Time: the tempo, Beat now, and the clock that lands cued acts when their beat is due for (const b of document.querySelectorAll('[data-tempo]')) b.addEventListener('click', async () => { const value = b.dataset.tempo, tempo = /^\d+$/.test(value) ? Number(value) : value; if (!(await run(l => setTempo(l, tempo, Date.now(), minerOf(l, lastHolding(l) ?? ui.holding))))) return; say(value === 'each' ? 'Each act is its own beat.' : value === 'manual' ? 'Acts are cued until you press Beat now.' : `Acts are cued and land together on the next beat, at most ${value} seconds after the first.`); }); $('beat-now').addEventListener('click', () => landCue(l => { beat(l, minerOf(l, lastHolding(l) ?? ui.holding)); })); setInterval(() => { renderClock(); const { tempo, nextAt } = lab.clock; if (ui.busy || document.hidden || typeof tempo !== 'number' || !lab.chain.mempool.length || (nextAt && Date.now() < nextAt)) return; landCue(l => tick(l, Date.now(), minerOf(l, lastHolding(l) ?? ui.holding))); }, 1000); addEventListener('storage', event => { if (event.key === LAB_KEY || event.key === null) load(); }); // The timeline: any beat, a replay, and back to now function stopReplay() { if (ui.replay) { clearInterval(ui.replay); ui.replay = null; renderTimeline(); } } function startReplay() { const root = node(ui.holding); if (!root || !tip()) return; setBeat(typeof root.born === 'number' ? root.born : tip()); if (ui.beat === null) return say('Nothing to replay yet: this holding has one beat of history.'); ui.replay = setInterval(() => { if (ui.beat === null || ui.beat + 1 >= tip()) stopReplay(); setBeat(ui.beat === null ? tip() : ui.beat + 1); }, 800); renderTimeline(); } $('beat').addEventListener('input', event => { stopReplay(); setBeat(Number(event.target.value)); }); $('replay').addEventListener('click', () => (ui.replay ? stopReplay() : startReplay())); for (const id of ['to-now', 'past-now']) $(id).addEventListener('click', () => { stopReplay(); setBeat(tip()); }); // Theme: system, light, dark (public/theme.js applies the saved choice before first paint) const THEMES = ['system', 'light', 'dark']; const showTheme = () => { $('theme').textContent = `Theme: ${document.documentElement.dataset.theme ?? 'system'}`; }; $('theme').addEventListener('click', () => { const next = THEMES[(THEMES.indexOf(document.documentElement.dataset.theme ?? 'system') + 1) % THEMES.length]; if (next === 'system') delete document.documentElement.dataset.theme; else document.documentElement.dataset.theme = next; try { if (next === 'system') localStorage.removeItem('orgchain-theme'); else localStorage.setItem('orgchain-theme', next); } catch { /* not remembered */ } showTheme(); }); // The lab: save, load and reset function confirmBox(text, label, onYes) { const box = $('confirm'); box.replaceChildren(h('span', { text }), h('button', { type: 'button', class: 'btn sm danger', text: label, onclick: () => { box.hidden = true; onYes(); } }), h('button', { type: 'button', class: 'btn sm', text: 'Cancel', onclick: () => { box.hidden = true; } })); box.hidden = false; box.querySelector('button').focus(); } const forgiving = { read: () => { try { return readLab(); } catch { return emptyLab(); } } }; async function replaceLab(next, message) { stopReplay(); if (!(await run(l => { Object.assign(l, next()); }, forgiving))) return; Object.assign(ui, { holding: null, node: null, event: null, tx: null, beat: null, closing: null }); derive(); say(message); } $('save').addEventListener('click', () => { const link = h('a', { href: URL.createObjectURL(new Blob([encodeLab(lab)], { type: 'application/json' })), download: `orgchain-lab-${new Date().toISOString().slice(0, 10)}.json` }); document.body.append(link); link.click(); link.remove(); setTimeout(() => URL.revokeObjectURL(link.href), 5000); say('Saved a backup of this lab, test wallet included. Load it in any browser to carry on.'); }); $('load').addEventListener('click', () => $('load-file').click()); $('load-file').addEventListener('change', async event => { const file = event.target.files?.[0]; event.target.value = ''; if (!file) return; let next; try { next = decodeLab(await file.text()); } catch (error) { return say(`${file.name} is not an Orgchain lab: ${error.message}`, true); } confirmBox(`Replace this browser's lab with ${file.name}?`, 'Replace', () => replaceLab(() => next, `Loaded ${file.name}.`)); }); $('reset').addEventListener('click', () => confirmBox("Erase this browser's lab: every holding, the chain and the test wallet?", 'Erase', () => replaceLab(emptyLab, 'Erased. This is a fresh lab.'))); showTheme(); load();