Files
orgchain/tests/money.test.js
T

186 lines
12 KiB
JavaScript
Raw Normal View History

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';
2026-10-15 14:51:52 -07:00
import { LocalChain, MAX_IO } 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]);
});
2026-10-15 14:51:52 -07:00
test('ordinary spends take up to 64 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);
2026-10-15 14:51:52 -07:00
const part = SAT / 2, fan = n => {
const tx = Tx.create({ vin: [vin(b)], vout: Array.from({ length: n }, (_, i) => getVout(i < n - 1 ? part : b.value - (n - 1) * part - FEE, buyer.script)) });
tx.vin[0].witness = [buyer.sign(tx)];
return Tx.encode(tx).hex;
};
assert.equal(MAX_IO, 64);
assert.throws(() => chain.submitPackage([fan(MAX_IO + 1)]), /Unsupported transaction shape/);
chain.submitPackage([fan(MAX_IO)]);
assert.equal(chain.coins(buyer.script).length, MAX_IO);
// 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);
// lineage graduates at beat 7 into a holding of its own, so it mines half of that beat. Its keyring at beat 6 filled
// its old gas tank, which stays with its old keys; its acts after graduating fill a new one.
assert.deepEqual(now, { yolopreneur: 275 * SAT - 50 * SAT - 2 * FEE - 2 * TOPUP, 'well-known': 8 * SAT - FEE - TOPUP,
lineage: 27 * SAT - FEE - 2 * 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 - TOPUP - 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), 27 * SAT - FEE - 2 * 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']);
});