Files
wikideck/tests/market.test.ts
T
2026-10-20 16:24:01 -04:00

491 lines
31 KiB
TypeScript
Executable File
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 { generateKeyPairSync, randomUUID, sign } from 'node:crypto';
import { mkdtemp, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { DatabaseSync } from 'node:sqlite';
import { Market, marketConfig } from '../server/market/store.ts';
import { mockQuote, paymentMode } from '../server/market/payments.ts';
import { ADDITIONAL_MARKET_FEE, marketMessage, parseUsd, type MarketAction } from '../shared/market.ts';
import { marketActionV3Schema, marketMessageV3, type MarketActionV3, type MarketActivityV3,
type MarketListingV3, type MarketOrderV3 } from '../shared/market-v3.ts';
import { NETWORK, nftTransferMessage, testnetOwnerOf, transactionCardIds, type NftTransfer, type TestnetSnapshot, type TestnetBlock } from '../shared/testnet.ts';
import type { CanonicalEvent } from '../server/testnet/canonical-index.ts';
import type { MarketPolicy } from '../server/market/policy.ts';
import { address, CHAIN_ID, GENESIS_HASH, transferId, verifySignature } 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), signed: (text: string) => sign('sha256', Buffer.from(text),
{ key: keys.privateKey, dsaEncoding: 'ieee-p1363' }).toString('base64') };
}
const alice = identity(), bob = identity(), carol = identity();
// Unit-test chain boundary. Production always uses Ledger + reference RandomX; no selectable fake PoW.
function chainFixture() {
const mint = { tokenId: 'a'.repeat(64), height: 1, owner: alice.actor,
article: { title: 'A collector’s discovery', image: 'https://upload.wikimedia.org/test.jpg' }, transactions: [] } as unknown as TestnetBlock;
const state: TestnetSnapshot = { network: NETWORK, chainId: CHAIN_ID, genesis: GENESIS_HASH,
tip: 'b'.repeat(64), blocks: [mint, { ...mint, tokenId: 'c'.repeat(64), height: 2 }],
transfers: [], pendingTransfers: [], peers: 0, algorithm: 'RandomX', maturity: 2, cumulativeWork: '2' };
return { state, snapshot: () => structuredClone(state), submitTransfer: async (raw: unknown) => {
const tx = raw as NftTransfer;
assert.ok(verifySignature(tx.publicKey, tx.from, nftTransferMessage(tx), tx.signature));
const id = transferId(tx);
if (!state.pendingTransfers.some(t => transferId(t as NftTransfer) === id)) state.pendingTransfers.push(tx);
return id;
} };
}
async function fixture(t: { after(fn: () => Promise<void>): void }, indexed = false) {
const dir = await mkdtemp(join(tmpdir(), 'wikideck-market-'));
const chain = chainFixture();
// Test-only indexed adapter: production indexes/events are exercised separately by Ledger tests.
const events: CanonicalEvent[] = [];
const streamId = randomUUID();
const adapter = indexed ? { ...chain,
snapshot: (): TestnetSnapshot => { throw new Error('Indexed market must not request a full chain snapshot'); },
summary: () => ({ tip: chain.state.tip, revision: events.length, height: chain.state.blocks.length }),
pendingTransactions: () => structuredClone(chain.state.pendingTransfers),
nft: (token: string) => {
const b = chain.state.blocks.find(b => b.tokenId === token);
return b ? { block: b, owner: testnetOwnerOf(chain.state, token)!,
sequence: chain.state.transfers.filter(tx => transactionCardIds(tx).includes(token)).length,
confirmations: chain.state.blocks.length + 1 - b.height } : undefined;
},
canonicalTransaction: (id: string) => {
const b = chain.state.blocks.find(b => b.transactions.some(tx => transferId(tx as NftTransfer) === id));
return b ? { height: b.height, blockHash: b.hash ?? 'f'.repeat(64) } : undefined;
},
canonicalHead: () => events.length,
canonicalEvents: (after: number, limit = 64) => {
if (after > events.length) throw new Error('Canonical cursor ahead of restored ledger');
const page = events.slice(after, after + limit), cursor = page.at(-1)?.sequence ?? after;
return { streamId, previousAnchor: after ? events[after - 1].eventHash : GENESIS_HASH,
anchor: page.at(-1)?.eventHash ?? (after ? events[after - 1].eventHash : GENESIS_HASH),
events: page, cursor, head: events.length, caughtUp: cursor === events.length };
},
} : chain;
let time = 1_800_000_000_000;
let market = await Market.open(dir, adapter, { feeBps: 250, confirmations: 2 }, () => time);
t.after(async () => { market.close(); await rm(dir, { recursive: true, force: true }); });
const envelope = (who: typeof alice, action: MarketAction) => {
const terms = { network: NETWORK, chainId: CHAIN_ID, marketId: market.id, actor: who.actor,
publicKey: who.publicKey, nonce: randomUUID(), expiresAt: time + 60000, action };
return { ...terms, signature: who.signed(marketMessage(terms)) };
};
const atomicFromCents = (cents: number) => ((BigInt(cents) * 1_000_000_000_000n + 14999n) / 15000n).toString();
const toV3 = (action: MarketAction): MarketActionV3 => {
if (action.type === 'list') return { type: 'list', cardHash: action.cardHash, sequence: action.sequence,
kind: action.kind, priceAtomicXmr: atomicFromCents(action.priceCents), endsAt: action.endsAt, feeBps: action.feeBps };
if (action.type === 'bid') return { type: 'bid', listingId: action.listingId, priceAtomicXmr: atomicFromCents(action.priceCents) };
if (action.type === 'buy') return { type: 'buy', listingId: action.listingId,
priceAtomicXmr: atomicFromCents(action.priceCents), feeBps: action.feeBps };
return action;
};
const envelopeV3 = (who: typeof alice, action: MarketActionV3) => {
const terms = { version: 3 as const, network: NETWORK, chainId: CHAIN_ID, marketId: market.id, actor: who.actor,
publicKey: who.publicKey, nonce: randomUUID(), expiresAt: time + 60000, action };
return { ...terms, signature: who.signed(marketMessageV3(terms)) };
};
const run = <T = MarketListingV3 | MarketOrderV3>(who: typeof alice, action: MarketAction) =>
market.execute(envelopeV3(who, toV3(action))) as Promise<T>;
const list = (kind: 'fixed' | 'auction' = 'fixed') => run<MarketListingV3>(alice, { type: 'list', cardHash: chain.state.blocks[0].tokenId,
sequence: 0, kind, priceCents: 1500, endsAt: kind === 'auction' ? time + 60000 : null, feeBps: 250 });
const buy = (l: MarketListingV3, who = bob, priceCents = 1500) => run<MarketOrderV3>(who,
{ type: 'buy', listingId: l.id, priceCents, feeBps: l.feeBps });
const orders = async (who = bob) => (await run<MarketActivityV3>(who, { type: 'activity' })).orders;
const delivery = (o: Pick<MarketOrderV3, 'cardHash' | 'sequence'>, to = bob) => {
const terms = { network: NETWORK, chainId: CHAIN_ID, cardHash: o.cardHash, from: alice.actor, to: to.actor, sequence: o.sequence };
return { ...terms, publicKey: alice.publicKey, signature: alice.signed(nftTransferMessage(terms)) };
};
const confirm = (tx: NftTransfer) => {
const height = chain.state.blocks.length + 1;
chain.state.blocks.push({ ...chain.state.blocks[0], height, transactions: [tx] });
chain.state.transfers.push(tx); chain.state.pendingTransfers = [];
};
return { dir, chain, events, envelope, envelopeV3, run, list, buy, orders, delivery, confirm,
get market() { return market; }, get now() { return time; }, advance: (ms: number) => { time += ms; },
restart: async (policy: MarketPolicy = {}) => { market.close(); market = await Market.open(dir, adapter,
{ feeBps: 500, confirmations: 5, ...policy }, () => time); } };
}
test('mock market switches block new listings/bids/checkout while allowing paid-order delivery and private activity', async t => {
const f = await fixture(t), l = await f.list(), o = await f.buy(l);
await f.run(bob, { type: 'mock-pay', orderId: o.id });
await f.restart({ marketEnabled: false });
assert.equal(f.market.info().marketEnabled, false); assert.deepEqual(f.market.browse().listings, []);
await assert.rejects(f.list(), /disabled/);
assert.equal((await f.orders())[0].id, o.id);
const tx = f.delivery(o); await f.run(alice, { type: 'deliver', orderId: o.id, transfer: tx });
assert.equal(f.chain.state.pendingTransfers.length, 1);
});
test('indexed market uses bounded durable event catch-up and never requests full snapshots', async t => {
const f = await fixture(t, true);
for (let i = 1; i <= 130; i++) f.events.push({ sequence: i, type: 'connect', height: i,
hash: 'd'.repeat(64), previousHash: 'e'.repeat(64), transactionIds: [], eventHash: i.toString(16).padStart(64, '0') });
await assert.rejects(f.list(), /catching up/);
assert.equal(f.market.diagnostics().canonicalEventCursor, 64);
f.market.browse(); assert.equal(f.market.diagnostics().canonicalEventCursor, 128);
const l = await f.list(); assert.ok(l.id);
assert.equal(f.market.diagnostics().canonicalEventCursor, 130);
assert.equal(f.market.diagnostics().eventBacklog, false);
await f.restart(); assert.equal(f.market.diagnostics().canonicalEventCursor, 130);
f.market.browse(); assert.equal(f.market.diagnostics().canonicalEventCursor, 130);
const anchor = f.events[129].eventHash;
f.events[129].eventHash = 'f'.repeat(64); f.advance(1001);
assert.throws(() => f.market.browse(), /history changed/);
f.events[129].eventHash = anchor;
f.events.length = 0;
assert.throws(() => f.market.browse(), /cursor ahead/);
});
test('disconnect cursor and payout hold commit together and survive a crash/restart', async t => {
const f = await fixture(t, true), listing = await f.list(), order = await f.buy(listing);
await f.run(bob, { type: 'mock-pay', orderId: order.id });
const delivery = f.delivery(order); f.confirm(delivery);
f.chain.state.blocks.push({ ...f.chain.state.blocks[0], height: f.chain.state.blocks.length + 1 });
f.events.push({ sequence: 1, type: 'connect', height: 3, hash: 'd'.repeat(64), previousHash: 'e'.repeat(64), transactionIds: [transferId(delivery)], eventHash: '1'.repeat(64) });
assert.equal((await f.orders())[0].status, 'complete');
// The same delivery may already be canonical again; the intermediate disconnect must not be lost.
f.events.push({ sequence: 2, type: 'disconnect', height: 3, hash: 'd'.repeat(64), previousHash: 'e'.repeat(64), transactionIds: [transferId(delivery)], eventHash: '2'.repeat(64) });
const db = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite'));
try {
db.exec("CREATE TRIGGER fail_cursor BEFORE UPDATE ON market_meta WHEN NEW.key='canonical_event_cursor' BEGIN SELECT RAISE(ABORT,'injected cursor failure'); END");
assert.throws(() => f.market.browse(), /injected cursor failure/);
const persisted = db.prepare('SELECT data FROM orders WHERE id=?').get(order.id) as { data: string };
assert.equal(JSON.parse(persisted.data).status, 'complete');
assert.equal(f.market.diagnostics().canonicalEventCursor, 1);
db.exec('DROP TRIGGER fail_cursor');
} finally { db.close(); }
await f.restart();
const reviewed = (await f.orders())[0];
assert.equal(reviewed.status, 'review'); assert.equal(reviewed.payout, 'review');
assert.equal(f.market.diagnostics().canonicalEventCursor, 2);
await f.restart(); assert.equal((await f.orders())[0].status, 'review');
});
test('USD and mock XMR accounting use bounded integer amounts and exact fee splitting', () => {
assert.equal(parseUsd('15.01'), 1501); assert.equal(parseUsd('0.01'), 1);
for (const invalid of ['0', '-1', '1e3', '1.001', ' 1', 'NaN', '1000001', '01.00']) assert.throws(() => parseUsd(invalid));
const quote = mockQuote(1500, 250, 15000);
assert.equal(quote.invoice.amountXmr, '0.100000000000');
assert.equal(quote.feeXmr, '0.002500000000');
assert.equal(quote.sellerNetXmr, '0.096500000000');
assert.deepEqual(quote.additionalFee, { bps: 100,
address: '44xHcCdFhW4b951yBHpkTPdLSJ85qTCnFQ8N7m96TtqqRL3dbhzbEH4fe2SJ3a6ESHcNyzCYH5nYJ9ActR1JtkeA7QYGbQv',
amountXmr: '0.001000000000', payout: 'not-started' });
assert.equal(quote.invoice.address, null);
assert.equal(mockQuote(1, 0, 3).invoice.amountXmr, '0.333333333334');
assert.equal(paymentMode({}), 'mock');
for (const mode of ['live','shkeeper','testnet','']) assert.throws(() => paymentMode({ WIKIDECK_PAYMENT_MODE: mode }));
for (const value of ['-1','5001','250.5','x','']) assert.throws(() => marketConfig({ WIKIDECK_MARKET_FEE_BPS: value }));
assert.equal(marketConfig({ WIKIDECK_MARKET_FEE_BPS: '0' }).feeBps, 0);
const noHostingFee = mockQuote(1500, 0, 15000);
assert.equal(noHostingFee.additionalFee.amountXmr, '0.001000000000');
assert.equal(noHostingFee.sellerNetXmr, '0.099000000000');
const atomic = (text: string) => BigInt(text.replace('.', ''));
for (const cents of [1, 19, 1500, 100_000_000]) for (const bps of [0, 250, 5000]) {
const q = mockQuote(cents, bps, 19999);
const gross = atomic(q.invoice.amountXmr);
assert.equal(atomic(q.additionalFee.amountXmr), gross * 100n / 10000n);
assert.equal(atomic(q.feeXmr) + atomic(q.additionalFee.amountXmr) + atomic(q.sellerNetXmr), gross);
assert.ok(atomic(q.sellerNetXmr) >= 0n);
}
});
test('versioned market commands bind actor, instance, network, expiry, currency, and every price term', async t => {
const f = await fixture(t);
const original = f.envelopeV3(alice, { type: 'list', cardHash: f.chain.state.blocks[0].tokenId, sequence: 0,
kind: 'fixed', priceAtomicXmr: '100000000000', endsAt: null, feeBps: 250 });
for (const bad of [
{ ...original, actor: bob.actor }, { ...original, marketId: randomUUID() },
{ ...original, chainId: '0'.repeat(64) }, { ...original, expiresAt: f.now - 1 },
{ ...original, expiresAt: f.now + 600_000 },
{ ...original, action: { ...original.action, priceAtomicXmr: '1' } },
]) await assert.rejects(async () => f.market.execute(bad), /signature/);
await assert.rejects(async () => f.market.execute({ ...original, network: 'wrong-network' }));
const first = await f.market.execute(original);
const message = JSON.parse(marketMessageV3(original));
assert.equal(message[1], 'market-command-v3');
assert.deepEqual(message.at(-1), [100, ADDITIONAL_MARKET_FEE.address]);
message[message.length - 1] = [0, ADDITIONAL_MARKET_FEE.address];
await assert.rejects(async () => f.market.execute({ ...original, signature: alice.signed(JSON.stringify(message)) }), /signature/);
assert.deepEqual(await f.market.execute(original), first);
const changed = { ...original, action: { ...original.action, priceAtomicXmr: '200000000000' } };
await assert.rejects(async () => f.market.execute({ ...changed, signature: alice.signed(marketMessageV3(changed)) }), /Nonce/);
const legacy = f.envelope(alice, { type: 'list', cardHash: f.chain.state.blocks[1].tokenId, sequence: 0,
kind: 'fixed', priceCents: 1500, endsAt: null, feeBps: 250 });
assert.equal(JSON.parse(marketMessage(legacy))[1], 'market-command-v2');
await assert.rejects(f.market.execute(legacy), /USD listing terms are retired/);
assert.equal(f.market.browse().listings.length, 1);
});
test('legacy USD listings are read-only until seller retires them with v3 authorization', async t => {
const f = await fixture(t), cardHash = f.chain.state.blocks[0].tokenId, id = randomUUID();
const legacy = { id, cardHash, sequence: 0, seller: alice.actor, title: 'Historical USD listing',
image: 'https://upload.wikimedia.org/test.jpg', kind: 'fixed' as const, priceCents: 1500,
feeBps: 250, createdAt: f.now, endsAt: null, status: 'open' as const, highestBid: null };
const db = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite'));
try { db.prepare('INSERT INTO listings VALUES(?,?,?,?)').run(id, cardHash, 'open', JSON.stringify(legacy)); }
finally { db.close(); }
const activity = await f.run<MarketActivityV3>(alice, { type: 'activity' });
assert.equal(activity.listings[0].id, id);
assert.equal((activity.listings[0] as typeof legacy).priceCents, 1500);
assert.equal(activity.listings[0].status, 'invalid');
await assert.rejects(f.run(bob, { type: 'buy', listingId: id, priceCents: 1500, feeBps: 250 }), /Legacy USD listings are read-only/);
await assert.rejects(f.run(alice, { type: 'bid', listingId: id, priceCents: 1500 }), /Legacy USD listings are read-only/);
const retired = await f.market.execute(f.envelopeV3(alice, { type: 'retire-legacy-listing', listingId: id })) as MarketListingV3;
assert.equal(retired.status, 'cancelled');
assert.equal((retired as unknown as typeof legacy).priceCents, 1500);
});
test('legacy reserved USD orders retain signed terms across current-XMR upgrade and restart', async t => {
const f = await fixture(t), listingId = randomUUID(), orderId = randomUUID(), cardHash = f.chain.state.blocks[0].tokenId;
const listing = { id: listingId, cardHash, sequence: 0, seller: alice.actor, title: 'Legacy in-flight order',
image: 'https://upload.wikimedia.org/test.jpg', kind: 'fixed' as const, priceCents: 1500, feeBps: 250,
createdAt: f.now, endsAt: null, status: 'reserved' as const, highestBid: null,
additionalFee: { bps: 100, address: ADDITIONAL_MARKET_FEE.address } };
const quote = mockQuote(1500, 250, 15000);
const order = { id: orderId, listingId, seller: alice.actor, buyer: bob.actor, cardHash, sequence: 0,
priceCents: 1500, feeBps: 250, createdAt: f.now, payBy: f.now + 30 * 60_000,
deliverBy: f.now + 30 * 60_000 + 48 * 60 * 60_000, status: 'awaiting-payment' as const,
...quote, payout: 'not-started' as const, deliveryConfirmations: 0, requiredConfirmations: 2 };
const db = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite'));
try {
db.prepare('INSERT INTO listings VALUES(?,?,?,?)').run(listingId, cardHash, 'reserved', JSON.stringify(listing));
db.prepare('INSERT INTO orders VALUES(?,?)').run(orderId, JSON.stringify(order));
} finally { db.close(); }
await assert.rejects(f.run(bob, { type: 'mock-pay', orderId }), /Legacy USD orders keep their original terms/);
const historical = await f.market.execute(f.envelope(bob, { type: 'mock-pay', orderId })) as typeof order;
assert.equal(historical.status, 'paid');
assert.equal(historical.priceCents, 1500);
assert.equal(historical.invoice.amountXmr, '0.100000000000');
await f.restart();
const restored = (await f.run<MarketActivityV3>(bob, { type: 'activity' })).orders[0] as typeof order;
assert.equal(restored.priceCents, 1500);
assert.equal(restored.invoice.amountXmr, '0.100000000000');
assert.equal(restored.status, 'paid');
});
test('native-XMR listing and checkout accept one atomic unit without conversion or fee minimums', async t => {
const f = await fixture(t);
const listing = await f.market.execute(f.envelopeV3(alice, { type: 'list', cardHash: f.chain.state.blocks[0].tokenId,
sequence: 0, kind: 'fixed', priceAtomicXmr: '1', endsAt: null, feeBps: 250 })) as MarketListingV3;
assert.equal(listing.priceAtomicXmr, '1');
const order = await f.market.execute(f.envelopeV3(bob, { type: 'buy', listingId: listing.id,
priceAtomicXmr: '1', feeBps: 250 })) as MarketOrderV3;
assert.equal(order.invoice.amountXmr, '0.000000000001');
assert.equal(order.feeXmr, '0.000000000000');
assert.equal(order.additionalFee?.amountXmr, '0.000000000000');
assert.equal(order.sellerNetXmr, '0.000000000001');
const terms = { type: 'list', cardHash: f.chain.state.blocks[1].tokenId, sequence: 0,
kind: 'fixed', priceAtomicXmr: '0', endsAt: null, feeBps: 250 };
assert.equal(marketActionV3Schema.safeParse(terms).success, false);
for (const invalid of ['01', '+1', '1.0', '1e0', '9'.repeat(23)]) {
assert.equal(marketActionV3Schema.safeParse({ ...terms, priceAtomicXmr: invalid }).success, false);
}
});
test('listings reject unknown, immature, stale, foreign, duplicate, and pending cards', async t => {
const f = await fixture(t);
const base = { type: 'list' as const, cardHash: f.chain.state.blocks[0].tokenId, sequence: 0,
kind: 'fixed' as const, priceCents: 1500, endsAt: null, feeBps: 250 };
await assert.rejects(f.run(bob, base), /Card/);
await assert.rejects(f.run(alice, { ...base, cardHash: '0'.repeat(64) }), /Card/);
await assert.rejects(f.run(alice, { ...base, sequence: 1 }), /Card/);
await assert.rejects(f.run(alice, { ...base, feeBps: 0 }), /fee changed/);
f.chain.state.blocks.pop();
await assert.rejects(f.run(alice, base), /Card/);
f.chain.state.blocks.push({ ...f.chain.state.blocks[0], height: 2 });
f.chain.state.pendingTransfers.push(f.delivery({ cardHash: base.cardHash, sequence: 0 }));
await assert.rejects(f.run(alice, base), /pending/);
f.chain.state.pendingTransfers = [];
const listing = await f.list();
await assert.rejects(f.list(), /already listed/);
await f.restart();
assert.equal(f.market.browse().listings[0].id, listing.id);
await f.run(alice, { type: 'cancel', listingId: listing.id });
assert.equal(f.market.browse().listings.length, 0);
});
test('fixed-price reservations are exclusive, idempotent, private and expire without charging', async t => {
const f = await fixture(t), listing = await f.list();
await assert.rejects(f.buy(listing, alice), /own card/);
const request = f.envelopeV3(bob, { type: 'buy', listingId: listing.id, priceAtomicXmr: '100000000000', feeBps: 250 });
const results = await Promise.allSettled([f.market.execute(request), f.buy(listing, carol)]);
assert.equal(results[0].status, 'fulfilled'); assert.equal(results[1].status, 'rejected');
const order = (results[0] as PromiseFulfilledResult<MarketOrderV3>).value;
assert.deepEqual(await f.market.execute(request), order);
assert.equal((await f.orders(carol)).length, 0);
assert.equal((await f.orders(alice)).length, 1);
await assert.rejects(f.run(carol, { type: 'mock-pay', orderId: order.id }), /Only the order buyer/);
await assert.rejects(f.run(alice, { type: 'cancel', listingId: listing.id }), /available/);
f.advance(30 * 60000);
assert.equal((await f.orders())[0].status, 'expired');
assert.equal(f.market.browse().listings[0].status, 'open');
await f.buy(listing, carol);
});
test('auction bids are signed, increasing, deadline-bound, uncancellable, and winner-only', async t => {
const f = await fixture(t), l = await f.list('auction');
await assert.rejects(f.buy(l), /winner/);
await assert.rejects(f.run(alice, { type: 'bid', listingId: l.id, priceCents: 1500 }), /own card/);
await assert.rejects(f.run(bob, { type: 'bid', listingId: l.id, priceCents: 1499 }), /Bid/);
await f.run(bob, { type: 'bid', listingId: l.id, priceCents: 1500 });
await assert.rejects(f.run(carol, { type: 'bid', listingId: l.id, priceCents: 1500 }), /Bid/);
await assert.rejects(f.run(alice, { type: 'cancel', listingId: l.id }), /cannot be cancelled/);
await f.run(carol, { type: 'bid', listingId: l.id, priceCents: 1600 });
f.advance(60000);
await assert.rejects(f.run(bob, { type: 'bid', listingId: l.id, priceCents: 1700 }), /not accepting/);
await assert.rejects(f.buy(l, bob, 1600), /winner/);
await assert.rejects(f.buy(l, carol, 1500), /terms changed/);
const order = await f.buy(l, carol, 1600);
assert.equal(order.priceAtomicXmr, '106666666667'); assert.equal(order.payBy, f.now + 30 * 60000);
assert.equal(order.additionalFee?.bps, 100);
assert.equal(order.additionalFee?.address, ADDITIONAL_MARKET_FEE.address);
});
test('auctions without bids or unpaid winners expire without a silent runner-up sale', async t => {
const f = await fixture(t), l = await f.list('auction');
f.advance(60000); assert.equal(f.market.browse().listings.length, 0);
const next = await f.list('auction');
await f.run(bob, { type: 'bid', listingId: next.id, priceCents: 1500 });
f.advance(31 * 60000);
assert.equal(f.market.browse().listings.length, 0);
await assert.rejects(f.buy(next), /available/);
});
test('mock payment cannot release a card: seller signature, exact buyer, and canonical depth required', async t => {
const f = await fixture(t), l = await f.list(), o = await f.buy(l);
await assert.rejects(f.run(alice, { type: 'deliver', orderId: o.id, transfer: f.delivery(o) }), /paid order seller/);
await f.run(bob, { type: 'mock-pay', orderId: o.id });
await assert.rejects(f.run(bob, { type: 'deliver', orderId: o.id, transfer: f.delivery(o) }), /paid order seller/);
await assert.rejects(f.run(alice, { type: 'deliver', orderId: o.id, transfer: f.delivery(o, carol) }), /match this sale/);
const transfer = f.delivery(o);
await f.run(alice, { type: 'deliver', orderId: o.id, transfer });
assert.equal((await f.orders())[0].payout, 'not-started');
assert.equal((await f.orders())[0].additionalFee?.payout, 'not-started');
f.confirm(transfer);
assert.equal((await f.orders())[0].status, 'delivery-pending');
f.chain.state.blocks.push({ ...f.chain.state.blocks[0], height: 4, transactions: [] });
const completed = (await f.orders())[0];
assert.equal(completed.status, 'complete'); assert.equal(completed.payout, 'mock-paid');
assert.equal(completed.feeXmr, '0.002500000000'); assert.equal(completed.sellerNetXmr, '0.096500000000');
assert.equal(completed.additionalFee?.amountXmr, '0.001000000000');
assert.equal(completed.additionalFee?.address, ADDITIONAL_MARKET_FEE.address);
assert.equal(completed.additionalFee?.payout, 'mock-paid');
assert.equal(f.market.browse().listings.length, 0);
});
test('settlement policy and quote survive restart/config changes; reorg never triggers duplicate payout', async t => {
const f = await fixture(t), l = await f.list(), o = await f.buy(l);
await f.run(bob, { type: 'mock-pay', orderId: o.id });
const tx = f.delivery(o);
await f.run(alice, { type: 'deliver', orderId: o.id, transfer: tx });
f.confirm(tx);
f.chain.state.blocks.push({ ...f.chain.state.blocks[0], height: 4, transactions: [] });
assert.equal((await f.orders())[0].status, 'complete');
const id = f.market.id;
await f.restart(); assert.equal(f.market.id, id);
const restored = (await f.orders())[0];
assert.equal(restored.status, 'complete'); assert.equal(restored.requiredConfirmations, 2);
assert.equal(restored.invoice.amountXmr, o.invoice.amountXmr);
assert.equal(restored.feeBps, 250);
assert.equal(restored.additionalFee?.address, ADDITIONAL_MARKET_FEE.address);
assert.equal(restored.additionalFee?.amountXmr, '0.001000000000');
assert.equal(restored.additionalFee?.payout, 'mock-paid');
f.chain.state.blocks.splice(2); f.chain.state.transfers = [];
assert.equal((await f.orders())[0].status, 'review');
f.confirm(tx); f.chain.state.blocks.push({ ...f.chain.state.blocks[0], height: 4, transactions: [] });
assert.equal((await f.orders())[0].status, 'review');
assert.equal((await f.orders())[0].payout, 'review');
assert.equal((await f.orders())[0].additionalFee?.payout, 'review');
});
test('failed seller delivery offers a full mock refund, never a seller payout', async t => {
const f = await fixture(t), l = await f.list(), o = await f.buy(l);
await f.run(bob, { type: 'mock-pay', orderId: o.id });
await assert.rejects(f.run(bob, { type: 'refund', orderId: o.id }), /Refund requires/);
f.advance(48 * 3600_000);
assert.equal((await f.orders())[0].status, 'refund-required');
await f.run(bob, { type: 'refund', orderId: o.id });
assert.equal((await f.orders())[0].status, 'refunded');
assert.equal((await f.orders())[0].payout, 'not-started');
assert.equal((await f.orders())[0].additionalFee?.payout, 'not-started');
assert.equal(f.market.browse().listings.length, 0);
});
test('stale listings are removed and changed cards cannot be paid for', async t => {
const f = await fixture(t), l = await f.list(), o = await f.buy(l);
f.chain.state.transfers.push(f.delivery(o, carol));
await assert.rejects(f.run(bob, { type: 'mock-pay', orderId: o.id }), /Seller card changed/);
f.advance(30 * 60000);
await f.orders(); assert.equal(f.market.browse().listings.length, 0);
});
test('direct canonical delivery outside the marketplace cannot also produce a refund', async t => {
const f = await fixture(t), l = await f.list(), o = await f.buy(l);
await f.run(bob, { type: 'mock-pay', orderId: o.id });
const tx = f.delivery(o);
f.confirm(tx); // No marketplace deliver command or saved transferId.
const settling = (await f.orders())[0];
assert.equal(settling.status, 'delivery-pending');
assert.equal(settling.transferId, transferId(tx));
await assert.rejects(f.run(bob, { type: 'refund', orderId: o.id }), /Refund requires/);
f.chain.state.blocks.push({ ...f.chain.state.blocks[0], height: 4, transactions: [] });
assert.equal((await f.orders())[0].status, 'complete');
});
test('direct pending delivery is not a conflict, while unpaid delivery never triggers payout', async t => {
const f = await fixture(t), l = await f.list(), o = await f.buy(l);
await f.run(bob, { type: 'mock-pay', orderId: o.id });
f.chain.state.pendingTransfers.push(f.delivery(o));
f.advance(48 * 3600_000);
assert.equal((await f.orders())[0].status, 'paid');
await assert.rejects(f.run(bob, { type: 'refund', orderId: o.id }), /Refund requires/);
});
test('canonical delivery without mock payment is held for review, not paid out', async t => {
const f = await fixture(t), l = await f.list(), o = await f.buy(l);
f.confirm(f.delivery(o));
f.chain.state.blocks.push({ ...f.chain.state.blocks[0], height: 4, transactions: [] });
assert.equal((await f.orders())[0].status, 'review');
assert.equal((await f.orders())[0].payout, 'review');
assert.equal((await f.orders())[0].additionalFee?.payout, 'review');
});
test('old open listings require fresh seller fee authorization and cannot be bought', async t => {
const f = await fixture(t), l = await f.list();
const db = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite'));
try {
delete l.additionalFee;
db.prepare('UPDATE listings SET data=? WHERE id=?').run(JSON.stringify(l), l.id);
} finally { db.close(); }
assert.equal(f.market.browse().listings.length, 0);
await assert.rejects(f.buy(l), /available/);
assert.equal((await f.list()).additionalFee?.bps, 100);
});
test('existing reserved orders retain their agreed net; the additional fee is never retroactive', async t => {
const f = await fixture(t), l = await f.list(), order = await f.buy(l);
const db = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite'));
try {
delete l.additionalFee;
delete order.additionalFee;
order.sellerNetXmr = '0.097500000000';
db.prepare('UPDATE listings SET data=? WHERE id=?').run(JSON.stringify({ ...l, status: 'reserved' }), l.id);
db.prepare('UPDATE orders SET data=? WHERE id=?').run(JSON.stringify(order), order.id);
} finally { db.close(); }
await f.restart();
await f.run(bob, { type: 'mock-pay', orderId: order.id });
const tx = f.delivery(order);
await f.run(alice, { type: 'deliver', orderId: order.id, transfer: tx });
f.confirm(tx);
for (let i = 0; i < 4; i++) f.chain.state.blocks.push({ ...f.chain.state.blocks[0], height: 4 + i, transactions: [] });
const restored = (await f.orders())[0];
assert.equal(restored.status, 'complete');
assert.equal(restored.additionalFee, undefined);
assert.equal(restored.sellerNetXmr, '0.097500000000');
});