Files
orgchain/tests/holdings.test.js
T

151 lines
11 KiB
JavaScript
Raw Normal View History

2026-10-15 14:51:52 -07:00
import test from 'node:test';
import assert from 'node:assert/strict';
import { MetadataIndex } from '../src/adapters/metadata-index.js';
import { holderCoin } from '../src/adapters/handoff.js';
import { lineageEvents, reduce, summary } from '../src/orgchain/lineage.js';
import { decodeLab, emptyLab, encodeLab, setRule } from '../src/orgchain/store.js';
import { accept, act, adopt, deposit, lineageState, offer, sell, send, take, unlist, withdraw } from '../src/orgchain/actions.js';
import { SAT, balances, ledger, miners, owners, tanks } from '../src/orgchain/money.js';
import { holderOf, keyOf, scriptHex } from '../src/orgchain/keys.js';
import { askId, decodeOffer, dealsOf, holderOfItem, offerWhy } from '../src/orgchain/offers.js';
// Whole trees change hands: a holding, sold whole by its owner (the lab wallet plays it), and an org with its derived tree
// and the items they hold. Under the 'none' rule no holding mines, so every balance moves only by what the test does.
function money(lab) {
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 { now: Object.fromEntries([...balances(lab.chain, owner)].map(([id, v]) => [name(id), v])),
log: ledger(lab.chain, owner, tanks(lab.keys, events, live), dealsOf(lab)).map(m => [m.kind, m.beat, name(m.from), name(m.to), m.amount]) };
}
const holder = (lab, id) => { const events = lineageEvents(new MetadataIndex(lab.chain)); return holderOfItem(lab, events, lineageState(lab), id)?.node.name ?? null; };
const alone = (lab, id) => holderCoin(lab.chain, id).inscriptions.length === 1;
const errors = (list, why) => list.forEach(run => assert.throws(run, e => e.message === why, why));
function twoHoldings() {
const lab = emptyLab();
setRule(lab, 'none');
const acme = act(lab, 'found', null, { name: 'acme' }), beta = act(lab, 'found', null, { name: 'beta' });
deposit(lab, acme, 10 * SAT);
deposit(lab, beta, 10 * SAT);
return { lab, acme, beta };
}
test('a holding sells whole on an ask: its owner lists it cash-free, its tree travels as one coin, and its buyer adopts it', () => {
const { lab, acme, beta } = twoHoldings(), ops = act(lab, 'birth', acme, { name: 'ops' });
send(lab, acme, ops, SAT);
const bot = act(lab, 'birth', ops, { name: 'bot', kind: 'agent' });
send(lab, ops, bot, SAT / 2);
const kit = act(lab, 'make', ops, { name: 'kit' }), tool = act(lab, 'make', bot, { name: 'tool' });
act(lab, 'keyring', ops, {});
const wallet = scriptHex(lab.wallet.script);
// Its owner lists it: the tree's money goes to the lab wallet, and its five inscriptions become one coin at acme's key.
const a = sell(lab, acme, 5 * SAT), listed = lab.chain.blocks.length, m = money(lab);
assert.deepEqual([a.seller, a.pay, m.now.acme ?? 0, m.now.ops ?? 0, m.now.bot ?? 0], [null, wallet, 0, 0, 0]);
assert.deepEqual(m.log.filter(x => x[1] === listed).map(x => [x[0], x[2], x[3]]), [['pay', 'acme', null], ['pay', 'ops', null], ['pay', 'bot', null]]);
assert.deepEqual(lab.chain.utxos.get(askId(a)).inscriptions.map(i => i.id), [acme, ops, bot, kit, tool]);
assert.deepEqual([a.layout, holder(lab, kit)], [[[kit, ops], [tool, bot]], 'acme']);
assert.deepEqual(decodeLab(encodeLab(lab)).asks, lab.asks);
// Listed, the whole tree is frozen, and what's in it doesn't sell alone.
errors([() => act(lab, 'decide', bot, { title: 'x' }), () => send(lab, beta, ops, SAT), () => sell(lab, ops, SAT)],
'acme is listed for sale at 5 tBTC. Withdraw its ask first.');
errors([() => offer(lab, kit, beta, SAT)], "Kit goes with acme's tree while it changes hands.");
errors([() => offer(lab, ops, beta, SAT)], "Ops goes with acme's tree while it changes hands.");
// Withdrawn, the coin splits back: each inscription at its own key, each item with whoever held it.
unlist(lab, askId(a));
assert.deepEqual([acme, ops, bot, kit, tool].map(id => alone(lab, id)), [true, true, true, true, true]);
assert.deepEqual([holder(lab, kit), holder(lab, tool), holderOf(lab, lineageState(lab), bot).who], ['ops', 'bot', 'own']);
// Listed again, beta takes it: the deal, the handshake and the split, in one beat. acme is beta's first org now.
const b = sell(lab, acme, 5 * SAT);
take(lab, askId(b), beta);
const s = lineageState(lab), node = id => s.nodes.get(id), deal = lab.chain.transactions.get(b.taken.txid).tx;
assert.deepEqual([node(acme).kind, node(acme).parent, node(acme).root, node(acme).index, s.roots.map(r => r.name)], ['org', beta, beta, 0, ['beta']]);
assert.deepEqual([acme, ops, bot].map(id => [node(id).root, keyOf(lab.keys, s, node(id)).from, holderOf(lab, s, id).who, alone(lab, id)]),
[[beta, "beta root › 0'", 'own', true], [beta, "beta root › 0' › 0'", 'own', true], [beta, "beta root › 0' › 0'", 'own', true]]);
assert.deepEqual([holder(lab, kit), holder(lab, tool), alone(lab, kit), alone(lab, tool)], ['ops', 'bot', true, true]);
assert.deepEqual([node(ops).keyring.retired.cause, scriptHex(deal.vout[1].scriptPubKey)], ['move', wallet]); // the price went to its owner
assert.deepEqual(money(lab).log.filter(x => x[0] === 'deal'), [['deal', lab.chain.blocks.length, 'beta', null, 5 * SAT]]);
assert.equal(summary(s.log.find(e => e.op === 'join'), s), 'join acme to beta, a holding till now');
// It acts again as beta's, on beta's money.
send(lab, beta, acme, SAT);
act(lab, 'decide', acme, { title: 'Under new owners' });
assert.equal(lineageState(lab).nodes.get(acme).decisions[0].title, 'Under new owners');
});
test('an org sells with its tree on an offer: every coin it sits in goes in, one comes out, and it splits back once adopted', () => {
const { lab, acme, beta } = twoHoldings(), ops = act(lab, 'birth', acme, { name: 'ops' });
send(lab, acme, ops, SAT);
const bot = act(lab, 'birth', ops, { name: 'bot', kind: 'agent' }), sub = act(lab, 'birth', ops, { name: 'sub' });
send(lab, ops, bot, SAT / 2);
const tool = act(lab, 'make', bot, { name: 'tool' }), o = offer(lab, ops, beta, 2 * SAT);
assert.deepEqual([o.seller, o.holders.map(h => h.ids), o.layout], [acme, [[ops], [bot], [sub], [tool]], [[tool, bot]]]);
// An item bought into the tree since would stay behind, so the offer goes stale; so does a node of the tree acting.
const gift = act(lab, 'make', acme, { name: 'gift' });
take(lab, askId(sell(lab, gift, SAT / 10)), bot);
assert.equal(offerWhy(lab, lineageState(lab), o), "ops's tree has changed since");
withdraw(lab, o.txid);
const o2 = offer(lab, ops, beta, 2 * SAT);
assert.deepEqual(o2.layout, [[tool, bot], [gift, bot]]);
act(lab, 'decide', bot, { title: 'Busy' });
assert.equal(offerWhy(lab, lineageState(lab), o2), "bot's inscription has moved since");
withdraw(lab, o2.txid);
const o3 = offer(lab, ops, beta, 2 * SAT);
assert.deepEqual(decodeLab(encodeLab(lab)).offers, lab.offers);
// acme accepts: the tree's money goes home first, then its five coins go in and leave as one, at beta's key.
accept(lab, o3.txid);
const merged = lab.chain.utxos.get(`${o3.txid}:0`), m = money(lab);
assert.equal(lab.chain.transactions.get(o3.txid).tx.vin.length, 5 + o3.payers.length);
assert.deepEqual([merged.inscriptions.map(i => i.id), scriptHex(merged.scriptPubKey)], [[ops, bot, sub, tool, gift], o3.to]);
assert.deepEqual([m.now.ops ?? 0, m.now.bot ?? 0, m.log.at(-1)], [0, 0, ['deal', lab.chain.blocks.length, 'beta', 'acme', 2 * SAT]]);
errors([() => act(lab, 'decide', bot, { title: 'x' }), () => act(lab, 'graduate', acme, { of: ops }), () => act(lab, 'close', acme)],
'ops was sold to beta, which adopts it next.');
errors([() => offer(lab, sub, acme, SAT)], "Sub goes with ops's tree while it changes hands.");
// beta adopts it, and the coin splits back by the offer's layout, every inscription at its new key.
adopt(lab, ops, beta);
const s = lineageState(lab), node = id => s.nodes.get(id);
assert.deepEqual([ops, bot, sub].map(id => [node(id).root, keyOf(lab.keys, s, node(id)).from, holderOf(lab, s, id).who, alone(lab, id)]),
[[beta, "beta root › 0'", 'own', true], [beta, "beta root › 0'", 'own', true], [beta, "beta root › 0' › 0'", 'own', true]]);
assert.deepEqual([holder(lab, tool), holder(lab, gift), alone(lab, tool), alone(lab, gift)], ['bot', 'bot', true, true]);
});
test('a tree sells only whole and frozen: nothing in it listed alone, nothing bought left to adopt, nothing above acting on it', () => {
const { lab, acme, beta } = twoHoldings(), ops = act(lab, 'birth', acme, { name: 'ops' });
send(lab, acme, ops, SAT);
const sub = act(lab, 'birth', ops, { name: 'sub' }), kit = act(lab, 'make', ops, { name: 'kit' });
const ka = sell(lab, kit, SAT);
errors([() => sell(lab, ops, 2 * SAT), () => sell(lab, acme, 2 * SAT)], 'Kit is listed on its own at 1 tBTC. Withdraw its ask first.');
unlist(lab, askId(ka));
const sa = sell(lab, sub, SAT);
errors([() => sell(lab, ops, 2 * SAT), () => offer(lab, ops, beta, 2 * SAT)], 'Sub is listed on its own at 1 tBTC. Withdraw its ask first.');
unlist(lab, askId(sa));
const a = sell(lab, ops, 2 * SAT);
errors([() => act(lab, 'decide', sub, { title: 'x' }), () => act(lab, 'graduate', ops, { of: sub }), () => act(lab, 'graduate', acme, { of: ops }),
() => act(lab, 'close', acme), () => sell(lab, sub, SAT)], 'ops is listed for sale at 2 tBTC. Withdraw its ask first.');
unlist(lab, askId(a));
// ops buys an org of beta's and has not adopted it: its holding cannot sell until it does.
send(lab, acme, ops, SAT);
const far = act(lab, 'birth', beta, { name: 'far' });
accept(lab, offer(lab, far, ops, SAT / 10).txid);
errors([() => sell(lab, acme, 3 * SAT)], 'Ops holds far, which it bought, and adopts it first.');
adopt(lab, far, ops);
const b = sell(lab, acme, 3 * SAT);
assert.deepEqual(lab.chain.utxos.get(askId(b)).inscriptions.map(i => i.id), [acme, ops, sub, far, kit]);
// A coin saved by an offer before v1.13 was its one holder.
const { holders, layout, ...old } = offer(lab, acme, beta, SAT);
assert.deepEqual([decodeOffer({ ...old, holder: holders[0] }).holders, layout], [[holders[0]], [[kit, ops]]]);
});
test('a holding changing hands mines nothing, so no beat pays keys it is leaving', () => {
const lab = emptyLab(), acme = act(lab, 'found', null, { name: 'acme' }), beta = act(lab, 'found', null, { name: 'beta' });
const mining = () => miners(lab, lineageState(lab)).map(r => r.name);
assert.deepEqual(mining(), ['acme', 'beta']);
const a = sell(lab, acme, SAT), had = money(lab).now.acme ?? 0;
assert.deepEqual([mining(), had], [['beta'], 0]);
assert.throws(() => deposit(lab, acme, SAT), /^Error: acme is listed for sale at 1 tBTC\. Withdraw its ask first\.$/);
act(lab, 'decide', beta, { title: 'Keep mining' });
assert.equal(money(lab).now.acme ?? 0, 0);
unlist(lab, askId(a));
assert.deepEqual(mining(), ['acme', 'beta']);
accept(lab, offer(lab, acme, beta, SAT).txid); // sold, and waiting for beta to adopt it
assert.deepEqual(mining(), ['beta']);
adopt(lab, acme, beta);
assert.deepEqual([mining(), lineageState(lab).nodes.get(acme).root], [['beta'], beta]);
});