Files
orgchain/src/main.js
T

599 lines
42 KiB
JavaScript
Raw Normal View History

// 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));
}
2026-10-14 19:09:50 -07:00
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 =>
2026-10-14 19:09:50 -07:00
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' };
2026-10-14 19:09:50 -07:00
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);
2026-10-14 19:09:50 -07:00
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];
2026-10-14 19:09:50 -07:00
return h('div', { class: 'keys' },
h('p', { class: 'lab', text: 'Keys' }),
h('dl', { class: 'fl' }, facts.map(fact)),
2026-10-14 19:09:50 -07:00
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();