Files
orgchain/tests/acquire.test.js
T
HyperboxandClaude Opus 5.5 adca2709f0 Orgchain v1.12: both sides of a deal, and orgs change hands
Deals go both ways now, OpenOrdex's partially signed transaction from either
side, on a lot: an item, or an org inside a holding, which its parent sells.
Buy makes an offer, the buy order: the buyer signs the whole deal and the
holder countersigns. Sell makes an ask, the sell order: the lot's key signs
its input alone with SIGHASH_SINGLE|ANYONECANPAY, committing to that input
and to the price to the seller and to nothing else, and any node can take
it, leading with a 600-sat coin of its own so the lot lands in its output.

An org sells cash-free: before its parent lists it or accepts an offer on
it, the org's money, gas tank and items go home, so the price is for the org
alone, and a listed org is frozen until its ask is taken or withdrawn. Its
buyer adopts it with two new lineage/v0 ops, in either order: join, signed
by whoever holds the org's inscription, and adopt, signed by the new parent.
The org takes the buyer's next index and derives its keys there, and its
inscription moves to its new key. Taking an ask adopts at once; after an
accepted offer the buyer adopts when it's ready. The seller's tree keeps a
row at the old index.

Rehearsed on Bitcoin Core regtest: an org sold on an ask, another on an
offer and an item on an ask, twelve beats and 104 transactions, and ord tells
the same story. The rehearsal caught that a miner can put a join and its
adoption in either order within a block, so a move records both halves and
each half belongs to both holdings. The sample ends with both sides
standing: lineage's offer for the launch kit, and well-known's ask for mailr.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4upF5LVwmPDDqrhdUfnXh
2026-10-15 13:32:29 -07:00

179 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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]]);
});