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
180 lines
12 KiB
JavaScript
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']);
|
|
});
|