2026-10-15 00:32:29 -07:00
|
|
|
import test from 'node:test';
|
|
|
|
|
import assert from 'node:assert/strict';
|
|
|
|
|
import { MetadataIndex } from '../src/adapters/metadata-index.js';
|
|
|
|
|
import { lineageEvents, reduce } from '../src/orgchain/lineage.js';
|
|
|
|
|
import { decodeLab, emptyLab, encodeLab, setRule, setTempo } from '../src/orgchain/store.js';
|
2026-10-15 13:32:29 -07:00
|
|
|
import { accept, act, deposit, lastHolding, lineageState, minerOf, offer, seedSample, send, take, withdraw } from '../src/orgchain/actions.js';
|
2026-10-15 00:32:29 -07:00
|
|
|
import { FEE, REWARD, SAT, balances, ledger, most, owners, payerOf, tanks } from '../src/orgchain/money.js';
|
2026-10-15 13:32:29 -07:00
|
|
|
import { askId, askStatus, dealsOf, holderOfItem, offerStatus, offerWhy } from '../src/orgchain/offers.js';
|
2026-10-15 00:32:29 -07:00
|
|
|
|
|
|
|
|
const TOPUP = 10_000 + FEE; // what one gas top-up takes from a node's money
|
|
|
|
|
// A lab's money as the app sees it, deals named by the lab's offers: balances by name, 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;
|
2026-10-15 13:32:29 -07:00
|
|
|
const deals = dealsOf(lab);
|
2026-10-15 00:32:29 -07:00
|
|
|
return { events, 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), deals).map(m => [m.kind, m.beat, name(m.from), name(m.to), m.amount]) };
|
|
|
|
|
}
|
|
|
|
|
const holder = (lab, item) => { const { events, live } = money(lab); return holderOfItem(lab, events, live, item)?.node.name ?? null; };
|
|
|
|
|
const status = (lab, o) => offerStatus(lab, lineageState(lab), o);
|
|
|
|
|
|
|
|
|
|
test('make records an item its maker holds, and the reducer vets it', () => {
|
|
|
|
|
const lab = emptyLab();
|
|
|
|
|
const acme = act(lab, 'found', null, { name: 'acme' }), bot = act(lab, 'birth', acme, { name: 'bot', kind: 'agent' });
|
|
|
|
|
send(lab, acme, bot, SAT);
|
|
|
|
|
const kit = act(lab, 'make', bot, { name: 'kit', note: ' A starter kit. ' });
|
|
|
|
|
const s = lineageState(lab);
|
|
|
|
|
assert.deepEqual(s.items.get(kit), { id: kit, name: 'kit', note: 'A starter kit.', maker: bot, beat: 5 });
|
|
|
|
|
assert.deepEqual(s.nodes.get(bot).made, [kit]);
|
|
|
|
|
assert.equal(s.log.at(-1).root, acme);
|
|
|
|
|
assert.equal(holder(lab, kit), 'bot');
|
|
|
|
|
assert.throws(() => act(lab, 'make', bot, { name: 'kit' }), /^Error: There is already a kit by bot\.$/);
|
|
|
|
|
assert.throws(() => act(lab, 'make', bot, { name: 'Kit!' }), /^Error: Names are 1-40 lowercase/);
|
|
|
|
|
act(lab, 'make', acme, { name: 'kit' }); // another maker may use the name
|
|
|
|
|
act(lab, 'close', bot, {});
|
|
|
|
|
assert.throws(() => act(lab, 'make', bot, { name: 'more' }), /^Error: The maker is closed\.$/);
|
|
|
|
|
assert.equal(lineageState(lab).items.size, 2);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('an offer stands until the holder accepts; then the item and the price change hands in one transaction', () => {
|
|
|
|
|
const lab = emptyLab();
|
|
|
|
|
const acme = act(lab, 'found', null, { name: 'acme' }), beta = act(lab, 'found', null, { name: 'beta' });
|
|
|
|
|
const kit = act(lab, 'make', acme, { name: 'kit', note: 'A starter kit.' });
|
|
|
|
|
const before = money(lab).now, height = lab.chain.blocks.length;
|
|
|
|
|
const o = offer(lab, kit, beta, 2 * SAT);
|
|
|
|
|
assert.equal(lab.chain.mempool.length, 0); // an offer is signatures, not a transaction
|
|
|
|
|
assert.equal(lab.chain.blocks.length, height);
|
|
|
|
|
assert.equal(status(lab, o), 'open');
|
|
|
|
|
assert.throws(() => offer(lab, kit, beta, SAT), /^Error: beta already has an open offer on kit\. Withdraw it first\.$/);
|
|
|
|
|
assert.throws(() => offer(lab, kit, acme, SAT), /^Error: acme already holds kit\.$/);
|
|
|
|
|
assert.throws(() => offer(lab, kit, beta, 100), /^Error: Offer nothing, or at least 330 sats\.$/);
|
|
|
|
|
assert.throws(() => offer(lab, kit, beta, 51 * SAT), /at most 50 test BTC/);
|
|
|
|
|
accept(lab, o.txid);
|
|
|
|
|
assert.equal(lab.chain.blocks.length, height + 1);
|
|
|
|
|
assert.equal(holder(lab, kit), 'beta');
|
|
|
|
|
const after = money(lab);
|
|
|
|
|
assert.deepEqual(after.log.at(-1), ['deal', height + 1, 'beta', 'acme', 2 * SAT]);
|
|
|
|
|
assert.equal(after.now.acme, before.acme + 2 * SAT + REWARD / 2);
|
|
|
|
|
assert.equal(after.now.beta, before.beta - 2 * SAT - FEE + REWARD / 2);
|
|
|
|
|
const { tx } = lab.chain.transactions.get(o.txid);
|
|
|
|
|
assert.deepEqual(tx.vout.map(v => Number(v.value)), [1000, 2 * SAT, o.change]);
|
|
|
|
|
assert.equal(tx.vin[0].witness.length, 1); // the holder's Taproot signature on the item; the buyer's SegWit ones follow
|
|
|
|
|
assert.ok(tx.vin.slice(1).every(i => i.witness.length === 2));
|
|
|
|
|
assert.equal(status(lab, o), 'done');
|
|
|
|
|
assert.throws(() => accept(lab, o.txid), /already accepted/);
|
|
|
|
|
assert.throws(() => withdraw(lab, o.txid), /on the chain now/);
|
|
|
|
|
const back = offer(lab, kit, acme, 0); // a free claim back: the buyer pays only the fee
|
|
|
|
|
accept(lab, back.txid);
|
|
|
|
|
assert.equal(holder(lab, kit), 'acme');
|
|
|
|
|
assert.deepEqual(money(lab).log.at(-1), ['deal', height + 2, 'acme', 'beta', 0]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('payments spend unpromised coins first; an offer goes stale when a coin it promised moves', () => {
|
|
|
|
|
const lab = emptyLab();
|
|
|
|
|
const acme = act(lab, 'found', null, { name: 'acme' }), ops = act(lab, 'birth', acme, { name: 'ops' });
|
|
|
|
|
send(lab, acme, ops, SAT);
|
|
|
|
|
send(lab, acme, ops, SAT);
|
|
|
|
|
const kit = act(lab, 'make', acme, { name: 'kit' });
|
|
|
|
|
const o = offer(lab, kit, ops, 0.5 * SAT);
|
|
|
|
|
send(lab, ops, acme, 0.2 * SAT);
|
|
|
|
|
send(lab, ops, acme, 0.7 * SAT);
|
|
|
|
|
assert.equal(status(lab, o), 'open'); // both sends spent the coin the offer did not promise
|
|
|
|
|
send(lab, ops, acme, 0.5 * SAT);
|
|
|
|
|
assert.equal(status(lab, o), 'stale');
|
|
|
|
|
assert.equal(offerWhy(lab, lineageState(lab), o), 'ops has spent a coin the offer promised');
|
|
|
|
|
assert.throws(() => accept(lab, o.txid), /^Error: This offer is stale: ops has spent a coin the offer promised\.$/);
|
|
|
|
|
withdraw(lab, o.txid);
|
|
|
|
|
assert.deepEqual(lab.offers, []);
|
|
|
|
|
const beta = act(lab, 'found', null, { name: 'beta' }), first = offer(lab, kit, ops, 0.1 * SAT), second = offer(lab, kit, beta, SAT);
|
|
|
|
|
accept(lab, second.txid);
|
|
|
|
|
assert.equal(offerWhy(lab, lineageState(lab), first), 'kit has moved since');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a graduation leaves items at the old key: priced offers to it go stale, and it still signs for what it held', () => {
|
|
|
|
|
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, ops, 2 * SAT);
|
|
|
|
|
const kit = act(lab, 'make', ops, { name: 'kit' });
|
|
|
|
|
const priced = offer(lab, kit, beta, SAT);
|
|
|
|
|
act(lab, 'graduate', acme, { of: ops });
|
|
|
|
|
assert.equal(offerWhy(lab, lineageState(lab), priced), 'ops has new keys since'); // its price would reach ops's old address
|
|
|
|
|
assert.equal(holder(lab, kit), 'ops'); // the item stays at ops's derived key, which the lab can still sign with
|
|
|
|
|
withdraw(lab, priced.txid);
|
|
|
|
|
const again = offer(lab, kit, beta, SAT);
|
|
|
|
|
accept(lab, again.txid);
|
|
|
|
|
assert.equal(holder(lab, kit), 'beta');
|
|
|
|
|
assert.deepEqual(money(lab).log.at(-1).slice(2), ['beta', 'ops', SAT]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a cued deal counts for the holder that accepted it, and offers survive a backup', () => {
|
|
|
|
|
const lab = emptyLab();
|
|
|
|
|
const acme = act(lab, 'found', null, { name: 'acme' }), beta = act(lab, 'found', null, { name: 'beta' });
|
|
|
|
|
const kit = act(lab, 'make', acme, { name: 'kit' });
|
|
|
|
|
setRule(lab, 'last');
|
|
|
|
|
setTempo(lab, 30, 1000);
|
|
|
|
|
const o = offer(lab, kit, beta, SAT);
|
|
|
|
|
const copy = decodeLab(encodeLab(lab));
|
|
|
|
|
assert.deepEqual(copy.offers, lab.offers);
|
|
|
|
|
accept(copy, o.txid, 2000);
|
|
|
|
|
assert.equal(lastHolding(copy), acme);
|
|
|
|
|
assert.deepEqual(money(copy).log.at(-1), ['deal', null, 'beta', 'acme', SAT]);
|
|
|
|
|
const bad = JSON.parse(encodeLab(lab));
|
|
|
|
|
bad.offers[0].price = 5;
|
|
|
|
|
assert.throws(() => decodeLab(JSON.stringify(bad)), /Invalid offer/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('on mainnet no holding mines: beats pay the lab wallet, and holdings are funded from it', () => {
|
|
|
|
|
const lab = emptyLab();
|
|
|
|
|
setRule(lab, 'none');
|
|
|
|
|
const acme = act(lab, 'found', null, { name: 'acme' });
|
|
|
|
|
assert.equal(minerOf(lab, acme), null);
|
|
|
|
|
assert.deepEqual(money(lab).now, {});
|
|
|
|
|
assert.throws(() => act(lab, 'birth', acme, { name: 'ops' }), /^Error: acme has no test BTC for gas\. Fund it from the lab wallet\.$/);
|
|
|
|
|
deposit(lab, acme, 5 * SAT);
|
|
|
|
|
assert.deepEqual(money(lab).log, [['fund', 3, null, 'acme', 5 * SAT]]);
|
|
|
|
|
const ops = act(lab, 'birth', acme, { name: 'ops' });
|
|
|
|
|
assert.throws(() => deposit(lab, ops, SAT), /Pick a holding to fund/);
|
|
|
|
|
assert.throws(() => deposit(lab, acme, 51 * SAT), /at most 50 test BTC/);
|
|
|
|
|
setTempo(lab, 'manual');
|
|
|
|
|
const height = lab.chain.blocks.length;
|
|
|
|
|
for (let i = 0; i < 4; i++) deposit(lab, acme, REWARD);
|
|
|
|
|
assert.ok(lab.chain.blocks.length > height); // the lab wallet runs short, and a beat refills it
|
|
|
|
|
assert.equal(money(lab).now.acme, 5 * SAT + 4 * REWARD - TOPUP);
|
|
|
|
|
assert.equal(decodeLab(encodeLab(lab)).mining.rule, 'none');
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-15 14:51:52 -07:00
|
|
|
test('a payment can spend up to 64 coins', () => {
|
2026-10-15 00:32:29 -07:00
|
|
|
const lab = emptyLab();
|
|
|
|
|
const acme = act(lab, 'found', null, { name: 'acme' }), ops = act(lab, 'birth', acme, { name: 'ops' });
|
|
|
|
|
for (let i = 0; i < 3; i++) send(lab, acme, ops, 20 * SAT);
|
|
|
|
|
const txid = send(lab, ops, acme, REWARD); // three coins: more than the two the chain used to take
|
|
|
|
|
assert.equal(lab.chain.transactions.get(txid).tx.vin.length, 3);
|
|
|
|
|
for (let i = 0; i < 9; i++) send(lab, acme, ops, SAT);
|
|
|
|
|
const wallet = payerOf(lab.keys, lineageState(lab), lineageState(lab).nodes.get(ops));
|
|
|
|
|
const small = lab.chain.coins(wallet.script).filter(c => c.value === SAT);
|
2026-10-15 14:51:52 -07:00
|
|
|
assert.deepEqual([most(small), most(small, 8)], [9 * SAT - FEE, 8 * SAT - FEE]);
|
2026-10-15 00:32:29 -07:00
|
|
|
});
|
|
|
|
|
|
2026-10-15 13:32:29 -07:00
|
|
|
test('the sample ends on both sides of a deal: lineage bids for the launch kit, and well-known asks for mailr', () => {
|
2026-10-15 00:32:29 -07:00
|
|
|
const lab = emptyLab();
|
|
|
|
|
seedSample(lab);
|
2026-10-15 13:32:29 -07:00
|
|
|
const [o] = lab.offers, [a] = lab.asks, s = lineageState(lab), name = id => s.nodes.get(id).name;
|
2026-10-15 00:32:29 -07:00
|
|
|
assert.deepEqual([s.items.get(o.item).name, name(o.buyer), name(o.seller), o.price, status(lab, o)], ['launch-kit', 'lineage', 'sol', 0.1 * SAT, 'open']);
|
2026-10-15 13:32:29 -07:00
|
|
|
assert.deepEqual([name(a.lot), name(a.seller), a.price, askStatus(lab, s, a)], ['mailr', 'well-known', SAT, 'open']);
|
2026-10-15 00:32:29 -07:00
|
|
|
accept(lab, o.txid);
|
|
|
|
|
assert.equal(holder(lab, o.item), 'lineage');
|
|
|
|
|
assert.deepEqual(money(lab).log.at(-1), ['deal', 8, 'lineage', 'sol', 0.1 * SAT]);
|
2026-10-15 13:32:29 -07:00
|
|
|
take(lab, askId(a), o.buyer);
|
|
|
|
|
const t = lineageState(lab), mailr = t.nodes.get(a.lot);
|
|
|
|
|
assert.deepEqual([name(mailr.parent), mailr.root, mailr.index, mailr.moves.length], ['lineage', o.buyer, 0, 1]);
|
|
|
|
|
assert.deepEqual(money(lab).log.filter(m => m[0] === 'deal').at(-1), ['deal', 9, 'lineage', 'well-known', SAT]);
|
2026-10-15 00:32:29 -07:00
|
|
|
});
|