Files
orgchain/tests/acquire.test.js
T

178 lines
13 KiB
JavaScript
Raw Normal View History

import test from 'node:test';
import assert from 'node:assert/strict';
import { MetadataIndex } from '../src/adapters/metadata-index.js';
import { PROTOCOL, kidsOf, lineageEvents, reduce, rows, summary } from '../src/orgchain/lineage.js';
import { decodeLab, emptyLab, encodeLab, setRule } from '../src/orgchain/store.js';
import { accept, act, adopt as adoptBought, deposit, frozenWhy, lineageState, offer, sell, send, take, unlist, withdraw } from '../src/orgchain/actions.js';
import { FEE, SAT, balances, ledger, owners, tanks } from '../src/orgchain/money.js';
import { holderOf, keyOf } from '../src/orgchain/keys.js';
import { DUMMY, askId, askStatus, askWhy, dealsOf, holderOfItem, offerWhy } from '../src/orgchain/offers.js';
let n = 0;
const ev = (op, parent = null, meta = {}, height = 1) => ({ id: `${String(++n).padStart(64, '0')}i0`, status: height === null ? 'pending' : 'confirmed',
height, metadata: { op, ...meta }, parent, protocol: PROTOCOL });
const join = (node, to, height = 2) => ev('join', node.id, { to: to.id ?? to }, height);
const adopt = (to, node, height = 2) => ev('adopt', to.id, { of: node.id ?? node }, height);
const why = (s, e) => { const entry = s.log.find(x => x.id === e.id); return entry.ok ? 'ok' : entry.why; };
test('an org changes hands by a handshake: it joins a new parent, which adopts it, in either order', () => {
const acme = ev('found', null, { name: 'acme' }), ops = ev('birth', acme.id, { name: 'ops' }), bot = ev('birth', ops.id, { name: 'bot', kind: 'agent' });
const kit = ev('birth', acme.id, { name: 'kit' }), beta = ev('found', null, { name: 'beta' }), ghost = 'f'.repeat(64) + 'i0';
const tries = [
[join(ops, beta), 'ops has derived children, and an org changes hands alone'],
[join(acme, beta), 'only orgs change hands'],
[adopt(beta, bot), 'only orgs change hands'],
[join(kit, bot), 'agents adopt only once they graduate'],
[join(kit, acme), "kit is already acme's"],
[join(kit, kit), 'kit cannot join itself or anything below it'],
[adopt(beta, ghost), 'the org must be a node in this lineage'],
[join(kit, ghost), 'the new parent must be a node in this lineage'],
];
const base = [acme, ops, bot, kit, beta], j = join(kit, beta, 3), a = adopt(beta, kit, 3);
const s = reduce([...base, ...tries.map(([e]) => e), j, a]);
tries.forEach(([e, want]) => assert.equal(why(s, e), want));
const k = s.nodes.get(kit.id), entry = id => s.log.find(x => x.id === id);
assert.deepEqual([k.parent, k.root, k.edge, k.index, k.joining], [beta.id, beta.id, 'derived', 0, null]);
assert.deepEqual(k.moves, [{ from: acme.id, index: 1, to: beta.id, beat: 3, join: j.id, adopt: a.id }]);
assert.deepEqual([s.nodes.get(acme.id).children.includes(kit.id), kidsOf(s.nodes, s.nodes.get(acme.id)).map(x => x.name)], [true, ['ops']]);
assert.deepEqual([entry(j.id).roots, entry(a.id).roots], [[acme.id, beta.id], [beta.id, acme.id]]);
assert.deepEqual([summary(entry(j.id), s), summary(entry(a.id), s)], ['join kit to beta, from acme', 'adopt kit into beta, from acme']);
// A miner may put the two halves in either order: the same tree, and the same record of each, either way.
const t = reduce([...base, ...tries.map(([e]) => e), a, j]), view = (st, id) => { const x = st.log.find(e => e.id === id); return [x.ok, x.root, x.roots, x.node]; };
assert.deepEqual(t.nodes.get(kit.id), k);
assert.deepEqual([view(t, j.id), view(t, a.id)], [view(s, j.id), view(s, a.id)]);
// Alone, a half waits for the other.
const half = reduce([...base, j]), other = reduce([...base, a]);
assert.deepEqual([summary(half.log.at(-1), half), summary(other.log.at(-1), other)], ['join kit to beta, for beta to adopt', 'adopt kit into beta, once kit joins']);
assert.deepEqual([half.nodes.get(kit.id).parent, other.nodes.get(kit.id).parent], [acme.id, acme.id]);
// acme's rows keep kit at its old index, away; beta's draw it at its new one.
assert.deepEqual(rows(s, acme.id).filter(r => r.type === 'node').map(r => [r.node.name, r.path, r.away, r.moved]),
[['acme', '●', false, false], ['ops', "0'", false, false], ['bot', "0'", false, false], ['kit', "1'", true, true]]);
assert.deepEqual(rows(s, beta.id).filter(r => r.type === 'node').map(r => [r.node.name, r.path, r.away]), [['beta', '●', false], ['kit', "0'", false]]);
// Its old index is never reused, and its name is free again under acme.
const u = reduce([...base, j, a, ev('birth', acme.id, { name: 'kit' }, 4)]), again = u.log.at(-1);
assert.deepEqual([again.ok, u.nodes.get(again.id).index], [true, 2]);
});
test('a joining org cannot graduate; a close leaves an org that moved away alone; a graduate cannot move', () => {
const acme = ev('found', null, { name: 'acme' }), kit = ev('birth', acme.id, { name: 'kit' }), beta = ev('found', null, { name: 'beta' });
const j = join(kit, beta), held = ev('graduate', acme.id, { of: kit.id }, 2), a = adopt(beta, kit, 3), shut = ev('close', acme.id, {}, 4);
const s = reduce([acme, kit, beta, j, held, a, shut]);
assert.equal(why(s, held), 'kit is joining beta');
assert.deepEqual([s.nodes.get(kit.id).status, s.log.at(-1).also], ['active', []]);
// Under beta, kit graduates into its own holding; a holding's root does not join another.
const t = reduce([acme, kit, beta, j, a, ev('graduate', beta.id, { of: kit.id }, 4), join(kit, acme, 5)]);
assert.equal(t.log.at(-2).ok, true);
assert.equal(t.log.at(-1).why, 'kit is a holding of its own, and only orgs inside a holding change hands');
// A later join replaces a pending one; an adoption waits for its own join.
const gamma = ev('found', null, { name: 'gamma' });
const u = reduce([acme, kit, beta, gamma, join(kit, beta), join(kit, gamma), adopt(beta, kit, 3)]);
assert.deepEqual([u.nodes.get(kit.id).parent, u.nodes.get(kit.id).joining.to, u.nodes.get(kit.id).moves], [acme.id, gamma.id, []]);
});
// The lab: asks and offers on items and orgs (offers.js), and the flows around them (actions.js). Under the 'none' rule no
// holding mines, so every balance moves only by what the test does.
const TOPUP = 10_000 + FEE; // what one gas top-up takes from a node's money
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; };
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('an ask is a sell order signed SINGLE|ANYONECANPAY: any node takes it, leading with a dummy of its own', () => {
const { lab, acme, beta } = twoHoldings(), kit = act(lab, 'make', acme, { name: 'kit' });
const before = money(lab).now, height = lab.chain.blocks.length;
const a = sell(lab, kit, 2 * SAT);
assert.equal(lab.chain.blocks.length, height); // an item's ask is a signature, not a transaction
assert.match(a.sig, /^[0-9a-f]{128}83$/);
assert.equal(askStatus(lab, lineageState(lab), a), 'open');
assert.throws(() => sell(lab, kit, SAT), /^Error: kit is already listed at 2 tBTC\. Withdraw that ask first\.$/);
assert.throws(() => sell(lab, beta, SAT), /^Error: Only orgs change hands\.$/);
assert.throws(() => sell(lab, kit, 100), /^Error: Ask at least 330 sats\.$/);
assert.throws(() => take(lab, askId(a), acme), /^Error: acme is the seller\.$/);
take(lab, askId(a), beta);
assert.equal(lab.chain.blocks.length, height + 1);
assert.equal(holder(lab, kit), 'beta');
const after = money(lab), { tx } = lab.chain.transactions.get(a.taken.txid);
assert.deepEqual(after.log.at(-1), ['deal', height + 1, 'beta', 'acme', 2 * SAT]);
assert.deepEqual([after.now.acme - before.acme, after.now.beta - before.beta], [2 * SAT, -2 * SAT - 2 * FEE - DUMMY]);
assert.deepEqual(tx.vout.map(v => Number(v.value)), [DUMMY + 1000, 2 * SAT]); // the dummy pads the item's coin now
assert.deepEqual(tx.vin.map(i => i.witness.length), [2, 1, 2]);
assert.equal(tx.vin[1].witness[0], a.sig);
assert.equal(askStatus(lab, lineageState(lab), a), 'done');
assert.throws(() => take(lab, askId(a), beta), /^Error: That ask was already taken\.$/);
assert.throws(() => unlist(lab, askId(a)), /on the chain now/);
// beta lists it again, then takes acme's lower offer instead: the coin moved, so the ask can never land.
const resale = sell(lab, kit, SAT), bid = offer(lab, kit, acme, SAT / 2);
accept(lab, bid.txid);
assert.equal(askWhy(lab, lineageState(lab), resale), 'kit has moved since');
assert.throws(() => take(lab, askId(resale), acme), /^Error: This ask is stale: kit has moved since\.$/);
unlist(lab, askId(resale));
assert.deepEqual(decodeLab(encodeLab(lab)).asks, lab.asks);
const bad = JSON.parse(encodeLab(lab));
bad.asks[0].sig = `${bad.asks[0].sig.slice(0, -2)}01`;
assert.throws(() => decodeLab(JSON.stringify(bad)), /Invalid ask/);
});
test('an org sells cash-free on an ask: what it holds goes home, it is frozen while listed, and its buyer adopts it at once', () => {
const { lab, acme, beta } = twoHoldings(), ops = act(lab, 'birth', acme, { name: 'ops' });
send(lab, acme, ops, SAT);
const kit = act(lab, 'make', ops, { name: 'kit' }), old = keyOf(lab.keys, lineageState(lab), lineageState(lab).nodes.get(ops));
assert.throws(() => sell(lab, acme, SAT), /^Error: Only orgs change hands\.$/);
const a = sell(lab, ops, 3 * SAT), listed = lab.chain.blocks.length;
// Its money and its gas tank went to acme's SegWit address, and its item to acme's key.
const m1 = money(lab);
assert.equal(m1.now.ops ?? 0, 0);
assert.deepEqual(m1.log.filter(x => x[1] === listed), [['pay', listed, 'ops', 'acme', 7500], ['pay', listed, 'ops', 'acme', SAT - TOPUP - FEE]]);
assert.equal(holder(lab, kit), 'acme');
assert.throws(() => act(lab, 'decide', ops, { title: 'Hold the line' }), /^Error: ops is listed for sale at 3 tBTC\. Withdraw its ask first\.$/);
assert.throws(() => send(lab, acme, ops, SAT), /^Error: ops is listed for sale/);
assert.throws(() => act(lab, 'graduate', acme, { of: ops }), /^Error: ops is listed for sale/);
assert.throws(() => take(lab, askId(a), act(lab, 'birth', beta, { name: 'bot', kind: 'agent' })), /^Error: Agents adopt only once they graduate\.$/);
// beta takes it: the deal, the handshake and the handoff to its new keys, in one beat.
take(lab, askId(a), beta);
const s = lineageState(lab), node = s.nodes.get(ops), k = keyOf(lab.keys, s, node);
assert.equal(lab.chain.blocks.length, listed + 2); // the agent's birth took one beat
assert.deepEqual([node.parent, node.root, node.index, node.moves.map(x => x.from)], [beta, beta, 0, [acme]]);
assert.deepEqual(s.log.slice(-2).map(e => [e.op, e.ok, summary(e, s)]), [['adopt', true, 'adopt ops into beta, from acme'], ['join', true, 'join ops to beta, from acme']]);
assert.deepEqual([k.from, holderOf(lab, s, ops).who], ["beta root › 0'", 'own']);
assert.notEqual(k.wallet.address, old.wallet.address);
assert.deepEqual(money(lab).log.filter(x => x[0] === 'deal'), [['deal', listed + 2, 'beta', 'acme', 3 * SAT]]);
// It acts again as beta's, on beta's money.
assert.throws(() => act(lab, 'decide', ops, { title: 'New owners' }), /^Error: ops has no test BTC for gas\. beta can send some\.$/);
send(lab, beta, ops, SAT);
act(lab, 'decide', ops, { title: 'New owners' });
assert.equal(lineageState(lab).nodes.get(ops).decisions[0].title, 'New owners');
});
test('an offer on an org: its parent accepts cash-free, and the buyer adopts it when it is ready', () => {
const { lab, acme, beta } = twoHoldings(), ops = act(lab, 'birth', acme, { name: 'ops' });
send(lab, acme, ops, SAT);
const o = offer(lab, ops, beta, 2 * SAT);
assert.throws(() => offer(lab, ops, acme, SAT), /^Error: Ops is already acme's\.$/);
act(lab, 'decide', ops, { title: 'Hold the line' }); // every act spends the org's inscription, so the offer goes stale
assert.equal(offerWhy(lab, lineageState(lab), o), "ops's inscription has moved since");
withdraw(lab, o.txid);
accept(lab, offer(lab, ops, beta, 2 * SAT).txid);
assert.equal(money(lab).now.ops ?? 0, 0);
assert.equal(lineageState(lab).nodes.get(ops).parent, acme);
assert.throws(() => act(lab, 'decide', ops, { title: 'x' }), /^Error: ops was sold to beta, which adopts it next\.$/);
assert.throws(() => send(lab, acme, ops, SAT), /^Error: ops was sold to beta/);
assert.throws(() => act(lab, 'graduate', acme, { of: ops }), /^Error: ops was sold to beta/);
assert.throws(() => sell(lab, ops, SAT), /^Error: Ops is held by beta\.$/);
assert.throws(() => adoptBought(lab, ops, acme), /^Error: acme does not hold ops\.$/);
adoptBought(lab, ops, beta);
const s = lineageState(lab);
assert.deepEqual([s.nodes.get(ops).parent, holderOf(lab, s, ops).who, frozenWhy(lab, s, 'decide', ops)], [beta, 'own', '']);
assert.deepEqual(money(lab).log.filter(x => x[0] === 'deal').map(x => x.slice(2)), [['beta', 'acme', 2 * SAT]]);
});