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, type MarketListing, type MarketOrder, type MarketActivity } from '../shared/market.ts'; import { NETWORK, nftTransferMessage, type NftTransfer, type TestnetSnapshot, type TestnetBlock } from '../shared/testnet.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 }) { const dir = await mkdtemp(join(tmpdir(), 'wikideck-market-')); const chain = chainFixture(); let time = 1_800_000_000_000; let market = await Market.open(dir, chain, { feeBps: 250, confirmations: 2, usdCentsPerXmr: 15000 }, () => 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 run = (who: typeof alice, action: MarketAction) => market.execute(envelope(who, action)) as Promise; const list = (kind: 'fixed' | 'auction' = 'fixed') => run(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: MarketListing, who = bob, priceCents = l.priceCents) => run(who, { type: 'buy', listingId: l.id, priceCents, feeBps: l.feeBps }); const orders = async (who = bob) => (await run(who, { type: 'activity' })).orders; const delivery = (o: MarketOrder, 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, envelope, run, list, buy, orders, delivery, confirm, get market() { return market; }, get now() { return time; }, advance: (ms: number) => { time += ms; }, restart: async () => { market.close(); market = await Market.open(dir, chain, { feeBps: 500, confirmations: 5, usdCentsPerXmr: 20000 }, () => time); } }; } 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('market commands bind actor, instance, network, expiry, and every listing term', async t => { const f = await fixture(t); const original = f.envelope(alice, { type: 'list', cardHash: f.chain.state.blocks[0].tokenId, sequence: 0, kind: 'fixed', priceCents: 1500, 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, priceCents: 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(marketMessage(original)); assert.equal(message[1], 'market-command-v2'); 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, priceCents: 1600 } }; await assert.rejects(async () => f.market.execute({ ...changed, signature: alice.signed(marketMessage(changed)) }), /Nonce/); assert.equal(f.market.browse().listings.length, 1); }); 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 } as MarketOrder)); 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.envelope(bob, { type: 'buy', listingId: listing.id, priceCents: 1500, 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).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.priceCents, 1600); 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'); });