Files
orgchain/tests/offers.test.js
T

169 lines
9.8 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 { lineageEvents, reduce } from '../src/orgchain/lineage.js';
import { decodeLab, emptyLab, encodeLab, setRule, setTempo } from '../src/orgchain/store.js';
import { accept, act, deposit, lastHolding, lineageState, minerOf, offer, seedSample, send, withdraw } from '../src/orgchain/actions.js';
import { FEE, REWARD, SAT, balances, ledger, most, owners, payerOf, tanks } from '../src/orgchain/money.js';
import { holderOfItem, offerStatus, offerWhy } from '../src/orgchain/offers.js';
const TOPUP = 10_000 + FEE; // what one gas top-up takes from a node's money
// A lab's money as the app sees it, deals named by the lab's offers: balances by name, and the ledger as
// [kind, beat, from, to, amount].
function money(lab, upTo) {
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;
const deals = new Map((lab.offers ?? []).map(o => [o.txid, o]));
return { events, live, now: Object.fromEntries([...balances(lab.chain, owner, upTo)].map(([id, v]) => [name(id), v])),
log: ledger(lab.chain, owner, tanks(lab.keys, events, live), deals).map(m => [m.kind, m.beat, name(m.from), name(m.to), m.amount]) };
}
const holder = (lab, item) => { const { events, live } = money(lab); return holderOfItem(lab, events, live, item)?.node.name ?? null; };
const status = (lab, o) => offerStatus(lab, lineageState(lab), o);
test('make records an item its maker holds, and the reducer vets it', () => {
const lab = emptyLab();
const acme = act(lab, 'found', null, { name: 'acme' }), bot = act(lab, 'birth', acme, { name: 'bot', kind: 'agent' });
send(lab, acme, bot, SAT);
const kit = act(lab, 'make', bot, { name: 'kit', note: ' A starter kit. ' });
const s = lineageState(lab);
assert.deepEqual(s.items.get(kit), { id: kit, name: 'kit', note: 'A starter kit.', maker: bot, beat: 5 });
assert.deepEqual(s.nodes.get(bot).made, [kit]);
assert.equal(s.log.at(-1).root, acme);
assert.equal(holder(lab, kit), 'bot');
assert.throws(() => act(lab, 'make', bot, { name: 'kit' }), /^Error: There is already a kit by bot\.$/);
assert.throws(() => act(lab, 'make', bot, { name: 'Kit!' }), /^Error: Names are 1-40 lowercase/);
act(lab, 'make', acme, { name: 'kit' }); // another maker may use the name
act(lab, 'close', bot, {});
assert.throws(() => act(lab, 'make', bot, { name: 'more' }), /^Error: The maker is closed\.$/);
assert.equal(lineageState(lab).items.size, 2);
});
test('an offer stands until the holder accepts; then the item and the price change hands in one transaction', () => {
const lab = emptyLab();
const acme = act(lab, 'found', null, { name: 'acme' }), beta = act(lab, 'found', null, { name: 'beta' });
const kit = act(lab, 'make', acme, { name: 'kit', note: 'A starter kit.' });
const before = money(lab).now, height = lab.chain.blocks.length;
const o = offer(lab, kit, beta, 2 * SAT);
assert.equal(lab.chain.mempool.length, 0); // an offer is signatures, not a transaction
assert.equal(lab.chain.blocks.length, height);
assert.equal(status(lab, o), 'open');
assert.throws(() => offer(lab, kit, beta, SAT), /^Error: beta already has an open offer on kit\. Withdraw it first\.$/);
assert.throws(() => offer(lab, kit, acme, SAT), /^Error: acme already holds kit\.$/);
assert.throws(() => offer(lab, kit, beta, 100), /^Error: Offer nothing, or at least 330 sats\.$/);
assert.throws(() => offer(lab, kit, beta, 51 * SAT), /at most 50 test BTC/);
accept(lab, o.txid);
assert.equal(lab.chain.blocks.length, height + 1);
assert.equal(holder(lab, kit), 'beta');
const after = money(lab);
assert.deepEqual(after.log.at(-1), ['deal', height + 1, 'beta', 'acme', 2 * SAT]);
assert.equal(after.now.acme, before.acme + 2 * SAT + REWARD / 2);
assert.equal(after.now.beta, before.beta - 2 * SAT - FEE + REWARD / 2);
const { tx } = lab.chain.transactions.get(o.txid);
assert.deepEqual(tx.vout.map(v => Number(v.value)), [1000, 2 * SAT, o.change]);
assert.equal(tx.vin[0].witness.length, 1); // the holder's Taproot signature on the item; the buyer's SegWit ones follow
assert.ok(tx.vin.slice(1).every(i => i.witness.length === 2));
assert.equal(status(lab, o), 'done');
assert.throws(() => accept(lab, o.txid), /already accepted/);
assert.throws(() => withdraw(lab, o.txid), /on the chain now/);
const back = offer(lab, kit, acme, 0); // a free claim back: the buyer pays only the fee
accept(lab, back.txid);
assert.equal(holder(lab, kit), 'acme');
assert.deepEqual(money(lab).log.at(-1), ['deal', height + 2, 'acme', 'beta', 0]);
});
test('payments spend unpromised coins first; an offer goes stale when a coin it promised moves', () => {
const lab = emptyLab();
const acme = act(lab, 'found', null, { name: 'acme' }), ops = act(lab, 'birth', acme, { name: 'ops' });
send(lab, acme, ops, SAT);
send(lab, acme, ops, SAT);
const kit = act(lab, 'make', acme, { name: 'kit' });
const o = offer(lab, kit, ops, 0.5 * SAT);
send(lab, ops, acme, 0.2 * SAT);
send(lab, ops, acme, 0.7 * SAT);
assert.equal(status(lab, o), 'open'); // both sends spent the coin the offer did not promise
send(lab, ops, acme, 0.5 * SAT);
assert.equal(status(lab, o), 'stale');
assert.equal(offerWhy(lab, lineageState(lab), o), 'ops has spent a coin the offer promised');
assert.throws(() => accept(lab, o.txid), /^Error: This offer is stale: ops has spent a coin the offer promised\.$/);
withdraw(lab, o.txid);
assert.deepEqual(lab.offers, []);
const beta = act(lab, 'found', null, { name: 'beta' }), first = offer(lab, kit, ops, 0.1 * SAT), second = offer(lab, kit, beta, SAT);
accept(lab, second.txid);
assert.equal(offerWhy(lab, lineageState(lab), first), 'kit has moved since');
});
test('a graduation leaves items at the old key: priced offers to it go stale, and it still signs for what it held', () => {
const lab = emptyLab();
const acme = act(lab, 'found', null, { name: 'acme' }), ops = act(lab, 'birth', acme, { name: 'ops' });
const beta = act(lab, 'found', null, { name: 'beta' });
send(lab, acme, ops, 2 * SAT);
const kit = act(lab, 'make', ops, { name: 'kit' });
const priced = offer(lab, kit, beta, SAT);
act(lab, 'graduate', acme, { of: ops });
assert.equal(offerWhy(lab, lineageState(lab), priced), 'ops has new keys since'); // its price would reach ops's old address
assert.equal(holder(lab, kit), 'ops'); // the item stays at ops's derived key, which the lab can still sign with
withdraw(lab, priced.txid);
const again = offer(lab, kit, beta, SAT);
accept(lab, again.txid);
assert.equal(holder(lab, kit), 'beta');
assert.deepEqual(money(lab).log.at(-1).slice(2), ['beta', 'ops', SAT]);
});
test('a cued deal counts for the holder that accepted it, and offers survive a backup', () => {
const lab = emptyLab();
const acme = act(lab, 'found', null, { name: 'acme' }), beta = act(lab, 'found', null, { name: 'beta' });
const kit = act(lab, 'make', acme, { name: 'kit' });
setRule(lab, 'last');
setTempo(lab, 30, 1000);
const o = offer(lab, kit, beta, SAT);
const copy = decodeLab(encodeLab(lab));
assert.deepEqual(copy.offers, lab.offers);
accept(copy, o.txid, 2000);
assert.equal(lastHolding(copy), acme);
assert.deepEqual(money(copy).log.at(-1), ['deal', null, 'beta', 'acme', SAT]);
const bad = JSON.parse(encodeLab(lab));
bad.offers[0].price = 5;
assert.throws(() => decodeLab(JSON.stringify(bad)), /Invalid offer/);
});
test('on mainnet no holding mines: beats pay the lab wallet, and holdings are funded from it', () => {
const lab = emptyLab();
setRule(lab, 'none');
const acme = act(lab, 'found', null, { name: 'acme' });
assert.equal(minerOf(lab, acme), null);
assert.deepEqual(money(lab).now, {});
assert.throws(() => act(lab, 'birth', acme, { name: 'ops' }), /^Error: acme has no test BTC for gas\. Fund it from the lab wallet\.$/);
deposit(lab, acme, 5 * SAT);
assert.deepEqual(money(lab).log, [['fund', 3, null, 'acme', 5 * SAT]]);
const ops = act(lab, 'birth', acme, { name: 'ops' });
assert.throws(() => deposit(lab, ops, SAT), /Pick a holding to fund/);
assert.throws(() => deposit(lab, acme, 51 * SAT), /at most 50 test BTC/);
setTempo(lab, 'manual');
const height = lab.chain.blocks.length;
for (let i = 0; i < 4; i++) deposit(lab, acme, REWARD);
assert.ok(lab.chain.blocks.length > height); // the lab wallet runs short, and a beat refills it
assert.equal(money(lab).now.acme, 5 * SAT + 4 * REWARD - TOPUP);
assert.equal(decodeLab(encodeLab(lab)).mining.rule, 'none');
});
test('a payment can spend up to eight coins', () => {
const lab = emptyLab();
const acme = act(lab, 'found', null, { name: 'acme' }), ops = act(lab, 'birth', acme, { name: 'ops' });
for (let i = 0; i < 3; i++) send(lab, acme, ops, 20 * SAT);
const txid = send(lab, ops, acme, REWARD); // three coins: more than the two the chain used to take
assert.equal(lab.chain.transactions.get(txid).tx.vin.length, 3);
for (let i = 0; i < 9; i++) send(lab, acme, ops, SAT);
const wallet = payerOf(lab.keys, lineageState(lab), lineageState(lab).nodes.get(ops));
const small = lab.chain.coins(wallet.script).filter(c => c.value === SAT);
assert.equal(most(small), 8 * SAT - FEE);
});
test('the sample ends on a standing offer: lineage bids for the launch kit sol made', () => {
const lab = emptyLab();
seedSample(lab);
const [o] = lab.offers, s = lineageState(lab), name = id => s.nodes.get(id).name;
assert.deepEqual([s.items.get(o.item).name, name(o.buyer), name(o.seller), o.price, status(lab, o)], ['launch-kit', 'lineage', 'sol', 0.1 * SAT, 'open']);
accept(lab, o.txid);
assert.equal(holder(lab, o.item), 'lineage');
assert.deepEqual(money(lab).log.at(-1), ['deal', 8, 'lineage', 'sol', 0.1 * SAT]);
});