diff --git a/AGENTS.md b/AGENTS.md
index cd927d5..0db4721 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -6,11 +6,14 @@ Orgchain is lineage's app for creating and operating lineage holdings on Super T
- Lineage semantics live in one place, `src/orgchain/lineage.js`. The UI never interprets events itself, and every act
is vetted by that reducer before it is signed. A new op means a reducer change, its tests and the README's table.
- The engine (`src/adapters`, `src/upstream`, `src/storage.js`, `src/miner.js`) is Super Testnet's. Compose with it
- rather than rewriting it, and keep its tests passing. Preserve upstream licenses and provenance.
+ rather than rewriting it, and keep its tests passing. Preserve upstream licenses and provenance. Its one local
+ change is P2WPKH payments in `src/adapters/chain.js` (PROVENANCE.md); keep it small and tested.
+- Money lives in `src/orgchain/money.js`: beats pay the holding being worked in, and sends stay inside a holding and
+ spend SegWit coins only, so an inscription can never leave as change. Balances are read off the chain, never stored.
- Keys follow the tree (`src/orgchain/keys.js`): store roots only (one per holding, one per graduated org) and derive
every other key from its position. Every act is signed by whichever key holds the authorizing inscription.
- A node's chain key is its account's BIP-86 Taproot key (`m/86'/1'/account'/0/0`); SegWit is BIP-84 at the same
- account. Leaves are HKDF-SHA256 (salt `lineage/v0`) and must stay byte for byte with lineage's key tree page;
+ account and holds its money. Leaves are HKDF-SHA256 (salt `lineage/v0`) and must stay byte for byte with lineage's key tree page;
`tests/keys.test.js` holds values read off that page.
- Secrets show only behind Reveal, reset on every selection, and match the key tree page byte for byte (the tests pin
hashes of the page's own output).
diff --git a/PROVENANCE.md b/PROVENANCE.md
index 28effeb..2ba5c79 100644
--- a/PROVENANCE.md
+++ b/PROVENANCE.md
@@ -13,13 +13,15 @@ The upstream CC0 legal text is retained unchanged at [`public/licenses/supertest
Runtime dependencies are pinned in `package.json` and `package-lock.json`:
-- **@cmdcode/tapscript 1.5.3**, Christopher Scott, [upstream](https://github.com/cmdruid/tapscript), CC-BY-1.0. Used for transaction encoding, signing/verification, Taproot commitments and addresses. Installed package license retained unchanged at [`public/licenses/tapscript.txt`](public/licenses/tapscript.txt). Library source is unmodified; Vite bundles/minifies it for the browser.
-- **@noble/curves 1.9.7**, Paul Miller and contributors, [upstream](https://github.com/paulmillr/noble-curves), MIT. Used for key generation/validation and public keys: secp256k1 for chain and nostr keys, Ed25519 and X25519 for lineage's leaves. License retained at [`public/licenses/noble-curves.txt`](public/licenses/noble-curves.txt).
-- **@noble/hashes 1.8.0**, Paul Miller and contributors, [upstream](https://github.com/paulmillr/noble-hashes), MIT. Used for synthetic block hashes, BIP-85's HMAC and the leaves' HKDF-SHA256. License retained at [`public/licenses/noble-hashes.txt`](public/licenses/noble-hashes.txt).
+- **@cmdcode/tapscript 1.5.3**, Christopher Scott, [upstream](https://github.com/cmdruid/tapscript), CC-BY-1.0. Used for transaction encoding, signing/verification, Taproot commitments and addresses, and the BIP-143 digests and signatures of SegWit payments. Installed package license retained unchanged at [`public/licenses/tapscript.txt`](public/licenses/tapscript.txt). Library source is unmodified; Vite bundles/minifies it for the browser.
+- **@noble/curves 1.9.7**, Paul Miller and contributors, [upstream](https://github.com/paulmillr/noble-curves), MIT. Used for key generation/validation and public keys, and to verify the low-S DER signatures of SegWit payments: secp256k1 for chain and nostr keys, Ed25519 and X25519 for lineage's leaves. License retained at [`public/licenses/noble-curves.txt`](public/licenses/noble-curves.txt).
+- **@noble/hashes 1.8.0**, Paul Miller and contributors, [upstream](https://github.com/paulmillr/noble-hashes), MIT. Used for synthetic block hashes, BIP-85's HMAC, the leaves' HKDF-SHA256 and the RIPEMD-160 of P2WPKH key hashes. License retained at [`public/licenses/noble-hashes.txt`](public/licenses/noble-hashes.txt).
- **@scure/base 1.2.6**, Paul Miller, [upstream](https://github.com/paulmillr/scure-base), MIT. Used for bech32 npubs and base64 leaf keys. License retained at [`public/licenses/scure-base.txt`](public/licenses/scure-base.txt).
-- **@scure/bip32 1.7.0**, Patricio Palladino and Paul Miller, [upstream](https://github.com/paulmillr/scure-bip32), MIT. Used for HD derivation under the BIP-85, BIP-86 and NIP-06 paths. License retained at [`public/licenses/scure-bip32.txt`](public/licenses/scure-bip32.txt).
+- **@scure/bip32 1.7.0**, Patricio Palladino and Paul Miller, [upstream](https://github.com/paulmillr/scure-bip32), MIT. Used for HD derivation under the BIP-85, BIP-86, BIP-84 and NIP-06 paths. License retained at [`public/licenses/scure-bip32.txt`](public/licenses/scure-bip32.txt).
- **@scure/bip39 1.6.0**, Patricio Palladino and Paul Miller, [upstream](https://github.com/paulmillr/scure-bip39), MIT. Used for the 24-word lab roots and their seeds. License retained at [`public/licenses/scure-bip39.txt`](public/licenses/scure-bip39.txt).
+One local change to the engine (Orgchain v1.5): `src/adapters/chain.js` also accepts native SegWit (P2WPKH) outputs and SIGHASH_ALL P2WPKH spends, verified per BIP-143, so lineage accounts can hold and send test BTC at their BIP-84 addresses. Everything about inscriptions stays Taproot-only. The change is original code, tested in `tests/money.test.js`; Super Testnet's tests (`tests/adapters.test.js`) pass unchanged.
+
Vite and Playwright are build/test dependencies, not runtime network services. Their installed package notices remain in the locked dependency installation. No unlicensed upstream code was copied.
The bounded JSON/CBOR codec, byte-derived MetadataIndex, trusted event-counter reducer, local API facade and persisted auto-miner schedule are original adapter code. Metadata encoding and endpoint naming were checked against the [Ordinals metadata](https://docs.ordinals.com/inscriptions/metadata.html) and [endpoint](https://docs.ordinals.com/inscriptions/recursion.html) references; no ord source code was copied.
diff --git a/README.md b/README.md
index c3c0998..dbc49f4 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,13 @@ chain itself records who did what, and when.
- **Time.** A beat is a block. At the default tempo each act is its own beat; at 10, 30 or 60 seconds, acts are cued and
land together on the next beat; on manual they wait for *Beat now*. The slider and *Replay* show a holding as it
stood at any beat.
+- **Money.** Each beat pays its 50 test BTC block reward to the holding being worked in, at the holding's SegWit
+ address (a closed holding, or one that predates lab keys, leaves it to the lab wallet). **Send** moves test BTC from
+ any active node to another in its holding, signed by the sender's BIP-84 key: at most two coins in, at most 50 test
+ BTC out, and the toy chain's fixed 500-sat fee. Sends land like acts, at once or cued, and *Beat now* mines a beat
+ even when nothing is cued. A graduation moves each re-keyed node's money to its new address along with its
+ inscription. Balances follow the slider, the timeline lists every beat's reward and every send, and Inspect has the
+ exact amounts and the raw transaction (`src/orgchain/money.js`). Inscription fees still come from the lab wallet.
- **One interpretation.** `src/orgchain/lineage.js` folds `lineage/v0` events into trees, and vets every act before
anything is signed. Events it rejects stay on the chain and show as rejected; they just don't change the tree.
- **Keys.** Each holding gets its own 24-word lab root, and every node has lineage's keys at its place in the tree
@@ -31,7 +38,7 @@ chain itself records who did what, and when.
BIP-380 checksums), and its leaves' private keys (OpenSSH, DKIM, WireGuard, PKCS#8, Headscale). Hidden again on
every selection; lab keys only.
-Test coins and test keys only: a local chain, the lab wallet (the faucet that funds every key) and each holding's lab
+Test coins and test keys only: a local chain, the lab wallet (the faucet that funds every key's acts) and each holding's lab
root, kept in this browser (`localStorage`, key `orgchain:lab:v1`). Nothing here is a real key; minting a real root is
the operator's call.
@@ -55,7 +62,7 @@ reused.
```
npm ci
-npm test # engine, reducer and lab (node --test)
+npm test # engine, reducer, lab and money (node --test)
npm run build # dist/ is the site; relative paths, so it works at any sub-path
npm run test:e2e # Playwright on vite preview; PW_CHANNEL=chrome uses installed Chrome
```
@@ -68,8 +75,9 @@ Deploy: copy `dist/` to `ventures/lineage/orgchain/` on `teahubxyz/time` `gh-pag
## Built on Super Testnet
The chain, wallet, inscriptions, metadata and index (`src/adapters`, `src/upstream`, with `src/storage.js` and
-`src/miner.js`) are [Super Testnet](https://supertestnet.lol)'s, unchanged and with their tests
-(`tests/adapters.test.js`). See `PROVENANCE.md` and `public/licenses/`. Lineage's key derivation and the claim and
+`src/miner.js`) are [Super Testnet](https://supertestnet.lol)'s, with their tests (`tests/adapters.test.js`) and one
+addition: the chain also moves native SegWit (P2WPKH) payments, BIP-143 signatures included (`tests/money.test.js`).
+See `PROVENANCE.md` and `public/licenses/`. Lineage's key derivation and the claim and
countersign handoff (`src/adapters/lineage-keys.js`, `src/adapters/handoff.js`, with their spec vectors in
`tests/keys.test.js`) come from the `tree` branch. The full lab (explorer, delegates, recursion and
the Inscriboor extension) lives at supertestnet.lol.
diff --git a/index.html b/index.html
index 01b22a8..a1d3c70 100644
--- a/index.html
+++ b/index.html
@@ -4,7 +4,7 @@
Orgchain · Lineage
-
+
@@ -16,7 +16,7 @@
Lineage /
Orgchain
-
Found a holding , then birth its orgs and agents, award belts, record decisions, graduate and close. Each act is an inscription whose parent is the node that authorizes it, and each beat is a block. Lineage gives the structure; the chain adds time.
+
Found a holding , then birth its orgs and agents, award belts, record decisions, send test BTC, graduate and close. Each act is an inscription whose parent is the node that authorizes it, and each beat is a block that pays the holding 50 test BTC. Lineage gives the structure; the chain adds time.
Test coins only
@@ -32,8 +32,8 @@
Tempo
Each act 10s 30s 60s Manual
- Beat 0 · nothing cued
- Beat now
+ Beat 0 · nothing cued · beats pay the lab wallet
+ Beat now
View
@@ -76,7 +76,7 @@
Select a holding, or a node in its tree, to act through it.
You are looking at beat . History is read-only.
Back to now
-
Birth Award Decide Graduate Close
+
Birth Award Decide Send Graduate Close
+
+ sends test BTC from its SegWit address. It has .
+ To
+
+ Amount test BTC
+ Max
+ The sender pays the chain's 500-sat fee. One send spends at most two coins and moves at most 50 test BTC, one block reward.
+ Send
+
vouches for , and the edge flips from derived to adopted. A graduated org holds its own root, so it stays open if closes. The event spends 's inscription.
Graduate
diff --git a/src/adapters/chain.js b/src/adapters/chain.js
index 3bbf5fc..a4f734b 100644
--- a/src/adapters/chain.js
+++ b/src/adapters/chain.js
@@ -1,7 +1,9 @@
import { outputRanges, satAt, offsetOf } from './sats.js';
import { Script, Signer, Tx } from '@cmdcode/tapscript';
import { sha256 } from '@noble/hashes/sha2';
-import { bytesToHex } from '@noble/curves/abstract/utils';
+import { ripemd160 } from '@noble/hashes/legacy';
+import { secp256k1 } from '@noble/curves/secp256k1';
+import { bytesToHex, hexToBytes } from '@noble/curves/abstract/utils';
import { getVout } from '../upstream/primitives.js';
import { readEnvelope, POSTAGE, FEE } from './inscription.js';
import { NETWORK } from './wallet.js';
@@ -12,9 +14,18 @@ const hash = value => bytesToHex(sha256(new TextEncoder().encode(JSON.stringify(
const key = (txid, vout) => `${txid}:${vout}`;
const scriptHex = script => Script.encode(script, false).hex;
const assert = (condition, message) => { if (!condition) throw new Error(message); };
+// Orgchain's one addition to the engine: native SegWit (P2WPKH) outputs and SIGHASH_ALL spends (BIP-143), so lineage
+// accounts are paid at their BIP-84 addresses. Inscriptions, their parents and their handoffs stay Taproot.
+const TAPROOT = /^5120[0-9a-f]{64}$/, P2WPKH = /^0014[0-9a-f]{40}$/;
+const hash160 = hex => bytesToHex(ripemd160(sha256(hexToBytes(hex))));
+// The BIP-143 digest commits to the spent key's P2PKH script code and the coin's value; low-S DER only.
+function segwitValid(tx, i) {
+ const [sig, pubkey] = tx.vin[i].witness;
+ return secp256k1.verify(sig.slice(0, -2), Signer.segwit.hash(tx, i, { pubkey, sigflag: 0x01 }), pubkey, { lowS: true, format: 'der' });
+}
// Original, intentionally narrow implementation of testnet_generator's local UTXO/mining model.
-// This is NOT a Bitcoin consensus engine. Only our key-path and ord MIME templates are accepted.
+// This is NOT a Bitcoin consensus engine. Only our key-path and ord MIME templates, and P2WPKH payments, are accepted.
export class LocalChain {
constructor(snapshot) {
this.blocks = []; this.mempool = []; this.utxos = new Map(); this.transactions = new Map();
@@ -42,7 +53,8 @@ export class LocalChain {
for (const output of tx.vout) {
const value = Number(output.value);
assert(Number.isSafeInteger(value) && value >= 330 && value <= REWARD, 'Invalid output amount.');
- assert(/^5120[0-9a-f]{64}$/.test(scriptHex(output.scriptPubKey)), 'Only Taproot outputs are supported.');
+ const script = scriptHex(output.scriptPubKey);
+ assert(TAPROOT.test(script) || P2WPKH.test(script), 'Only Taproot and P2WPKH outputs are supported.');
total += value;
}
let inscription;
@@ -60,6 +72,14 @@ export class LocalChain {
tx.vin.forEach((input, i) => {
assert(input.sequence === 'ffffffff' && scriptHex(input.scriptSig) === '', 'Unsupported input flags.');
input.prevout = { value: coins[i].value, scriptPubKey: coins[i].scriptPubKey };
+ const spent = scriptHex(coins[i].scriptPubKey);
+ if (P2WPKH.test(spent)) {
+ const [sig, pubkey] = input.witness ?? [];
+ assert(i !== scriptInput && input.witness?.length === 2, 'Unsupported witness.');
+ assert(/^30[0-9a-f]{12,140}01$/.test(sig) && /^0[23][0-9a-f]{64}$/.test(pubkey) && hash160(pubkey) === spent.slice(4),
+ 'P2WPKH spends need a SIGHASH_ALL signature by the key they pay.');
+ return;
+ }
assert(input.witness?.length === 1 || (i === scriptInput && input.witness?.length === 3), 'Unsupported witness.');
assert(/^[0-9a-f]{128}$/.test(input.witness[0]), 'Only default 64-byte Schnorr signatures are accepted.');
});
@@ -87,7 +107,7 @@ export class LocalChain {
}
tx.vin.forEach((input, i) => {
let valid = false;
- try { valid = Signer.taproot.verify(tx, i, i === scriptInput ? config : {}); } catch { /* Normalize malformed signatures. */ }
+ try { valid = P2WPKH.test(scriptHex(coins[i].scriptPubKey)) ? segwitValid(tx, i) : Signer.taproot.verify(tx, i, i === scriptInput ? config : {}); } catch { /* Normalize malformed signatures. */ }
assert(valid, 'Invalid signature or Taproot commitment.');
});
diff --git a/src/main.js b/src/main.js
index 58790c1..8f16bab 100644
--- a/src/main.js
+++ b/src/main.js
@@ -4,8 +4,9 @@ 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, setTempo, tick } from './orgchain/store.js';
-import { SAMPLE, act, seedSample } from './orgchain/actions.js';
+import { SAMPLE, act, minerOf, send, seedSample } from './orgchain/actions.js';
import { addressesOf, holderOf, keyOf, leavesOf, secretsOf } from './orgchain/keys.js';
+import { DUST, balances, brief, btc, coins, ledger, most, owners, payee, payerOf, satsOf } from './orgchain/money.js';
import { readEnvelope } from './adapters/inscription.js';
const $ = id => document.getElementById(id);
@@ -27,12 +28,18 @@ const clockTime = ms => new Date(ms).toLocaleTimeString([], { hour: '2-digit', m
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, beat: null, verb: 'birth', busy: false, replay: null, closing: null, reveal: null };
+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 cued = () => events.filter(e => e.status === 'pending').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, closing: null, reveal: null }, next);
+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); }
@@ -51,11 +58,16 @@ function derive() {
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), 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.txid === 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();
}
@@ -77,9 +89,13 @@ async function perform(op, by, fields, done) {
render();
return true;
}
+// Run something that may beat, then say what the beat landed and whom it paid.
async function landCue(action) {
- const before = cued();
- if ((await run(action)) && cued() < before) say(`Beat ${tip()}: ${plural(before - cued(), 'cued act')} landed.`);
+ 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 m = money.ledger.find(x => x.kind === 'mine' && x.beat === tip()), earned = `${m ? node(m.to)?.name : 'the lab wallet'} earned ${btc(m?.amount ?? 5e9)}`;
+ say(`Beat ${tip()}: ${landed.length ? `${landed.join(' and ')} landed, and ${earned}` : earned}.`);
}
function render() { renderClock(); renderHoldings(); renderTree(); renderAct(); renderInspect(); renderTimeline(); }
@@ -89,7 +105,9 @@ function renderClock() {
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';
- $('beat-now').disabled = !n;
+ const to = payee(lab.keys, live, ui.holding) ? node(ui.holding).name : 'the lab wallet';
+ $('clock-pays').textContent = `beats pay ${to}`;
+ $('beat-now').title = `Beat now: ${n ? 'land what is cued, and ' : ''}pay ${to} 50 test BTC`;
}
function cueNote() {
const { tempo, nextAt } = lab.clock;
@@ -101,7 +119,8 @@ function cueNote() {
function renderHoldings() {
$('holdings-list').replaceChildren(...live.roots.map(r => {
const c = census(live, r.id), on = r.id === ui.holding;
- const facts = [at(r.born), plural(c.orgs, 'org'), plural(c.agents, 'agent'), plural(c.belts, 'belt'), r.status === 'closed' && 'closed'].filter(Boolean);
+ 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 && btc(worth), r.status === 'closed' && 'closed'].filter(Boolean);
return h('li', {}, h('button', { type: 'button', class: `hold${on ? ' on' : ''}${r.status === 'closed' ? ' 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(' · ') })));
}));
@@ -135,6 +154,7 @@ function marks(n) {
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
}
@@ -148,6 +168,15 @@ 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);
+// Why n cannot send now ('' when it can).
+function sendBlock(n) {
+ const wallet = payerOf(lab.keys, live, n), root = node(n.root);
+ 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 n.id === n.root ? `${n.name} has no test BTC yet. Each beat pays it 50` : `${n.name} has no test BTC to send yet. Each beat pays ${root?.name ?? 'its holding'}, which can send some down`;
+ }
+ return recipients(n).length ? '' : 'There is nobody else in this holding 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` : '';
@@ -155,6 +184,7 @@ function blocked(n) {
birth: closed || (n.kind === 'agent' ? 'Agents do not give birth' : ''),
award: closed || (n.kind === 'agent' ? 'Agents do not award belts' : recipients(n).length ? '' : 'There is nobody else in this holding to award yet'),
decide: closed,
+ 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` : ''),
close: closed,
@@ -177,13 +207,16 @@ function renderAct() {
$('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 to = $('award-to'), keep = to.value;
- const options = recipients(n).map(x => h('option', { value: x.id, text: `${trail(live, x.id)}${x.kind === 'agent' ? ' (agent)' : ''}` }));
const sig = list => list.map(o => `${o.value}=${o.textContent}`).join('|');
- if (sig(options) !== sig([...to.options])) { to.replaceChildren(...options); if (options.some(o => o.value === keep)) to.value = keep; }
+ for (const to of [$('award-to'), $('send-to')]) {
+ const keep = to.value, options = recipients(n).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);
- $('act-key').textContent = !signer || why[ui.verb] ? '' : k ? `Signs as ${signer.name}: ${k.path}` : 'Signs with the lab wallet: this holding predates lab keys.';
+ const 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 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.`
@@ -195,6 +228,8 @@ function renderAct() {
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.txid === 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.' }));
@@ -211,6 +246,7 @@ function nodeView(n) {
parent && ['Edge', n.edge === 'adopted' ? `adopted at ${at(n.graduated)}: it holds its own root` : `derived: ${parent.name} holds its seed`],
n.closed !== null && ['Closed', at(n.closed)],
['Children', n.children.length ? n.children.map(id => node(id, view)?.name).join(', ') : 'none yet'],
+ payerOf(lab.keys, view, n) && ['Balance', btc(purse(n.id)), `Test BTC at its SegWit address${ui.beat === null ? '' : `, as of beat ${ui.beat}`}.`],
['Inscription', n.id],
].filter(Boolean);
return [
@@ -222,9 +258,29 @@ function nodeView(n) {
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)})` }))))),
- keysView(n)),
+ 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`;
+ 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 => node(id)?.name ?? '?';
+ 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`;
+ 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' };
@@ -240,7 +296,7 @@ function keysView(n) {
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}. For payments; this toy chain only moves Taproot.`],
+ 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' },
@@ -304,6 +360,25 @@ function eventView(e) {
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' ? "the beat's block reward (its coinbase)" : `${name(m.from)}'s SegWit address${m.kind === 'move' ? ', from before it graduated' : ''}`],
+ ['To', `${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);
@@ -315,8 +390,10 @@ function renderTimeline() {
$('replay').disabled = !root || t === 0;
$('to-now').disabled = ui.beat === null;
$('timeline-sub').textContent = root?.name ?? '';
- const groups = new Map();
- for (const e of live.log) if (e.root && e.root === ui.holding) { const k = e.pending ? 'cued' : e.beat; groups.set(k, [...(groups.get(k) ?? []), e]); }
+ 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 => {
@@ -325,14 +402,17 @@ function renderTimeline() {
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).map(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(' ') })));
- })));
+ 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.txid === ui.tx ? ' on' : ''}`, 'aria-pressed': String(m.txid === ui.tx),
+ onclick: () => pick({ tx: m.txid, node: 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 => {
@@ -366,6 +446,20 @@ $('f-decide').addEventListener('submit', async event => {
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)?.name}, ${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);
@@ -384,16 +478,16 @@ for (const b of document.querySelectorAll('[data-verb]')) b.addEventListener('cl
// 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)))) return;
+ if (!(await run(l => setTempo(l, tempo, Date.now(), minerOf(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 => (l.chain.mempool.length ? beat(l) : false)));
+$('beat-now').addEventListener('click', () => landCue(l => { beat(l, minerOf(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));
+ landCue(l => tick(l, Date.now(), minerOf(l, ui.holding)));
}, 1000);
addEventListener('storage', event => { if (event.key === LAB_KEY || event.key === null) load(); });
@@ -433,7 +527,7 @@ const forgiving = { read: () => { try { return readLab(); } catch { return empty
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, beat: null, closing: null });
+ Object.assign(ui, { holding: null, node: null, event: null, tx: null, beat: null, closing: null });
derive();
say(message);
}
diff --git a/src/orgchain.css b/src/orgchain.css
index f21369c..3445f52 100644
--- a/src/orgchain.css
+++ b/src/orgchain.css
@@ -284,3 +284,16 @@ textarea { resize: vertical; line-height: 1.5; }
}
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.code-h { margin: 0; }
+.mk.sats { color: var(--ink); text-transform: none; letter-spacing: 0.02em; }
+.ev.money .op { color: var(--ok); }
+.ev.money.on .op { color: var(--on-signal); }
+.beat.later .ev.money .op { color: var(--ink-3); }
+.vf .line.amt { margin-top: 0; }
+#clock-pays { white-space: nowrap; }
+.verbs { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); }
+.verbs button:nth-child(3n) { border-right: 0; }
+.verbs button:nth-child(-n+3) { border-bottom: 2px solid var(--ink); }
+@media (max-width: 640px) {
+ .v { margin-left: 1ch; }
+ .mk { padding: 3px 4px 2px; }
+}
diff --git a/src/orgchain/actions.js b/src/orgchain/actions.js
index 5086418..ab17f94 100644
--- a/src/orgchain/actions.js
+++ b/src/orgchain/actions.js
@@ -1,6 +1,7 @@
// Orgchain's verbs as lineage/v0 inscriptions: found, birth, award, decide, graduate, close.
// Each act is signed by the key that holds its authorizing node's inscription (lineage's key tree, ./keys.js).
-// The lab wallet is the faucet: beats pay it, and it funds every other key and pays for handoffs.
+// The lab wallet is the faucet: it funds every other key's acts and pays for handoffs. Beats pay the holding being
+// worked in (./money.js), which can send test BTC down its tree.
import { Tx } from '@cmdcode/tapscript';
import { InscriptionAdapter, POSTAGE, FEE } from '../adapters/inscription.js';
import { MetadataIndex } from '../adapters/metadata-index.js';
@@ -11,6 +12,7 @@ import { holderCoin, makeClaim, countersign } from '../adapters/handoff.js';
import { PROTOCOL, lineageEvents, reduce } from './lineage.js';
import { armClock, beat } from './store.js';
import { derivedSubtree, emptyKeys, holderOf, keyOf, rootWallet } from './keys.js';
+import { SAT, pay, payee, payerOf, sweep } from './money.js';
const FIELDS = { found: ['name'], birth: ['name', 'kind'], award: ['to', 'skill', 'belt'], decide: ['title', 'why'], graduate: ['of'], close: [] };
const BODY = { found: m => `found ${m.name}`, birth: m => `birth ${m.name}`, award: m => `award ${m.skill} ${m.belt}`,
@@ -63,6 +65,9 @@ function hand(lab, id, from, to) {
function inscribe(lab, wallet, op, by, metadata) {
return new InscriptionAdapter(lab.chain, wallet).inscribe(BODY[op](metadata), by ?? null, metadata, { metaprotocol: PROTOCOL });
}
+// A beat pays the holding of the node that acted, or the lab wallet when that holding is closed or predates lab keys.
+const minerFor = (lab, id) => { const state = lineageState(lab); return payee(lab.keys, state, state.nodes.get(id)?.root ?? null); };
+export const minerOf = (lab, holdingId) => payee(lab.keys, lineageState(lab), holdingId);
// Who signs for a node: whichever key holds its inscription.
function signerFor(lab, state, id) {
const holder = holderOf(lab, state, id);
@@ -95,6 +100,8 @@ function perform(lab, op, by, metadata) {
const after = lineageState(lab);
for (const [nodeId, from] of moving) {
const to = keyOf(lab.keys, after, after.nodes.get(nodeId)).wallet;
+ // Its money follows it: what sits at its old SegWit address moves to its new one.
+ sweep(lab, payerOf(lab.keys, before, before.nodes.get(nodeId)), payerOf(lab.keys, after, after.nodes.get(nodeId)));
if (nodeId !== org.id) { hand(lab, nodeId, from, to); continue; }
// The fresh root signs its fee input first, which fixes where the org goes; the holder then countersigns.
supply(lab, to, FEE + 330);
@@ -109,20 +116,31 @@ export function act(lab, op, by, fields, now = Date.now()) {
const metadata = check(lab, op, by, fields);
fund(lab);
const id = perform(lab, op, by ?? null, metadata);
- if (lab.clock.tempo === 'each') beat(lab); else armClock(lab, now);
+ if (lab.clock.tempo === 'each') beat(lab, minerFor(lab, by ?? id)); else armClock(lab, now);
return id;
}
+// Send test BTC from one node to another in its holding (./money.js). Like an act, it lands now or is cued.
+export function send(lab, from, to, amount, now = Date.now()) {
+ const txid = pay(lab, lineageState(lab), from, to, amount);
+ if (lab.clock.tempo === 'each') beat(lab, minerFor(lab, from)); else armClock(lab, now);
+ return txid;
+}
+
// The drawing in docs/lineage.md, rehearsed over six beats. Names stand in for inscription ids until those exist.
+// Every beat pays yolopreneur, and a few sends pass that down the tree.
export const SAMPLE = 'yolopreneur';
const PLAN = [
[['found', null, { name: 'yolopreneur' }]],
[['birth', 'yolopreneur', { name: 'well-known', kind: 'org' }], ['birth', 'yolopreneur', { name: 'time', kind: 'org' }]],
[['birth', 'well-known', { name: 'mailr', kind: 'org' }], ['birth', 'well-known', { name: 'lineage', kind: 'org' }],
- ['birth', 'time', { name: 'luna', kind: 'agent' }], ['birth', 'time', { name: 'sol', kind: 'agent' }], ['birth', 'time', { name: 'astra', kind: 'agent' }]],
+ ['birth', 'time', { name: 'luna', kind: 'agent' }], ['birth', 'time', { name: 'sol', kind: 'agent' }], ['birth', 'time', { name: 'astra', kind: 'agent' }],
+ ['send', 'yolopreneur', { to: 'time', amount: 40 }], ['send', 'yolopreneur', { to: 'well-known', amount: 10 }]],
[['birth', 'time', { name: 'docscope', kind: 'org' }], ['birth', 'time', { name: 'dollhouse', kind: 'org' }],
- ['birth', 'time', { name: 'doodleloop', kind: 'org' }], ['birth', 'time', { name: 'hearthframe', kind: 'org' }]],
+ ['birth', 'time', { name: 'doodleloop', kind: 'org' }], ['birth', 'time', { name: 'hearthframe', kind: 'org' }],
+ ['send', 'well-known', { to: 'lineage', amount: 2 }]],
[['award', 'time', { to: 'sol', skill: 'launch', belt: 'green' }], ['award', 'time', { to: 'luna', skill: 'post', belt: 'white' }],
+ ['send', 'time', { to: 'sol', amount: 1 }], ['send', 'time', { to: 'luna', amount: 0.5 }],
['decide', 'yolopreneur', { title: 'Every venture gets a page', why: '.yolopreneur.lol, served from one pages branch.' }]],
[['graduate', 'well-known', { of: 'lineage' }],
['decide', 'lineage', { title: 'Lineage gets its own org', why: 'Its own repo on Teahub now, lineage.llc later: adopted, not derived.' }]],
@@ -133,11 +151,12 @@ export function seedSample(lab) {
const ids = {};
for (const group of PLAN) {
for (const [op, by, fields] of group) {
+ if (op === 'send') { pay(lab, lineageState(lab), ids[by], ids[fields.to], fields.amount * SAT); continue; }
const resolved = Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, k === 'to' || k === 'of' ? ids[v] : v]));
const parent = by ? ids[by] : null, id = perform(lab, op, parent, check(lab, op, parent, resolved));
if (op === 'found' || op === 'birth') ids[fields.name] = id;
}
- beat(lab);
+ beat(lab, minerOf(lab, ids[SAMPLE]));
}
return ids[SAMPLE];
}
diff --git a/src/orgchain/money.js b/src/orgchain/money.js
new file mode 100644
index 0000000..5efd939
--- /dev/null
+++ b/src/orgchain/money.js
@@ -0,0 +1,184 @@
+// Orgchain's money: test BTC that moves inside a holding. Each beat pays its block reward to the holding being worked
+// in, at the holding's SegWit address (BIP-84), and any active node can send to another node of its holding, signed by
+// its own SegWit key. A graduation re-keys a subtree, and its money moves to the new keys with it. Inscriptions and
+// their fees stay on Taproot, where the lab wallet still pays. Test coins only.
+import { Address, Signer, Tx } from '@cmdcode/tapscript';
+import { FEE } from '../adapters/inscription.js';
+import { getVin, getVout } from '../upstream/primitives.js';
+import { PATHS, segwitPubkey, segwitSecret } from '../adapters/lineage-keys.js';
+import { reduce } from './lineage.js';
+import { scriptHex, seedOf } from './keys.js';
+
+export { FEE };
+export const SAT = 100_000_000;
+export const REWARD = 50 * SAT; // the toy chain's block reward, and the most any one output may carry
+export const DUST = 330; // the toy chain's smallest output
+
+// 4999999500 -> "49.999995 tBTC"
+export const coins = sats => (sats / SAT).toFixed(8).replace(/\.?0+$/, '');
+export const btc = sats => `${coins(sats)} tBTC`;
+// The tree's short form: two decimals at most, with ≈ when that rounds. Inspect has the exact amount.
+export function brief(sats) {
+ if (sats < 1e6) return btc(sats);
+ const near = Math.round(sats / 1e6) * 1e6;
+ return `${near === sats ? '' : '≈'}${btc(near)}`;
+}
+// "1.5" -> 150000000, or null unless it is a plain amount with at most eight decimals.
+export function satsOf(text) {
+ const m = /^\s*(\d{0,7})(?:\.(\d{0,8}))?\s*$/.exec(String(text ?? ''));
+ if (!m || !(m[1] || m[2])) return null;
+ return Number(m[1] || 0) * SAT + Number((m[2] ?? '').padEnd(8, '0'));
+}
+
+// A node's payments key: its account's BIP-84 key at m/84'/1'/account'/0/0.
+export class PayWallet {
+ constructor(secret, pubkey, path) {
+ this.secret = secret; this.pubkey = pubkey; this.path = path;
+ this.address = Address.p2wpkh.fromPubKey(pubkey, 'regtest');
+ this.script = Address.toScriptPubKey(this.address);
+ }
+ // BIP-143 with SIGHASH_ALL; the witness is the DER signature and the compressed key.
+ sign(tx, index) { return [Signer.segwit.sign(this.secret, tx, index, { pubkey: this.pubkey }).hex, this.pubkey]; }
+}
+const wallets = new Map();
+export function payerOf(keys, state, node) {
+ const seed = seedOf(keys, state, node);
+ if (!seed) return null;
+ const id = `${seed.mnemonic}|${seed.account}`;
+ if (!wallets.has(id)) {
+ wallets.set(id, new PayWallet(segwitSecret(seed.mnemonic, seed.account), segwitPubkey(seed.mnemonic, seed.account), PATHS.segwit(seed.account)));
+ }
+ return wallets.get(id);
+}
+
+// Whom a beat pays: the holding being worked in, while it is active and has lab keys. Null means the lab wallet.
+export function payee(keys, state, holdingId) {
+ const holding = holdingId ? state.nodes.get(holdingId) : null;
+ return holding?.kind === 'holding' && holding.status === 'active' ? payerOf(keys, state, holding)?.script ?? null : null;
+}
+
+// The coins a send spends: the fewest and smallest that cover the amount and the fee and leave no dust. The toy chain
+// takes at most two inputs, and no output above one block reward.
+function choose(list, amount) {
+ const need = amount + FEE, fits = total => total === need || (total - need >= DUST && total - need <= REWARD);
+ const sorted = [...list].sort((a, b) => a.value - b.value), one = sorted.find(c => fits(c.value));
+ if (one) return [one];
+ let best = null;
+ for (let i = 0; i < sorted.length; i++) {
+ for (let j = i + 1; j < sorted.length; j++) {
+ const total = sorted[i].value + sorted[j].value;
+ if (fits(total) && (!best || total < best[0].value + best[1].value)) best = [sorted[i], sorted[j]];
+ }
+ }
+ return best;
+}
+// The most one send can move from these coins.
+export function most(list) {
+ const v = list.map(c => c.value).sort((a, b) => b - a), out = [];
+ if (v.length) out.push(v[0] - FEE);
+ if (v.length > 1) { const t = v[0] + v[1] - FEE; out.push(t <= REWARD ? t : t - REWARD >= DUST ? REWARD : t - DUST); }
+ return Math.max(0, ...out.filter(x => x >= DUST && x <= REWARD));
+}
+// Why a send cannot be made from these coins (when choose found none).
+function short(name, list, amount) {
+ const has = list.reduce((sum, c) => sum + c.value, 0), need = amount + FEE, max = most(list);
+ if (has < need) return `${name} has ${btc(has)}, and sending ${btc(amount)} also costs the ${FEE}-sat fee.`;
+ const totals = [...list.map(c => c.value), ...list.flatMap((a, i) => list.slice(i + 1).map(b => a.value + b.value))];
+ const cover = Math.min(...totals.filter(t => t >= need));
+ if (Number.isFinite(cover) && cover - need < DUST) {
+ return `Sending ${btc(amount)} would leave ${cover - need} sats of change, under the ${DUST}-sat floor. Send ${btc(max)}, or at least ${DUST - (cover - need)} sats less.`;
+ }
+ return `One send spends at most two coins, so the most ${name} can send at once is ${btc(max)}.`;
+}
+
+// One send: the sender's coins in, the amount to the recipient's SegWit address, change back, and the fixed fee.
+export function pay(lab, state, fromId, toId, amount) {
+ const from = state.nodes.get(fromId), to = state.nodes.get(toId);
+ if (!from || !to) throw new Error('Pick who sends and who receives.');
+ if (from.id === to.id) throw new Error(`${from.name} cannot send to itself.`);
+ if (from.root !== to.root) throw new Error('Sends stay inside one holding for now.');
+ for (const n of [from, to]) if (n.status !== 'active') throw new Error(`${n.name} is ${n.status}.`);
+ const wallet = payerOf(lab.keys, state, from), dest = payerOf(lab.keys, state, to);
+ if (!wallet || !dest) throw new Error('This holding predates lab keys, so it has no SegWit addresses.');
+ if (!Number.isSafeInteger(amount) || amount < DUST) throw new Error(`Send at least ${DUST} sats.`);
+ if (amount > REWARD) throw new Error('Send at most 50 test BTC at a time: no output on this chain is bigger than one block reward.');
+ const list = lab.chain.coins(wallet.script), picked = choose(list, amount);
+ if (!picked) throw new Error(short(from.name, list, amount));
+ const change = picked.reduce((sum, c) => sum + c.value, 0) - amount - FEE;
+ const tx = Tx.create({ vin: picked.map(c => getVin(c.txid, c.vout, c.value, c.scriptPubKey)),
+ vout: [getVout(amount, dest.script), ...(change ? [getVout(change, wallet.script)] : [])] });
+ picked.forEach((c, i) => { tx.vin[i].witness = wallet.sign(tx, i); });
+ const raw = Tx.encode(tx).hex;
+ lab.chain.submitPackage([raw]);
+ return Tx.util.getTxid(raw);
+}
+
+// When a graduation re-keys a node, its money follows: each coin at its old SegWit address moves to the new one,
+// less the fee. A coin too small to pay its own fee stays behind.
+export function sweep(lab, from, to) {
+ if (!from || !to || scriptHex(from.script) === scriptHex(to.script)) return 0;
+ let moved = 0;
+ for (const coin of lab.chain.coins(from.script)) {
+ if (coin.value - FEE < DUST) continue;
+ const tx = Tx.create({ vin: [getVin(coin.txid, coin.vout, coin.value, coin.scriptPubKey)], vout: [getVout(coin.value - FEE, to.script)] });
+ tx.vin[0].witness = from.sign(tx, 0);
+ lab.chain.submitPackage([Tx.encode(tx).hex]);
+ moved++;
+ }
+ return moved;
+}
+
+// Every SegWit script a node has had, so beats and sends can be named at any beat. A graduation re-keys its subtree,
+// so those nodes also have the script they had before it. live must be reduce(events, { pending: true }).
+export function owners(keys, events, live) {
+ const map = new Map();
+ const add = state => {
+ for (const n of state.nodes.values()) {
+ const w = payerOf(keys, state, n), s = w && scriptHex(w.script);
+ if (s && !map.has(s)) map.set(s, n.id);
+ }
+ };
+ add(live);
+ live.log.forEach((e, i) => { if (e.ok && e.op === 'graduate') add(reduce(events.slice(0, i), { pending: true })); });
+ return map;
+}
+
+// The money's record in chain order: beats that paid a node (mine), sends between nodes (pay), and money that
+// followed a node to its new keys (move). A beat of null means cued.
+export function ledger(chain, owner) {
+ const out = [], who = script => owner.get(scriptHex(script)) ?? null, sum = list => list.reduce((s, x) => s + x.value, 0);
+ for (const { txid, tx, height, coinbase } of chain.transactions.values()) {
+ const outs = tx.vout.map(o => ({ node: who(o.scriptPubKey), value: Number(o.value) }));
+ if (coinbase) {
+ if (outs[0].node) out.push({ kind: 'mine', txid, beat: height, from: null, to: outs[0].node, amount: outs[0].value, fee: 0, change: 0 });
+ continue;
+ }
+ const ins = tx.vin.map(i => {
+ const prev = chain.transactions.get(i.txid)?.tx.vout[i.vout];
+ return prev ? { node: who(prev.scriptPubKey), value: Number(prev.value) } : null;
+ });
+ const from = ins[0]?.node;
+ if (!from || !ins.every(x => x?.node === from)) continue; // inscriptions, grants and handoffs are Taproot, not money
+ const paid = outs.filter(o => o.node !== from), back = outs.filter(o => o.node === from), fee = sum(ins) - sum(outs);
+ out.push(paid.length
+ ? { kind: 'pay', txid, beat: height, from, to: paid[0].node, amount: paid[0].value, fee, change: sum(back) }
+ : { kind: 'move', txid, beat: height, from, to: from, amount: sum(back), fee, change: 0 });
+ }
+ return out;
+}
+
+// Each node's balance as of a beat; upTo Infinity is now, cued sends included.
+export function balances(chain, owner, upTo = Infinity) {
+ const held = new Map();
+ for (const { txid, tx, height, coinbase } of chain.transactions.values()) {
+ if (height === null ? upTo !== Infinity : height > upTo) continue;
+ if (!coinbase) for (const i of tx.vin) held.delete(`${i.txid}:${i.vout}`);
+ tx.vout.forEach((o, vout) => {
+ const node = owner.get(scriptHex(o.scriptPubKey));
+ if (node) held.set(`${txid}:${vout}`, { node, value: Number(o.value) });
+ });
+ }
+ const out = new Map();
+ for (const { node, value } of held.values()) out.set(node, (out.get(node) ?? 0) + value);
+ return out;
+}
diff --git a/src/orgchain/store.js b/src/orgchain/store.js
index 58ec51f..ec07dbe 100644
--- a/src/orgchain/store.js
+++ b/src/orgchain/store.js
@@ -56,10 +56,11 @@ export async function mutate(action, { locks = globalThis.navigator?.locks, read
});
}
-// The clock. A beat is a block; cued acts wait in the mempool for it.
-export function beat(lab) {
+// The clock. A beat is a block; cued acts wait in the mempool for it. Its reward pays script (the holding being worked
+// in; see money.js), or the lab wallet.
+export function beat(lab, script = null) {
if (!lab.wallet) lab.wallet = TestWallet.create();
- const block = lab.chain.mine(lab.wallet.script);
+ const block = lab.chain.mine(script ?? lab.wallet.script);
lab.clock = clockPreference({ tempo: lab.clock.tempo, nextAt: null });
return block;
}
@@ -68,16 +69,16 @@ export function armClock(lab, now = Date.now()) {
const { tempo, nextAt } = lab.clock;
if (typeof tempo === 'number' && lab.chain.mempool.length && nextAt === null) lab.clock = { tempo, nextAt: now + tempo * 1000 };
}
-export function setTempo(lab, tempo, now = Date.now()) {
+export function setTempo(lab, tempo, now = Date.now(), script = null) {
lab.clock = clockPreference({ tempo, nextAt: null });
- if (tempo === 'each' && lab.chain.mempool.length) beat(lab); else armClock(lab, now);
+ if (tempo === 'each' && lab.chain.mempool.length) beat(lab, script); else armClock(lab, now);
}
// Run on a timer, inside the lock: at most one beat, only when one is due, and never an empty one.
-export function tick(lab, now = Date.now()) {
+export function tick(lab, now = Date.now(), script = null) {
const { tempo, nextAt } = lab.clock;
if (typeof tempo !== 'number' || !lab.chain.mempool.length) return false;
if (nextAt === null) { armClock(lab, now); return true; }
if (now < nextAt) return false;
- beat(lab);
+ beat(lab, script);
return true;
}
diff --git a/tests/money.test.js b/tests/money.test.js
new file mode 100644
index 0000000..1591a74
--- /dev/null
+++ b/tests/money.test.js
@@ -0,0 +1,121 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { Tx } from '@cmdcode/tapscript';
+import { secp256k1 } from '@noble/curves/secp256k1';
+import { bytesToHex } from '@noble/curves/abstract/utils';
+import { LocalChain } from '../src/adapters/chain.js';
+import { getVin, getVout } from '../src/upstream/primitives.js';
+import { MetadataIndex } from '../src/adapters/metadata-index.js';
+import { lineageEvents, reduce } from '../src/orgchain/lineage.js';
+import { decodeLab, emptyLab, encodeLab, setTempo, tick } from '../src/orgchain/store.js';
+import { act, minerOf, seedSample, send } from '../src/orgchain/actions.js';
+import { FEE, PayWallet, REWARD, SAT, balances, brief, btc, ledger, most, owners, payerOf, satsOf } from '../src/orgchain/money.js';
+
+const key = byte => { const secret = byte.repeat(32); return new PayWallet(secret, bytesToHex(secp256k1.getPublicKey(secret, true)), "m/84'/1'/0'/0/0"); };
+// A lab's money as the app sees it: who owns each SegWit script, balances now (or at a beat), and the ledger.
+function money(lab, upTo) {
+ const events = lineageEvents(new MetadataIndex(lab.chain)), live = reduce(events, { pending: true }), owner = owners(lab.keys, events, live);
+ const named = map => Object.fromEntries([...map].map(([id, v]) => [live.nodes.get(id).name, v]));
+ return { live, owner, now: named(balances(lab.chain, owner, upTo)), log: ledger(lab.chain, owner).map(m =>
+ [m.kind, m.beat, live.nodes.get(m.from)?.name ?? null, live.nodes.get(m.to)?.name, m.amount]) };
+}
+
+test('the toy chain mines to SegWit and moves P2WPKH spends signed by the key they pay', () => {
+ const chain = new LocalChain(), a = key('11'), b = key('22');
+ chain.mine(a.script);
+ const [coin] = chain.coins(a.script);
+ assert.equal(coin.value, REWARD);
+ const build = (amount = SAT) => Tx.create({ vin: [getVin(coin.txid, coin.vout, coin.value, coin.scriptPubKey)],
+ vout: [getVout(amount, b.script), getVout(coin.value - amount - FEE, a.script)] });
+ const signed = (signer, tx = build(), witness = null) => { tx.vin[0].witness = witness ?? signer.sign(tx, 0); return Tx.encode(tx).hex; };
+ const good = build();
+ const sig = a.sign(good, 0);
+ assert.throws(() => chain.submitPackage([signed(b)]), /P2WPKH spends need a SIGHASH_ALL signature by the key they pay/);
+ assert.throws(() => chain.submitPackage([signed(a, build(), [`${sig[0].slice(0, -2)}83`, sig[1]])]), /P2WPKH spends need/);
+ assert.throws(() => chain.submitPackage([signed(a, build(), ['ab'.repeat(64)])]), /Unsupported witness/);
+ assert.throws(() => chain.submitPackage([signed(a, build(SAT + 1), sig)]), /Invalid signature/);
+ chain.submitPackage([signed(a, good, sig)]);
+ chain.mine(a.script);
+ assert.equal(chain.balance(b.script), SAT);
+ assert.equal(chain.balance(a.script), 2 * REWARD - SAT - FEE);
+ const copy = new LocalChain(chain.snapshot());
+ assert.deepEqual([copy.balance(a.script), copy.balance(b.script)], [2 * REWARD - SAT - FEE, SAT]);
+});
+
+test('each beat pays the holding being worked in, and sends move test BTC down its tree', () => {
+ const lab = emptyLab();
+ const acme = act(lab, 'found', null, { name: 'acme' }), ops = act(lab, 'birth', acme, { name: 'ops' });
+ const bot = act(lab, 'birth', ops, { name: 'bot', kind: 'agent' });
+ assert.deepEqual(money(lab).now, { acme: 3 * REWARD }); // beats 2-4; beat 1 funded the lab wallet
+ send(lab, acme, ops, 12.5 * SAT);
+ send(lab, ops, bot, SAT);
+ assert.equal(lab.chain.blocks.length, 6);
+ assert.deepEqual(money(lab).now, { acme: 5 * REWARD - 12.5 * SAT - FEE, ops: 11.5 * SAT - FEE, bot: SAT });
+ assert.deepEqual(money(lab).log, [['mine', 2, null, 'acme', REWARD], ['mine', 3, null, 'acme', REWARD], ['mine', 4, null, 'acme', REWARD],
+ ['mine', 5, null, 'acme', REWARD], ['pay', 5, 'acme', 'ops', 12.5 * SAT], ['mine', 6, null, 'acme', REWARD], ['pay', 6, 'ops', 'bot', SAT]]);
+ assert.deepEqual(money(lab, 4).now, { acme: 3 * REWARD });
+ const s = money(lab).live, opsKey = payerOf(lab.keys, s, s.nodes.get(ops));
+ assert.match(opsKey.address, /^bcrt1q/);
+ assert.equal(opsKey.path, "m/84'/1'/0'/0/0");
+ assert.equal(most(lab.chain.coins(opsKey.script)), 11.5 * SAT - 2 * FEE);
+ const before = encodeLab(lab);
+ assert.throws(() => send(lab, bot, ops, 2 * SAT), /^Error: bot has 1 tBTC, and sending 2 tBTC also costs the 500-sat fee\.$/);
+ assert.throws(() => send(lab, acme, acme, SAT), /cannot send to itself/);
+ assert.throws(() => send(lab, acme, ops, 51 * SAT), /at most 50 test BTC/);
+ assert.throws(() => send(lab, acme, ops, 100), /at least 330 sats/);
+ assert.throws(() => send(lab, ops, bot, 11.5 * SAT - 2 * FEE - 100),
+ /^Error: Sending 11\.499989 tBTC would leave 100 sats of change, under the 330-sat floor\. Send 11\.49999 tBTC, or at least 230 sats less\.$/);
+ assert.equal(encodeLab(lab), before);
+ const other = act(lab, 'found', null, { name: 'other' });
+ assert.throws(() => send(lab, acme, other, SAT), /inside one holding/);
+ assert.equal(money(lab).now.other, REWARD); // its founding beat paid it
+ const copy = decodeLab(encodeLab(lab));
+ assert.deepEqual(money(copy).now, money(lab).now);
+});
+
+test('a timed tempo cues sends; the beat that lands them pays whoever the clock is told', () => {
+ const lab = emptyLab();
+ const acme = act(lab, 'found', null, { name: 'acme' }), ops = act(lab, 'birth', acme, { name: 'ops' });
+ setTempo(lab, 10, 1000);
+ send(lab, acme, ops, SAT, 2000);
+ assert.deepEqual(lab.clock, { tempo: 10, nextAt: 12000 });
+ assert.deepEqual(money(lab).log.at(-1), ['pay', null, 'acme', 'ops', SAT]);
+ assert.equal(money(lab).now.ops, SAT); // now includes what is cued
+ assert.equal(money(lab, 3).now.ops, undefined);
+ assert.equal(tick(lab, 12000, minerOf(lab, acme)), true);
+ assert.deepEqual(money(lab).log.slice(-2), [['mine', 4, null, 'acme', REWARD], ['pay', 4, 'acme', 'ops', SAT]]); // a coinbase leads its block
+ assert.equal(tick(lab, 99999, minerOf(lab, acme)), false); // nothing cued: no empty beats
+});
+
+test('closed nodes neither send nor receive, and a closed holding earns nothing', () => {
+ const lab = emptyLab();
+ const acme = act(lab, 'found', null, { name: 'acme' }), ops = act(lab, 'birth', acme, { name: 'ops' });
+ const lab2 = act(lab, 'birth', acme, { name: 'lab' });
+ send(lab, acme, ops, SAT);
+ act(lab, 'close', ops, {});
+ assert.throws(() => send(lab, acme, ops, SAT), /ops is closed/);
+ assert.throws(() => send(lab, ops, lab2, 1000), /ops is closed/);
+ const paid = money(lab).now.acme;
+ act(lab, 'close', acme, {});
+ assert.equal(money(lab).now.acme, paid); // the beat that closed it paid the lab wallet
+ assert.equal(minerOf(lab, acme), null);
+});
+
+test('graduation moves money to the new keys; the sample shows it', () => {
+ const lab = emptyLab();
+ seedSample(lab);
+ const { now, log, live } = money(lab);
+ assert.deepEqual(now, { yolopreneur: 300 * SAT - 50 * SAT - 2 * FEE, 'well-known': 8 * SAT - FEE, lineage: 2 * SAT - FEE,
+ time: 38.5 * SAT - 2 * FEE, sol: SAT, luna: 0.5 * SAT });
+ assert.deepEqual(log.at(-1), ['move', 7, 'lineage', 'lineage', 2 * SAT - FEE]);
+ const lineage = [...live.nodes.values()].find(n => n.name === 'lineage');
+ assert.equal(lab.chain.balance(payerOf(lab.keys, live, lineage).script), 2 * SAT - FEE);
+ assert.deepEqual(money(lab, 5).now.lineage, 2 * SAT); // before graduating, at its derived address
+});
+
+test('amounts read and print as test BTC', () => {
+ assert.deepEqual(['1.5', '0.00000001', '.5', '7', ' 2.25 ', '1.123456789', '', 'abc', '1e3', '-1'].map(satsOf),
+ [1.5 * SAT, 1, 0.5 * SAT, 7 * SAT, 2.25 * SAT, null, null, null, null, null]);
+ assert.deepEqual([4999999500, 0, REWARD, 1].map(btc), ['49.999995 tBTC', '0 tBTC', '50 tBTC', '0.00000001 tBTC']);
+ assert.deepEqual([4999999500, 1250000000, 125000000, 99999, 1149999500].map(brief), ['≈50 tBTC', '12.5 tBTC', '1.25 tBTC', '0.00099999 tBTC', '≈11.5 tBTC']);
+});
diff --git a/tests/orgchain.spec.js b/tests/orgchain.spec.js
index 492c1b3..9e60a3c 100644
--- a/tests/orgchain.spec.js
+++ b/tests/orgchain.spec.js
@@ -71,9 +71,9 @@ test('a timed tempo cues acts; Beat now and the clock land them', async ({ page
await expect(page.locator('#clock-cued')).toContainText('2 cued · next beat in');
await expect(row(page, 'b')).toContainText('cued');
await page.locator('#beat-now').click();
- await expect(say(page)).toHaveText('Beat 3: 2 cued acts landed.');
+ await expect(say(page)).toHaveText('Beat 3: 2 cued acts landed, and acme earned 50 tBTC.');
await birth(page, 'c');
- await expect(say(page)).toHaveText('Beat 4: 1 cued act landed.', { timeout: 15_000 });
+ await expect(say(page)).toHaveText('Beat 4: 1 cued act landed, and acme earned 50 tBTC.', { timeout: 15_000 });
await page.locator('[data-tempo="manual"]').click();
await birth(page, 'd');
await expect(page.locator('#clock-cued')).toHaveText('1 cued · waiting for Beat now');
@@ -83,6 +83,61 @@ test('a timed tempo cues acts; Beat now and the clock land them', async ({ page
expect(problems).toEqual([]);
});
+test('beats pay the holding, and test BTC moves down its tree', async ({ page }) => {
+ const problems = await open(page), inspect = page.locator('#inspect-body');
+ await found(page, 'acme');
+ await expect(row(page, 'acme')).toContainText('50 tBTC');
+ await expect(page.locator('#clock-pays')).toHaveText('beats pay acme');
+ await birth(page, 'ops');
+ await row(page, 'ops').click();
+ await birth(page, 'bot', 'agent');
+ await verb(page, 'send');
+ await expect(page.locator('#act-why')).toHaveText('ops has no test BTC to send yet. Each beat pays acme, which can send some down.');
+ await row(page, 'acme').click();
+ await verb(page, 'send');
+ await expect(page.locator('#send-has')).toHaveText('150 tBTC');
+ await expect(page.locator('#act-key')).toHaveText("Signs as acme: acme root · m/84'/1'/0'/0/0");
+ await page.locator('#send-to').selectOption({ label: 'acme / ops' });
+ await page.locator('#send-amount').fill('12.5');
+ await submit(page, 'send');
+ await expect(say(page)).toHaveText('acme sent 12.5 tBTC to ops, at beat 5.');
+ await expect(row(page, 'ops')).toContainText('12.5 tBTC');
+ await expect(row(page, 'acme')).toContainText('≈187.5 tBTC');
+ await row(page, 'ops').click();
+ await verb(page, 'send');
+ await page.locator('#send-to').selectOption({ label: 'acme / ops / bot (agent)' });
+ await page.locator('#send-max').click();
+ await expect(page.locator('#send-amount')).toHaveValue('12.499995');
+ await page.locator('#send-amount').fill('1');
+ await submit(page, 'send');
+ await expect(say(page)).toHaveText('ops sent 1 tBTC to bot, at beat 6.');
+ await expect(row(page, 'bot')).toContainText('1 tBTC');
+ await page.locator('#timeline-list .ev', { hasText: 'ops → bot' }).click();
+ for (const text of ['500 sats, retired', '11.499995 tBTC back to ops', "ops's SegWit key (BIP-143, SIGHASH_ALL)"]) await expect(inspect).toContainText(text);
+ await page.locator('#beat-now').click();
+ await expect(say(page)).toHaveText('Beat 7: acme earned 50 tBTC.');
+ await page.locator('[data-tempo="manual"]').click();
+ await row(page, 'acme').click();
+ await verb(page, 'send');
+ await page.locator('#send-to').selectOption({ label: 'acme / ops / bot (agent)' });
+ await page.locator('#send-amount').fill('0.25');
+ await submit(page, 'send');
+ await expect(say(page)).toHaveText('acme sent 0.25 tBTC to bot, cued for the next beat.');
+ await expect(page.locator('#clock-cued')).toHaveText('1 cued · waiting for Beat now');
+ await page.locator('#beat-now').click();
+ await expect(say(page)).toHaveText('Beat 8: 1 cued send landed, and acme earned 50 tBTC.');
+ await expect(row(page, 'bot')).toContainText('1.25 tBTC');
+ await slide(page, 4);
+ await expect(row(page, 'ops')).not.toContainText('tBTC');
+ await expect(row(page, 'acme')).toContainText('150 tBTC');
+ await row(page, 'acme').click();
+ await expect(inspect).toContainText('as of beat 4');
+ await page.locator('#to-now').click();
+ await expect(page.locator('#holdings-list')).toContainText('tBTC');
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
+ expect(problems).toEqual([]);
+});
+
test('the sample replays, any beat can be inspected, and the lab survives a reload', async ({ page }) => {
const problems = await open(page);
await page.locator('#sample').click();
@@ -90,6 +145,8 @@ test('the sample replays, any beat can be inspected, and the lab survives a relo
await expect(page.locator('#tree-rows .sel')).toHaveCount(12);
await expect(page.locator('#beat-label')).toHaveText('Now · beat 7');
await expect(row(page, 'lineage')).toContainText('graduated');
+ await expect(row(page, 'time')).toContainText('≈38.5 tBTC');
+ await expect(page.locator('#timeline-list')).toContainText('40 tBTC yolopreneur → time');
await expect(page.locator('#sample')).toBeDisabled();
await slide(page, 3);
await expect(page.locator('#beat-label')).toHaveText('Beat 3 of 7');