An org now changes hands with its tree, the derived orgs and agents below it and the items they hold, and a holding changes hands whole, sold by its owner, whom the lab wallet plays (the price goes there). Sales stay cash-free: the money in the tree, every gas tank included, goes to the seller first, and the items go with the tree. An ask signs one input, so listing a tree bundles every inscription in it into one output at the lot's key; an offer spends every output the tree sits in and merges them. After the join and the adopt, which now move a holding too (it becomes its buyer's next org), the buyer splits the output back, one inscription to an output at its new key, each item with the node that held it, by the layout the deal recorded. A listed or sold tree is frozen whole: nothing in it acts, sends, is funded or sells alone, nothing above it graduates or closes it, and a listed holding mines nothing. Graduation roots are kept by graduation event, since a node can graduate, move and graduate again. Transactions take up to 64 inputs and outputs. Rehearsed on Bitcoin Core regtest from height 144: sixteen beats and 131 transactions, time with its six below it bought on an offer, and acme sold whole on an ask, eleven inscriptions in one output, then split again; ord puts every inscription in the same output as the lab. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4upF5LVwmPDDqrhdUfnXh
204 lines
16 KiB
JavaScript
204 lines
16 KiB
JavaScript
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { MetadataIndex } from '../src/adapters/metadata-index.js';
|
||
import { PROTOCOL, census, kidsOf, lineageEvents, reduce, rows, standing, 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 = [
|
||
[adopt(beta, bot), 'an agent changes hands only with the org it sits in'],
|
||
[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'],
|
||
[join(acme, ops), 'acme cannot join itself or anything below it'],
|
||
[adopt(beta, ghost), 'that node is not 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 can come back', () => {
|
||
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, then joins acme again, as acme's next org: its first index stays
|
||
// unused. It can graduate again from there, with a fresh root of its own.
|
||
const g = ev('graduate', beta.id, { of: kit.id }, 4), t = reduce([acme, kit, beta, j, a, g, join(kit, acme, 5), adopt(acme, kit, 5)]);
|
||
const k = t.nodes.get(kit.id);
|
||
assert.ok(t.log.every(e => e.ok), t.log.filter(e => !e.ok).map(e => e.why).join('; '));
|
||
assert.deepEqual([k.parent, k.root, k.edge, k.index, k.rooted, t.roots.map(r => r.name)], [acme.id, acme.id, 'derived', 1, g.id, ['acme', 'beta']]);
|
||
assert.deepEqual(rows(t, acme.id).filter(r => r.type === 'node').map(r => [r.node.name, r.path, r.moved]), [['acme', '●', false], ['kit', "0'", true], ['kit', "1'", false]]);
|
||
const again = ev('graduate', acme.id, { of: kit.id }, 6), v = reduce([acme, kit, beta, j, a, g, join(kit, acme, 5), adopt(acme, kit, 5), again]);
|
||
assert.deepEqual([v.log.at(-1).ok, v.nodes.get(kit.id).rooted, v.roots.map(r => r.name)], [true, again.id, ['acme', 'beta', 'kit']]);
|
||
// 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, []]);
|
||
});
|
||
|
||
test('a holding changes hands whole: it joins another as an org, its tree with it, and its sole proprietorship lapses', () => {
|
||
const acme = ev('found', null, { name: 'acme' }), ops = ev('birth', acme.id, { name: 'ops' }), bot = ev('birth', ops.id, { name: 'bot', kind: 'agent' });
|
||
const lab = ev('birth', ops.id, { name: 'lab' }), beta = ev('found', null, { name: 'beta' }), sole = ev('charter', acme.id, { form: 'sole' });
|
||
const base = [acme, ops, bot, lab, beta, sole], j = join(acme, beta, 2), a = adopt(beta, acme, 2), s = reduce([...base, j, a]);
|
||
const node = e => s.nodes.get(e.id);
|
||
assert.ok(s.log.every(e => e.ok), s.log.filter(e => !e.ok).map(e => e.why).join('; '));
|
||
assert.deepEqual([node(acme).kind, node(acme).parent, node(acme).root, node(acme).edge, node(acme).index], ['org', beta.id, beta.id, 'derived', 0]);
|
||
assert.deepEqual([s.roots.map(r => r.name), [ops, bot, lab].map(e => node(e).root)], [['beta'], [beta.id, beta.id, beta.id]]);
|
||
assert.deepEqual(rows(s, beta.id).filter(r => r.type === 'node').map(r => [r.node.name, r.path]),
|
||
[['beta', '●'], ['acme', "0'"], ['ops', "0'"], ['bot', "0'"], ['lab', "0'"]]);
|
||
assert.deepEqual(census(s, beta.id), { orgs: 3, agents: 1, belts: 0, decisions: 0, closed: 0 });
|
||
assert.deepEqual(standing(s, node(acme)), { ok: false, why: 'acme joined another holding, and a sole proprietor is a person' });
|
||
assert.deepEqual([summary(s.log.at(-2), s), summary(s.log.at(-1), s)], ['join acme to beta, a holding till now', 'adopt acme into beta, a holding till now']);
|
||
// Its name is free for a new holding; an agent never moves alone; the old order of events lands the same tree.
|
||
const t = reduce([...base, a, j, ev('found', null, { name: 'acme' }, 3), join(bot, beta, 3)]);
|
||
assert.deepEqual([t.log.at(-2).ok, t.log.at(-1).why], [true, 'an agent changes hands only with the org it sits in']);
|
||
assert.deepEqual(t.nodes.get(acme.id), node(acme));
|
||
});
|
||
|
||
// 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, 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: its money goes home, its item goes with it, 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));
|
||
const a = sell(lab, ops, 3 * SAT), listed = lab.chain.blocks.length;
|
||
// Its money and its gas tank went to acme's SegWit address. Its item goes with it: the two inscriptions are one coin at
|
||
// its key now, the one its ask signs, and the layout keeps who holds the item.
|
||
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.deepEqual([holder(lab, kit), lab.chain.utxos.get(askId(a)).inscriptions.map(i => i.id), a.layout], ['ops', [ops, kit], [[kit, ops]]]);
|
||
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);
|
||
// The coin it came in split back: its inscription and its item, each in a coin of its own at its new key.
|
||
const coin = id => lab.chain.coins(k.wallet.script).find(c => c.inscriptions.some(i => i.id === id));
|
||
assert.deepEqual([holder(lab, kit), coin(ops).inscriptions.length, coin(kit).inscriptions.length], ['ops', 1, 1]);
|
||
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]]);
|
||
});
|