Paid-sale workflow

This commit is contained in:
2026-10-20 17:38:34 -04:00
parent 621611ecd9
commit ca8ff48628
11 changed files with 414 additions and 13 deletions
+166
View File
@@ -22,6 +22,11 @@ import { CUSTODY_POLICY, custodyMessage, type CustodyAction, type CustodyCommand
type CustodyListing, type CustodyOrder, type CustodyWithdrawal } from '../../shared/custody.ts';
import { NETWORK } from '../../shared/network.ts';
import { CHAIN_ID, address } from '../../server/testnet/consensus.ts';
import { saleContext, termsForCustodyOrder, saleId } from '../../server/testnet/sale-candidate.ts';
import { SALE_CANDIDATE, saleTermsMessage, saleCompletionMessage, saleCancellationMessage,
type SaleTransaction } from '../../shared/sale-candidate.ts';
import { replayReservedSale, type SaleRehearsal } from '../../server/market/sale-rehearsal.ts';
import { keccak } from 'hash-wasm';
if (process.env.WIKIDECK_PG_DISPOSABLE_TEST !== '1') throw new Error('PostgreSQL tests require the isolated disposable compose fixture');
const config: PoolConfig = { host: '127.0.0.1', database: 'custody_test', user: 'postgres', ssl: false, max: 4,
@@ -33,6 +38,35 @@ function identity() {
{ key: keys.privateKey, dsaEncoding: 'ieee-p1363' }).toString('base64') };
}
const buyer = identity(), seller = identity(), other = identity(), hash = 'a'.repeat(64);
const host = identity();
async function syntheticFeeAddress() {
const prefix = Uint8Array.from([18, ...Array(64).fill(2)]);
const data = Buffer.concat([prefix, Buffer.from((await keccak(prefix, 256)).slice(0, 8), 'hex')]);
const alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
let encoded = '';
for (let i = 0; i < data.length; i += 8) {
const chunk = data.subarray(i, i + 8); let n = BigInt(`0x${chunk.toString('hex')}`), text = '';
while (n) { text = alphabet[Number(n % 58n)] + text; n /= 58n; }
encoded += text.padStart(chunk.length === 8 ? 11 : 7, '1');
}
return encoded;
}
function saleTranscript(order: CustodyOrder, custodianId: string, feeBps = 250) {
const policy = { coinbaseMaturity: 2, lockDepth: 2, outcomeDepth: 3, maxLockBlocks: 30, maxTransactions: 64 };
const context = saleContext(policy);
const terms = termsForCustodyOrder(order, context, { custodianId, host: host.actor, feeBps, lockUntilHeight: 12, expiresHeight: 20 });
const authorization = (who: typeof buyer, message: string) => ({ publicKey: who.publicKey, signature: who.sign(message) });
const lock: Extract<SaleTransaction, { type: 'sale-lock' }> = { type: 'sale-lock', terms,
sellerAuthorization: authorization(seller, saleTermsMessage(terms)), buyerAuthorization: authorization(buyer, saleTermsMessage(terms)) };
const input: SaleRehearsal = { policy, seed: { height: 9, mintedHeight: 1, pageId: 1 }, lock, blocks: [[lock]] };
const reservationDigest = replayReservedSale(order, custodianId, input, feeBps).reservationDigest;
const reference = { protocol: SALE_CANDIDATE, chainId: context.chainId, saleId: saleId(terms) } as const;
const completion = { ...reference, reservationDigest };
const complete: SaleTransaction = { type: 'sale-complete', ...completion, hostAuthorization: authorization(host, saleCompletionMessage(completion)) };
const cancel: SaleTransaction = { type: 'sale-cancel', ...reference,
hostAuthorization: authorization(host, saleCancellationMessage(reference)), buyerAuthorization: authorization(buyer, saleCancellationMessage(reference)) };
return { input, complete, cancel, reservationDigest };
}
const options = { feeBps: 250, card: (token: string, owner: string, sequence: number) =>
token === hash && owner === seller.actor && sequence === 0 ? { title: 'PostgreSQL fixture' } : undefined };
function envelope(id: string, who: typeof buyer, action: CustodyAction, nonce: string = randomUUID()): CustodyCommand {
@@ -380,3 +414,135 @@ test('imported fixture completion and cancellation states reconcile their exact
try { assert.deepEqual(await pg.audit(), expected); }
finally { await pg.close(); }
});
test('sale rehearsal requires explicit disposable instance consent and exact trader-signed reservation terms', async t => {
const f = await fixture(t); await f.execute(buyer, { type: 'mock-credit' });
await assert.rejects(PostgresCustody.open({ ...f.runtime, database: 'postgres' }, { ...options, disposableSaleRehearsal: true }), /disposable database/);
const l = await f.execute(seller, { type: 'list', cardHash: hash, sequence: 0, priceAtomicXmr: '100000000000', feeBps: 250 }) as CustodyListing;
const o = await f.execute(buyer, { type: 'reserve', listingId: l.id, priceAtomicXmr: l.priceAtomicXmr, feeBps: 250 }) as CustodyOrder;
const t1 = saleTranscript(o, f.market.id);
assert.throws(() => f.market.observeSaleRehearsal(o.id, t1.input), /unavailable/);
const rehearsal = await PostgresCustody.open(f.runtime, { ...options, disposableSaleRehearsal: true });
try {
const wrong = structuredClone(t1.input); wrong.lock = { ...t1.input.lock, terms: { ...t1.input.lock.terms, priceAtomicXmr: '1' } } as typeof wrong.lock;
await assert.rejects(rehearsal.observeSaleRehearsal(o.id, wrong), /operation failed/);
const locked = await rehearsal.observeSaleRehearsal(o.id, t1.input);
assert.equal(locked.action, 'hold'); assert.equal(locked.reservationDigest, t1.reservationDigest);
await assert.rejects(rehearsal.observeSaleRehearsal(o.id, { ...t1.input, seed: { ...t1.input.seed, pageId: 2 } }), /authorization changed/);
await assert.rejects(rehearsal.observeSaleRehearsal(o.id, { ...t1.input, blocks: Array(129).fill([]) }), /operation failed/);
const cycle: Record<string, unknown> = {}; cycle.self = cycle;
await assert.rejects(rehearsal.observeSaleRehearsal(o.id, cycle), /operation failed/);
await assert.rejects(f.execute(buyer, { type: 'cancel-reservation', orderId: o.id }), /authorization exists/);
assert.equal((await f.activity()).purchaseReservedAtomicXmr, o.grossAtomicXmr);
assert.equal((await f.activity(seller)).availableAtomicXmr, '0');
assert.equal(rehearsal.info().nftSettlement, false);
await rehearsal.audit();
} finally { await rehearsal.close(); }
});
test('signed candidate completion settles actual PostgreSQL balances once across pools and reconnect', async t => {
const f = await fixture(t); await f.execute(buyer, { type: 'mock-credit' });
const l = await f.execute(seller, { type: 'list', cardHash: hash, sequence: 0, priceAtomicXmr: '100000000000', feeBps: 250 }) as CustodyListing;
const o = await f.execute(buyer, { type: 'reserve', listingId: l.id, priceAtomicXmr: l.priceAtomicXmr, feeBps: 250 }) as CustodyOrder;
const transcript = saleTranscript(o, f.market.id);
const a = await PostgresCustody.open(f.runtime, { ...options, disposableSaleRehearsal: true });
const b = await PostgresCustody.open(f.runtime, { ...options, disposableSaleRehearsal: true });
const mature = { ...transcript.input, blocks: [[transcript.input.lock], [], [transcript.complete], [], []] };
try {
const immature = { ...mature, blocks: mature.blocks.slice(0, 3) };
assert.equal((await a.observeSaleRehearsal(o.id, immature)).action, 'hold');
const results = await Promise.all([a.observeSaleRehearsal(o.id, mature), b.observeSaleRehearsal(o.id, mature)]);
assert.deepEqual(results.map(r => r.action).sort(), ['none', 'settle']);
assert.equal((await f.activity(seller)).availableAtomicXmr, '96500000000');
assert.equal((await f.activity()).purchaseReservedAtomicXmr, '0');
const claims = await f.admin.query("SELECT id,balance::text FROM wk_custody.accounts WHERE id LIKE 'author:%' OR id LIKE 'revenue:host:%'");
assert.deepEqual(claims.rows.map(r => r.balance).sort(), ['1000000000', '2500000000']);
assert.equal((await a.audit()).journals, 3);
const reopened = await PostgresCustody.open(f.runtime, { ...options, disposableSaleRehearsal: true });
try { assert.equal((await reopened.observeSaleRehearsal(o.id, mature)).action, 'none'); await reopened.audit(); }
finally { await reopened.close(); }
// Fork replaces completion with valid cancellation. Financial history stays intact and incident is durable.
const fork = { ...mature, blocks: [[transcript.input.lock], [], [transcript.cancel], [], []] };
await assert.rejects(a.observeSaleRehearsal(o.id, fork), /outcome-reorg/);
await assert.rejects(b.observeSaleRehearsal(o.id, mature), /incident unresolved/);
assert.equal((await f.activity(seller)).availableAtomicXmr, '96500000000');
assert.equal((await f.admin.query("SELECT count(*)::int AS n FROM wk_custody.journals WHERE kind='mock-sale-settlement'")).rows[0].n, 1);
await assert.rejects(PostgresCustody.open(f.runtime, options), /unresolved-integrity/);
} finally { await a.close(); await b.close(); }
});
test('candidate cancellation and withheld-lock expiry release actual reservations only after depth', async t => {
const f = await fixture(t); await f.execute(buyer, { type: 'mock-credit' });
const rehearsal = await PostgresCustody.open(f.runtime, { ...options, disposableSaleRehearsal: true });
try {
for (const withheld of [false, true]) {
const l = await f.execute(seller, { type: 'list', cardHash: hash, sequence: 0, priceAtomicXmr: '1000', feeBps: 250 }) as CustodyListing;
const o = await f.execute(buyer, { type: 'reserve', listingId: l.id, priceAtomicXmr: l.priceAtomicXmr, feeBps: 250 }) as CustodyOrder;
const s = saleTranscript(o, f.market.id);
const transcript = { ...s.input, blocks: withheld ? [[], [], [], [], []] : [[s.input.lock], [s.cancel], []] };
assert.equal((await rehearsal.observeSaleRehearsal(o.id, transcript)).action, 'hold');
transcript.blocks.push([]);
assert.equal((await rehearsal.observeSaleRehearsal(o.id, transcript)).action, 'refund');
assert.equal((await rehearsal.observeSaleRehearsal(o.id, transcript)).action, 'none');
assert.equal((await f.activity()).availableAtomicXmr, '1000000000000');
await rehearsal.audit();
}
} finally { await rehearsal.close(); }
});
test('rehearsal rejects a different fee rate even when tiny-order commission rounding is identical', async t => {
const f = await fixture(t); await f.execute(buyer, { type: 'mock-credit' });
const l = await f.execute(seller, { type: 'list', cardHash: hash, sequence: 0, priceAtomicXmr: '1000', feeBps: 250 }) as CustodyListing;
const o = await f.execute(buyer, { type: 'reserve', listingId: l.id, priceAtomicXmr: l.priceAtomicXmr, feeBps: 250 }) as CustodyOrder;
const s = saleTranscript(o, f.market.id, 251);
assert.throws(() => replayReservedSale(o, f.market.id, s.input, l.feeBps), /fee rate/);
const rehearsal = await PostgresCustody.open(f.runtime, { ...options, disposableSaleRehearsal: true });
try {
await assert.rejects(rehearsal.observeSaleRehearsal(o.id, s.input), /operation failed/);
assert.equal((await f.activity()).purchaseReservedAtomicXmr, '1000');
assert.equal((await f.admin.query("SELECT count(*)::int AS n FROM wk_custody.custody_meta WHERE key LIKE 'sale-rehearsal-%'")).rows[0].n, 0);
} finally { await rehearsal.close(); }
});
test('rehearsal preserves one-atomic sales and the signed custom host recipient across policy changes', async t => {
const f = await fixture(t); await f.execute(buyer, { type: 'mock-credit' });
const recipient = await syntheticFeeAddress();
const custom = await PostgresCustody.open(f.runtime, { ...options, hostFeeAddress: recipient });
const rehearsal = await PostgresCustody.open(f.runtime, { ...options, disposableSaleRehearsal: true });
try {
for (const price of ['1', '1000']) {
const l = await custom.execute(envelope(custom.id, seller, { type: 'list', cardHash: hash, sequence: 0,
priceAtomicXmr: price, feeBps: 250, hostFeeAddress: recipient })) as CustodyListing;
const o = await f.execute(buyer, { type: 'reserve', listingId: l.id, priceAtomicXmr: price, feeBps: 250, hostFeeAddress: recipient }) as CustodyOrder;
const s = saleTranscript(o, f.market.id);
const changed = structuredClone(s.input); changed.lock.terms.hostFeeAddress = f.market.info().hostFeeAddress;
await assert.rejects(rehearsal.observeSaleRehearsal(o.id, changed), /operation failed/);
const transcript = { ...s.input, blocks: [[s.input.lock], [], [s.complete], [], []] };
assert.equal((await rehearsal.observeSaleRehearsal(o.id, transcript)).action, 'settle');
assert.equal((await rehearsal.observeSaleRehearsal(o.id, transcript)).action, 'none');
await rehearsal.audit();
}
assert.equal((await f.activity(seller)).availableAtomicXmr, '966');
assert.equal((await f.admin.query('SELECT balance::text FROM wk_custody.accounts WHERE id=$1', [`revenue:host:${recipient}`])).rows[0].balance, '25');
assert.equal((await f.admin.query("SELECT count(*)::int AS n FROM wk_custody.custody_meta WHERE key LIKE 'sale-rehearsal-observation:%'")).rows[0].n, 2);
} finally { await custom.close(); await rehearsal.close(); }
});
test('sale terminal-posting evidence failure rolls back seller credit and remains safely retryable', async t => {
const f = await fixture(t); await f.execute(buyer, { type: 'mock-credit' });
const l = await f.execute(seller, { type: 'list', cardHash: hash, sequence: 0, priceAtomicXmr: '1000', feeBps: 250 }) as CustodyListing;
const o = await f.execute(buyer, { type: 'reserve', listingId: l.id, priceAtomicXmr: l.priceAtomicXmr, feeBps: 250 }) as CustodyOrder;
const s = saleTranscript(o, f.market.id), transcript = { ...s.input, blocks: [[s.input.lock], [], [s.complete], [], []] };
const rehearsal = await PostgresCustody.open(f.runtime, { ...options, disposableSaleRehearsal: true });
try {
await f.admin.query("CREATE FUNCTION wk_custody.fail_terminal() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.key LIKE 'sale-rehearsal-terminal:%' THEN RAISE EXCEPTION 'fixture terminal failure'; END IF; RETURN NEW; END $$; CREATE TRIGGER fail_terminal BEFORE INSERT ON wk_custody.custody_meta FOR EACH ROW EXECUTE FUNCTION wk_custody.fail_terminal()");
await assert.rejects(rehearsal.observeSaleRehearsal(o.id, transcript), /operation failed/);
assert.equal((await f.activity(seller)).availableAtomicXmr, '0');
assert.equal((await f.activity()).purchaseReservedAtomicXmr, '1000');
await f.admin.query('DROP TRIGGER fail_terminal ON wk_custody.custody_meta');
assert.equal((await rehearsal.observeSaleRehearsal(o.id, transcript)).action, 'settle');
await rehearsal.audit();
await f.admin.query("UPDATE wk_custody.custody_meta SET value='{}' WHERE key=$1", [`sale-rehearsal-terminal:${o.id}`]);
await assert.rejects(rehearsal.audit(), /evidence-mismatch/);
} finally { await rehearsal.close(); }
});