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
179 lines
13 KiB
JavaScript
179 lines
13 KiB
JavaScript
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]]);
|
||
});
|