2026-10-14 22:25:38 -07:00
|
|
|
import test from 'node:test';
|
|
|
|
|
import assert from 'node:assert/strict';
|
2026-10-15 00:32:29 -07:00
|
|
|
import { Signer, Tx } from '@cmdcode/tapscript';
|
|
|
|
|
import { TestWallet } from '../src/adapters/wallet.js';
|
2026-10-14 22:25:38 -07:00
|
|
|
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';
|
2026-10-14 22:25:38 -07:00
|
|
|
import { getVin, getVout } from '../src/upstream/primitives.js';
|
|
|
|
|
import { MetadataIndex } from '../src/adapters/metadata-index.js';
|
|
|
|
|
import { lineageEvents, reduce } from '../src/orgchain/lineage.js';
|
2026-10-14 23:16:03 -07:00
|
|
|
import { decodeLab, emptyLab, encodeLab, miningPreference, setRigs, setRule, setTempo, tick } from '../src/orgchain/store.js';
|
2026-10-14 23:21:33 -07:00
|
|
|
import { act, gasCheck, lastHolding, lineageState, minerOf, seedSample, send } from '../src/orgchain/actions.js';
|
2026-10-14 23:16:03 -07:00
|
|
|
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';
|
2026-10-14 22:25:38 -07:00
|
|
|
|
2026-10-14 23:16:03 -07:00
|
|
|
const TOPUP = 10_000 + FEE; // what one gas top-up takes from a node's money
|
2026-10-14 22:25:38 -07:00
|
|
|
const key = byte => { const secret = byte.repeat(32); return new PayWallet(secret, bytesToHex(secp256k1.getPublicKey(secret, true)), "m/84'/1'/0'/0/0"); };
|
2026-10-14 23:16:03 -07:00
|
|
|
// A lab's money as the app sees it: balances now (or at a beat), and the ledger as [kind, beat, from, to, amount].
|
2026-10-14 22:25:38 -07:00
|
|
|
function money(lab, upTo) {
|
2026-10-14 23:16:03 -07:00
|
|
|
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]) };
|
2026-10-14 22:25:38 -07:00
|
|
|
}
|
|
|
|
|
|
2026-10-14 23:16:03 -07:00
|
|
|
test('the toy chain mines to SegWit, splits a coinbase, and moves P2WPKH spends signed by the key they pay', () => {
|
2026-10-14 22:25:38 -07:00
|
|
|
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);
|
2026-10-14 23:16:03 -07:00
|
|
|
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);
|
2026-10-14 22:25:38 -07:00
|
|
|
const copy = new LocalChain(chain.snapshot());
|
2026-10-14 23:16:03 -07:00
|
|
|
assert.deepEqual([copy.balance(a.script), copy.balance(b.script)], [2.5 * REWARD - SAT - FEE, SAT + REWARD / 2]);
|
2026-10-14 22:25:38 -07:00
|
|
|
});
|
|
|
|
|
|
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', () => {
|
2026-10-15 00:32:29 -07:00
|
|
|
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);
|
2026-10-15 00:32:29 -07:00
|
|
|
// 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);
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-14 23:16:03 -07:00
|
|
|
test('every act pays its own gas, and an unfunded node is stopped before anything is signed', () => {
|
2026-10-14 22:25:38 -07:00
|
|
|
const lab = emptyLab();
|
|
|
|
|
const acme = act(lab, 'found', null, { name: 'acme' }), ops = act(lab, 'birth', acme, { name: 'ops' });
|
|
|
|
|
const before = encodeLab(lab);
|
2026-10-14 23:16:03 -07:00
|
|
|
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/);
|
2026-10-14 22:25:38 -07:00
|
|
|
assert.equal(encodeLab(lab), before);
|
2026-10-14 23:16:03 -07:00
|
|
|
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\.$/);
|
2026-10-14 22:25:38 -07:00
|
|
|
});
|
|
|
|
|
|
2026-10-14 23:16:03 -07:00
|
|
|
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);
|
2026-10-14 23:21:33 -07:00
|
|
|
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');
|
2026-10-14 23:16:03 -07:00
|
|
|
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', () => {
|
2026-10-14 22:25:38 -07:00
|
|
|
const lab = emptyLab();
|
|
|
|
|
const acme = act(lab, 'found', null, { name: 'acme' }), ops = act(lab, 'birth', acme, { name: 'ops' });
|
2026-10-14 23:16:03 -07:00
|
|
|
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]);
|
2026-10-14 22:25:38 -07:00
|
|
|
setTempo(lab, 10, 1000);
|
2026-10-14 23:16:03 -07:00
|
|
|
send(lab, beta, ops, SAT, 2000);
|
2026-10-14 22:25:38 -07:00
|
|
|
assert.deepEqual(lab.clock, { tempo: 10, nextAt: 12000 });
|
2026-10-14 23:16:03 -07:00
|
|
|
assert.deepEqual(money(lab).log.at(-1), ['pay', null, 'beta', 'ops', SAT]);
|
2026-10-14 22:25:38 -07:00
|
|
|
assert.equal(money(lab).now.ops, SAT); // now includes what is cued
|
2026-10-14 23:16:03 -07:00
|
|
|
assert.equal(money(lab, 5).now.ops, undefined);
|
2026-10-14 22:25:38 -07:00
|
|
|
assert.equal(tick(lab, 12000, minerOf(lab, acme)), true);
|
2026-10-14 23:16:03 -07:00
|
|
|
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']]);
|
2026-10-14 22:25:38 -07:00
|
|
|
assert.equal(tick(lab, 99999, minerOf(lab, acme)), false); // nothing cued: no empty beats
|
2026-10-14 23:16:03 -07:00
|
|
|
setTempo(lab, 'each');
|
2026-10-14 22:25:38 -07:00
|
|
|
act(lab, 'close', ops, {});
|
|
|
|
|
assert.throws(() => send(lab, acme, ops, SAT), /ops is closed/);
|
2026-10-14 23:16:03 -07:00
|
|
|
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
|
2026-10-14 22:25:38 -07:00
|
|
|
});
|
|
|
|
|
|
2026-10-14 23:16:03 -07:00
|
|
|
test('graduation moves money to the new keys, and the graduate pays its own claim; the sample shows it', () => {
|
2026-10-14 22:25:38 -07:00
|
|
|
const lab = emptyLab();
|
|
|
|
|
seedSample(lab);
|
|
|
|
|
const { now, log, live } = money(lab);
|
2026-10-15 10:48:28 -07:00
|
|
|
// 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.
|
2026-10-15 12:15:48 -07:00
|
|
|
assert.deepEqual(now, { yolopreneur: 275 * SAT - 50 * SAT - 2 * FEE - 2 * TOPUP, 'well-known': 8 * SAT - FEE - TOPUP,
|
2026-10-15 10:48:28 -07:00
|
|
|
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]]);
|
2026-10-14 22:25:38 -07:00
|
|
|
const lineage = [...live.nodes.values()].find(n => n.name === 'lineage');
|
2026-10-15 10:48:28 -07:00
|
|
|
assert.equal(lab.chain.balance(payerOf(lab.keys, live, lineage).script), 27 * SAT - FEE - 2 * TOPUP);
|
2026-10-14 23:16:03 -07:00
|
|
|
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\.$/);
|
2026-10-14 22:25:38 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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']);
|
|
|
|
|
});
|