Files
orgchain/tests/money.test.js
T
teahubxyzandClaude Opus 5.5 69a97544da Orgchain v1.7: items, standing offers and the first deal; a Mainnet setting
The chain takes ordinary spends of up to eight inputs and outputs, whose
key-path signatures may carry SIGHASH_SINGLE|ANYONECANPAY (0x83); reveals
keep their two-in, two-out shape. A new make op records an item, an
inscription its maker holds. Any node can offer test BTC for an item another
node holds, in any holding: the buyer's SegWit key signs the whole deal, the
offer stands in the lab, and the holder accepts by countersigning the item's
input, so the item and the payment swap in one transaction. Offers go stale
when a promised coin moves or a side re-keys or closes, and payments spend
unpromised coins first. Sends take up to eight coins. Mainnet is a third
mining setting: no holding mines, beats pay the lab wallet, and holdings are
funded from it. The sample ends on lineage's open offer for sol's launch-kit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4upF5LVwmPDDqrhdUfnXh
2026-10-15 00:32:29 -07:00

180 lines
12 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { Signer, Tx } from '@cmdcode/tapscript';
import { TestWallet } from '../src/adapters/wallet.js';
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, miningPreference, setRigs, setRule, setTempo, tick } from '../src/orgchain/store.js';
import { act, gasCheck, lastHolding, lineageState, minerOf, seedSample, send } from '../src/orgchain/actions.js';
import { FEE, PayWallet, REWARD, SAT, balances, brief, btc, ledger, most, owners, payerOf, satsOf, tankOf, tanks } from '../src/orgchain/money.js';
import { keyOf } from '../src/orgchain/keys.js';
const TOPUP = 10_000 + FEE; // what one gas top-up takes from a node's money
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: balances now (or at a beat), and the ledger as [kind, beat, from, to, amount].
function money(lab, upTo) {
const events = lineageEvents(new MetadataIndex(lab.chain)), live = reduce(events, { pending: true });
const owner = owners(lab.keys, events, live), name = id => live.nodes.get(id)?.name ?? null;
return { live, now: Object.fromEntries([...balances(lab.chain, owner, upTo)].map(([id, v]) => [name(id), v])),
log: ledger(lab.chain, owner, tanks(lab.keys, events, live)).map(m => [m.kind, m.beat, name(m.from), name(m.to), m.amount]) };
}
test('the toy chain mines to SegWit, splits a coinbase, 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);
chain.mineTo([getVout(REWARD / 2, a.script), getVout(REWARD / 2, b.script)]);
assert.throws(() => chain.mineTo([getVout(REWARD, a.script), getVout(1000, b.script)]), /Invalid local coinbase/);
assert.equal(chain.blocks.length, 3);
const copy = new LocalChain(chain.snapshot());
assert.deepEqual([copy.balance(a.script), copy.balance(b.script)], [2.5 * REWARD - SAT - FEE, SAT + REWARD / 2]);
});
test('ordinary spends take up to eight inputs and outputs, and a key-path signature may be SINGLE|ANYONECANPAY', () => {
const chain = new LocalChain(), seller = TestWallet.create(), buyer = TestWallet.create();
chain.mine(seller.script);
chain.mine(buyer.script);
const [s] = chain.coins(seller.script), [b] = chain.coins(buyer.script), vin = c => getVin(c.txid, c.vout, c.value, c.scriptPubKey);
const fan = n => { const tx = Tx.create({ vin: [vin(b)], vout: Array.from({ length: n }, (_, i) => getVout(i < n - 1 ? SAT : b.value - (n - 1) * SAT - FEE, buyer.script)) }); tx.vin[0].witness = [buyer.sign(tx)]; return Tx.encode(tx).hex; };
assert.throws(() => chain.submitPackage([fan(9)]), /Unsupported transaction shape/);
chain.submitPackage([fan(8)]);
assert.equal(chain.coins(buyer.script).length, 8);
// A sell order: the seller signs its coin and the output it wants paid, and nothing else; a buyer fills it later.
const order = Tx.create({ vin: [vin(s)], vout: [getVout(s.value - FEE, seller.script)] });
const sig = Signer.taproot.sign(seller.secret, order, 0, { sigflag: 0x83 }).hex;
assert.match(sig, /^[0-9a-f]{128}83$/);
const [c] = chain.coins(buyer.script), fill = (paid = s.value - FEE) => {
const tx = Tx.create({ vin: [vin(s), vin(c)], vout: [getVout(paid, seller.script), getVout(c.value - (paid - (s.value - FEE)), buyer.script)] });
tx.vin[0].witness = [sig];
tx.vin[1].witness = [buyer.sign(tx, undefined, 1)];
return Tx.encode(tx).hex;
};
assert.throws(() => chain.submitPackage([fill(s.value - FEE - 1000)]), /Invalid signature/); // the seller's output is fixed
chain.submitPackage([fill()]);
chain.mine(buyer.script);
assert.equal(chain.balance(seller.script), REWARD - FEE);
});
test('every act pays its own gas, and an unfunded node is stopped before anything is signed', () => {
const lab = emptyLab();
const acme = act(lab, 'found', null, { name: 'acme' }), ops = act(lab, 'birth', acme, { name: 'ops' });
const before = encodeLab(lab);
assert.throws(() => act(lab, 'birth', ops, { name: 'bot', kind: 'agent' }), /^Error: ops has no test BTC for gas\. acme can send some\.$/);
assert.throws(() => act(lab, 'decide', ops, { title: 'Ship it' }), /ops has no test BTC for gas/);
assert.equal(encodeLab(lab), before);
send(lab, acme, ops, 12.5 * SAT);
const bot = act(lab, 'birth', ops, { name: 'bot', kind: 'agent' });
send(lab, ops, bot, SAT);
const { now, log } = money(lab);
assert.deepEqual(now, { acme: 5 * REWARD - 12.5 * SAT - TOPUP - FEE, ops: 11.5 * SAT - TOPUP - FEE, bot: SAT });
assert.deepEqual(log.filter(([kind]) => kind !== 'mine'), [['gas', 3, 'acme', 'acme', 10_000], ['pay', 4, 'acme', 'ops', 12.5 * SAT],
['gas', 5, 'ops', 'ops', 10_000], ['pay', 6, 'ops', 'bot', SAT]]);
const s = lineageState(lab), tank = name => tankOf(lab.chain, keyOf(lab.keys, s, [...s.nodes.values()].find(n => n.name === name)).wallet);
assert.deepEqual([tank('acme'), tank('ops')], [7830, 7500]); // a birth burns three fees and hands its postage to the child
assert.equal(gasCheck(lab, s, 'decide', bot, {}), '');
const opsKey = payerOf(lab.keys, s, s.nodes.get(ops));
assert.equal(most(lab.chain.coins(opsKey.script)), 11.5 * SAT - TOPUP - 2 * FEE);
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, ops, bot, 11.5 * SAT - TOPUP - 2 * FEE - 100),
/^Error: Sending 11\.499884 tBTC would leave 100 sats of change, under the 330-sat floor\. Send 11\.499885 tBTC, or at least 230 sats less\.$/);
});
test('beats split across holdings by hashrate, or go whole to the last act', () => {
const lab = emptyLab();
const acme = act(lab, 'found', null, { name: 'acme' }), beta = act(lab, 'found', null, { name: 'beta' });
const mined = beat => money(lab).log.filter(([kind, b]) => kind === 'mine' && b === beat).map(([, , , to, amount]) => [to, amount]);
assert.deepEqual(mined(2), [['acme', REWARD]]);
assert.deepEqual(mined(3), [['acme', REWARD / 2], ['beta', REWARD / 2]]); // beta's founding beat already splits
setRigs(lab, acme, 3);
act(lab, 'decide', beta, { title: 'Mine more' });
assert.deepEqual(mined(4), [['acme', 37.5 * SAT], ['beta', 12.5 * SAT]]);
setRigs(lab, beta, 0);
act(lab, 'decide', acme, { title: 'Alone' });
assert.deepEqual(mined(5), [['acme', REWARD]]);
setRule(lab, 'last');
setRigs(lab, beta, 1);
act(lab, 'decide', beta, { title: 'Clicked' });
assert.deepEqual(mined(6), [['beta', REWARD]]);
assert.equal(minerOf(lab, acme).length, 1);
setTempo(lab, 30, 1000);
act(lab, 'decide', acme, { title: 'First' }, 2000);
act(lab, 'decide', beta, { title: 'Last in' }, 3000);
assert.equal(lastHolding(lab), beta);
assert.equal(tick(lab, 32000, minerOf(lab, lastHolding(lab))), true);
assert.deepEqual(mined(7), [['beta', REWARD]]); // the race for a timed beat: whoever acted last takes it
assert.equal(lastHolding(lab), null);
setTempo(lab, 'each');
assert.deepEqual(decodeLab(encodeLab(lab)).mining, { rule: 'last', rigs: { [acme]: 3 } });
assert.throws(() => miningPreference({ rule: 'luck' }), /Invalid mining rule/);
assert.throws(() => miningPreference({ rigs: { [acme]: 10 } }), /Invalid hashrate/);
const three = emptyLab();
for (const name of ['a', 'b', 'c']) act(three, 'found', null, { name });
assert.deepEqual(money(three).log.filter(([kind, b]) => kind === 'mine' && b === 4).map(m => m[4]), [1666666666, 1666666666, 1666666668]);
});
test('sends cross holdings and cue at a timed tempo; closed nodes neither send nor receive', () => {
const lab = emptyLab();
const acme = act(lab, 'found', null, { name: 'acme' }), ops = act(lab, 'birth', acme, { name: 'ops' });
const beta = act(lab, 'found', null, { name: 'beta' });
send(lab, acme, beta, 2 * SAT);
assert.deepEqual(money(lab).log.at(-1), ['pay', 5, 'acme', 'beta', 2 * SAT]);
setTempo(lab, 10, 1000);
send(lab, beta, ops, SAT, 2000);
assert.deepEqual(lab.clock, { tempo: 10, nextAt: 12000 });
assert.deepEqual(money(lab).log.at(-1), ['pay', null, 'beta', 'ops', SAT]);
assert.equal(money(lab).now.ops, SAT); // now includes what is cued
assert.equal(money(lab, 5).now.ops, undefined);
assert.equal(tick(lab, 12000, minerOf(lab, acme)), true);
assert.deepEqual(money(lab).log.slice(-3).map(m => m.slice(0, 4)), [['mine', 6, null, 'acme'], ['mine', 6, null, 'beta'], ['pay', 6, 'beta', 'ops']]);
assert.equal(tick(lab, 99999, minerOf(lab, acme)), false); // nothing cued: no empty beats
setTempo(lab, 'each');
act(lab, 'close', ops, {});
assert.throws(() => send(lab, acme, ops, SAT), /ops is closed/);
assert.throws(() => send(lab, ops, beta, 1000), /ops is closed/);
assert.throws(() => send(lab, acme, acme, SAT), /cannot send to itself/);
assert.throws(() => send(lab, acme, beta, 51 * SAT), /at most 50 test BTC/);
assert.throws(() => send(lab, acme, beta, 100), /at least 330 sats/);
act(lab, 'close', beta, {});
assert.equal(minerOf(lab, acme).length, 1); // a closed holding stops mining
});
test('graduation moves money to the new keys, and the graduate pays its own claim; 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 - TOPUP, 'well-known': 8 * SAT - FEE - TOPUP,
lineage: 2 * SAT - FEE - TOPUP, time: 38.5 * SAT - 2 * FEE - 3 * TOPUP, sol: SAT - TOPUP, luna: 0.5 * SAT });
assert.deepEqual(log.slice(-2), [['move', 7, 'lineage', 'lineage', 2 * SAT - FEE], ['gas', 7, 'lineage', 'lineage', 10_000]]);
const lineage = [...live.nodes.values()].find(n => n.name === 'lineage');
assert.equal(lab.chain.balance(payerOf(lab.keys, live, lineage).script), 2 * SAT - FEE - TOPUP);
assert.equal(money(lab, 5).now.lineage, 2 * SAT); // before graduating, at its derived address
const solo = emptyLab();
const root = act(solo, 'found', null, { name: 'acme' }), kid = act(solo, 'birth', root, { name: 'kid' });
assert.throws(() => act(solo, 'graduate', root, { of: kid }), /^Error: kid needs test BTC of its own to claim its new root\. Send it some first\.$/);
});
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']);
});