Files
wikideck/tests/custody.test.ts
T
2026-10-20 14:04:59 -04:00

198 lines
14 KiB
TypeScript
Executable File

import test from 'node:test';
import assert from 'node:assert/strict';
import { generateKeyPairSync, randomUUID, sign } from 'node:crypto';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { CustodyMarket, custodyMode } from '../server/market/custody.ts';
import { CUSTODY_POLICY, MOCK_CREDIT_ATOMIC, custodyCommandSchema, custodyMessage,
type CustodyAction, type CustodyActivity, type CustodyListing, type CustodyOrder,
type CustodyWithdrawal, type CustodyCommand } from '../shared/custody.ts';
import { NETWORK } from '../shared/testnet.ts';
import { address, CHAIN_ID } from '../server/testnet/consensus.ts';
function identity() {
const keys = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
const publicKey = keys.publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
return { publicKey, actor: address(publicKey), sign: (message: string) => sign('sha256', Buffer.from(message),
{ key: keys.privateKey, dsaEncoding: 'ieee-p1363' }).toString('base64') };
}
const seller = identity(), buyer = identity(), stranger = identity(), cardHash = 'a'.repeat(64);
const evidence = `fixture:${'c'.repeat(64)}`, tx = `fixture:${'d'.repeat(64)}`;
async function fixture(t: { after(fn: () => Promise<void>): void }) {
const dir = await mkdtemp(join(tmpdir(), 'wikideck-custody-'));
let now = 1_800_000_000_000, present = true;
const options = { feeBps: 250, now: () => now, card: (hash: string, owner: string, sequence: number) =>
present && hash === cardHash && owner === seller.actor && sequence === 0 ? { title: 'Custody fixture' } : undefined };
let market = await CustodyMarket.open(dir, options);
t.after(async () => { market.close(); await rm(dir, { recursive: true, force: true }); });
const envelope = (who: typeof buyer, action: CustodyAction, nonce: string = randomUUID()) => {
const c: Omit<CustodyCommand, 'signature'> = { version: 1, mode: 'sandbox', policy: CUSTODY_POLICY, network: NETWORK,
chainId: CHAIN_ID, custodianId: market.id, actor: who.actor, publicKey: who.publicKey,
nonce, expiresAt: now + 60000, action };
return { ...c, signature: who.sign(custodyMessage(c)) };
};
const execute = <T>(who: typeof buyer, action: CustodyAction) => market.execute(envelope(who, action)) as T;
const activity = (who = buyer) => execute<CustodyActivity>(who, { type: 'activity', offset: 0 });
const credit = (who = buyer) => execute<CustodyActivity>(who, { type: 'mock-credit' });
const listing = (price = '100000000000') => execute<CustodyListing>(seller, { type: 'list', cardHash,
sequence: 0, priceAtomicXmr: price, feeBps: 250 });
const reserve = (l: CustodyListing, who = buyer) => execute<CustodyOrder>(who, { type: 'reserve', listingId: l.id,
priceAtomicXmr: l.priceAtomicXmr, feeBps: l.feeBps });
return { dir, envelope, execute, activity, credit, listing, reserve, get market() { return market; },
changeCard: () => { present = false; }, advance: (ms: number) => { now += ms; },
restart: async () => { market.close(); market = await CustodyMarket.open(dir, options); } };
}
test('custody mode is off by default and rejects all live configurations', () => {
assert.equal(custodyMode({}), 'off'); assert.equal(custodyMode({ WIKIDECK_CUSTODY_MODE: 'sandbox' }), 'sandbox');
for (const mode of ['live', 'mainnet', '', 'shkeeper']) assert.throws(() => custodyMode({ WIKIDECK_CUSTODY_MODE: mode }));
});
test('custody requires a separate consent domain and rejects malformed monetary encodings', async t => {
const f = await fixture(t), c = f.envelope(buyer, { type: 'mock-credit' });
assert.match(custodyMessage(c), /wikideck-custody-command-v1/);
for (const patch of [{ actor: seller.actor }, { chainId: 'f'.repeat(64) }, { custodianId: randomUUID() },
{ expiresAt: c.expiresAt + 1 }, { action: { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '1', maxFeeAtomicXmr: '0' } }]) {
assert.throws(() => f.market.execute({ ...c, ...patch }));
}
for (const value of ['0', '-1', '01', '1.0', '1e2', ' 1', '1\n', '999999999999999999999999999999999']) {
assert.equal(custodyCommandSchema.safeParse({ ...c, action: { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: value, maxFeeAtomicXmr: '0' } }).success, false);
}
assert.throws(() => f.market.execute({ ...c, mode: 'live' }));
assert.throws(() => f.market.execute({ ...c, action: { type: 'withdraw', destination: 'real-address', amountAtomicXmr: '1', maxFeeAtomicXmr: '0' } }));
assert.equal(f.activity().availableAtomicXmr, '0');
});
test('mock funding and replay receipts persist across restart and never credit twice', async t => {
const f = await fixture(t), c = f.envelope(buyer, { type: 'mock-credit' });
const first = f.market.execute(c);
assert.deepEqual(f.market.execute(c), first);
assert.equal(f.activity().availableAtomicXmr, MOCK_CREDIT_ATOMIC);
assert.throws(() => f.credit(), /already claimed/);
const changed = f.envelope(buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '1', maxFeeAtomicXmr: '0' }, c.nonce);
assert.throws(() => f.market.execute(changed), /Nonce already used/);
await f.restart(); assert.deepEqual(f.market.execute(c), first);
assert.equal(f.market.audit().journals, 1);
f.advance(60001); assert.throws(() => f.market.execute(c), /expired/);
});
test('funded fixed-price reservation and blocked intent commit together with private activity', async t => {
const f = await fixture(t), l = f.listing();
assert.throws(() => f.reserve(l), /Insufficient/);
assert.equal(f.market.browse().listings[0].status, 'open');
assert.equal(f.activity().orders.length, 0);
f.credit(); const order = f.reserve(l);
assert.equal(order.status, 'awaiting-lock'); assert.equal(order.sellerAtomicXmr, '96500000000');
assert.equal(order.authorAtomicXmr, '1000000000'); assert.equal(order.hostAtomicXmr, '2500000000');
assert.equal(f.activity().availableAtomicXmr, '900000000000');
assert.equal(f.activity().purchaseReservedAtomicXmr, l.priceAtomicXmr);
assert.equal(f.activity(seller).availableAtomicXmr, '0');
assert.equal(f.activity(seller).orders.length, 1); assert.equal(f.activity(stranger).orders.length, 0);
assert.throws(() => f.execute(stranger, { type: 'cancel-reservation', orderId: order.id }), /Only the buyer/);
await f.restart();
f.execute(buyer, { type: 'cancel-reservation', orderId: order.id });
assert.equal(f.activity().availableAtomicXmr, MOCK_CREDIT_ATOMIC); assert.equal(f.activity().purchaseReservedAtomicXmr, '0');
assert.throws(() => f.market.settleFixture(order.id, evidence), /not eligible/);
assert.equal(f.market.audit().assetsAtomicXmr, MOCK_CREDIT_ATOMIC);
});
test('card changes, self-purchases and changed amounts cannot reserve funds', async t => {
const f = await fixture(t), l = f.listing(); f.credit();
assert.throws(() => f.reserve(l, seller), /own card/);
assert.throws(() => f.execute(buyer, { type: 'reserve', listingId: l.id, priceAtomicXmr: '1', feeBps: 250 }), /terms changed/);
f.changeCard(); assert.throws(() => f.reserve(l), /no longer available/);
assert.equal(f.activity().availableAtomicXmr, MOCK_CREDIT_ATOMIC);
});
test('offline canonical fixture settles exactly once with conserved fees and one-atomic-unit prices', async t => {
const f = await fixture(t); f.credit(); const l = f.listing('1'), o = f.reserve(l);
assert.equal(o.sellerAtomicXmr, '1'); assert.equal(o.hostAtomicXmr, '0'); assert.equal(o.authorAtomicXmr, '0');
assert.throws(() => f.market.settleFixture(o.id, 'rpc-confirmed'), /offline fixture/);
f.market.settleFixture(o.id, evidence);
assert.equal(f.activity(seller).availableAtomicXmr, '1');
assert.equal(f.activity().availableAtomicXmr, '999999999999');
await f.restart(); f.market.settleFixture(o.id, evidence);
assert.equal(f.activity(seller).availableAtomicXmr, '1');
assert.throws(() => f.market.settleFixture(o.id, `fixture:${'b'.repeat(64)}`), /Conflicting/);
assert.throws(() => f.execute(buyer, { type: 'cancel-reservation', orderId: o.id }), /Only the buyer/);
assert.deepEqual(f.market.audit(), { journals: 3, assetsAtomicXmr: MOCK_CREDIT_ATOMIC, claimsAndRevenueAtomicXmr: MOCK_CREDIT_ATOMIC });
});
test('competing signed reservations serialize and failed attempts leave no partial writes', async t => {
const f = await fixture(t), l = f.listing('800000000000'); f.credit(); f.credit(stranger);
const commands = [f.envelope(buyer, { type: 'reserve', listingId: l.id, priceAtomicXmr: l.priceAtomicXmr, feeBps: 250 }),
f.envelope(stranger, { type: 'reserve', listingId: l.id, priceAtomicXmr: l.priceAtomicXmr, feeBps: 250 })];
const results = await Promise.allSettled(commands.map(c => Promise.resolve().then(() => f.market.execute(c))));
assert.equal(results.filter(r => r.status === 'fulfilled').length, 1);
assert.equal(f.activity().orders.length + f.activity(stranger).orders.length, 1);
assert.equal(f.market.audit().journals, 3);
assert.throws(() => f.execute(buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '300000000000', maxFeeAtomicXmr: '0' }), /Insufficient/);
assert.equal(f.activity().withdrawals.length, 0);
});
test('withdrawal cancellation releases maximum fee and prepared uncertainty blocks cancellation/replacement', async t => {
const f = await fixture(t); f.credit();
const make = () => f.execute<CustodyWithdrawal>(buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '100000000000', maxFeeAtomicXmr: '1000' });
const cancelled = make(); f.execute(buyer, { type: 'cancel-withdrawal', withdrawalId: cancelled.id });
assert.equal(f.activity().availableAtomicXmr, MOCK_CREDIT_ATOMIC);
const w = make(), attempt = randomUUID();
assert.throws(() => f.market.prepareWithdrawalFixture(w.id, attempt, tx, '1001'), /fee cap/);
assert.equal(f.activity().withdrawals[0].status, 'reserved');
f.market.prepareWithdrawalFixture(w.id, attempt, tx, '400');
await f.restart();
assert.throws(() => f.execute(buyer, { type: 'cancel-withdrawal', withdrawalId: w.id }), /unprepared/);
assert.throws(() => f.market.prepareWithdrawalFixture(w.id, randomUUID(), tx, '400'), /different terms/);
assert.equal(f.market.prepareWithdrawalFixture(w.id, attempt, tx, '400').transactionId, tx);
assert.throws(() => f.market.confirmWithdrawalFixture(w.id, `fixture:${'e'.repeat(64)}`), /mismatch/);
f.market.confirmWithdrawalFixture(w.id, tx); f.market.confirmWithdrawalFixture(w.id, tx);
assert.equal(f.activity().availableAtomicXmr, '899999999600'); assert.equal(f.activity().withdrawalReservedAtomicXmr, '0');
assert.equal(f.market.audit().assetsAtomicXmr, '899999999600');
});
test('multiple connections cannot overspend a balance and cancelled commands do not double-release', async t => {
const f = await fixture(t); f.credit();
const second = await CustodyMarket.open(f.dir, { feeBps: 250, now: () => 1_800_000_000_000, card: () => undefined });
try {
const c = f.envelope(buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '900000000000', maxFeeAtomicXmr: '0' });
const w = second.execute(c) as CustodyWithdrawal;
assert.throws(() => f.execute(buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '900000000000', maxFeeAtomicXmr: '0' }), /Insufficient/);
const cancel = f.envelope(buyer, { type: 'cancel-withdrawal', withdrawalId: w.id });
f.market.execute(cancel); second.execute(cancel);
assert.equal(f.activity().availableAtomicXmr, MOCK_CREDIT_ATOMIC); second.audit();
} finally { second.close(); }
});
test('sandbox cap stops new work but never blocks release of existing unstarted holds', async t => {
const f = await fixture(t); f.credit();
const w = f.execute<CustodyWithdrawal>(buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '100', maxFeeAtomicXmr: '1' });
const db = new DatabaseSync(join(f.dir, 'testnet', 'custody-sandbox.sqlite'));
try {
const insert = db.prepare('INSERT INTO receipts VALUES(?,?,?)');
db.exec('BEGIN'); for (let i = 0; i < 10000; i++) insert.run(`fixture:${i}`, 'fixture', '{}'); db.exec('COMMIT');
assert.throws(() => f.listing(), /budget/);
f.execute(buyer, { type: 'cancel-withdrawal', withdrawalId: w.id });
assert.equal(f.activity().availableAtomicXmr, MOCK_CREDIT_ATOMIC);
assert.throws(() => db.prepare("UPDATE entries SET debit='1'").run(), /Immutable/);
assert.throws(() => db.prepare('DELETE FROM journals').run(), /Immutable/);
db.prepare("UPDATE accounts SET balance='12' WHERE id=?").run(`customer:${buyer.actor}:available`);
assert.throws(() => f.market.audit(), /projection mismatch/);
} finally { db.close(); }
});
test('property-style repeated settlements conserve gross and never round up author/host fees', async t => {
const f = await fixture(t); f.credit();
let spent = 0n, received = 0n;
for (let i = 1n; i <= 40n; i++) {
const price = i * 137n, o = f.reserve(f.listing(price.toString()));
f.market.settleFixture(o.id, `fixture:${i.toString(16).padStart(64, '0')}`);
assert.equal(BigInt(o.authorAtomicXmr), price / 100n);
assert.equal(BigInt(o.hostAtomicXmr), price * 250n / 10000n);
spent += price; received += BigInt(o.sellerAtomicXmr);
assert.equal(BigInt(f.activity().availableAtomicXmr) + spent, BigInt(MOCK_CREDIT_ATOMIC));
assert.equal(BigInt(f.activity(seller).availableAtomicXmr), received);
assert.equal(f.market.audit().assetsAtomicXmr, MOCK_CREDIT_ATOMIC);
}
});