Files
orgchain/src/main.js
T
teahubxyzandClaude Opus 5.5 32e8131500 Orgchain v1.6.1: under Last act, a timed beat goes to whoever acted last
A timed or manual beat used to pay the holding being worked in. Now it pays
the holding behind the newest cued act or send; with nothing cued, Beat now
still pays the one being worked in. At a timed tempo, holdings race for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4upF5LVwmPDDqrhdUfnXh
2026-10-14 23:21:33 -07:00

600 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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();