Files
wikideck/tests/market.test.ts
T
2026-10-22 10:45:32 -04:00

772 lines
50 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 { mkdir, 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, useWorker = false, workerLedger = 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 fEvents: CanonicalEvent[] = [];
const streamId = randomUUID();
const ledgerFile = join(dir, 'testnet', 'ledger.sqlite');
if (workerLedger) {
await mkdir(join(dir, 'testnet'), { recursive: true });
const ledgerDb = new DatabaseSync(ledgerFile);
ledgerDb.exec(`PRAGMA journal_mode=WAL; CREATE TABLE blocks(hash TEXT PRIMARY KEY,block_json TEXT NOT NULL);
CREATE TABLE canonical_blocks(height INTEGER PRIMARY KEY,hash TEXT NOT NULL);
CREATE TABLE canonical_nfts(token TEXT PRIMARY KEY,owner TEXT NOT NULL,sequence INTEGER NOT NULL,minted_height INTEGER NOT NULL,block_hash TEXT NOT NULL);
CREATE TABLE canonical_transactions(id TEXT PRIMARY KEY,height INTEGER NOT NULL,block_hash TEXT NOT NULL);
CREATE TABLE canonical_events(sequence INTEGER PRIMARY KEY,type TEXT NOT NULL);
CREATE TABLE pending_transfers(position INTEGER PRIMARY KEY,id TEXT NOT NULL,transaction_json TEXT NOT NULL);`);
const insertBlock = ledgerDb.prepare('INSERT INTO blocks VALUES(?,?)');
const insertCanonicalBlock = ledgerDb.prepare('INSERT INTO canonical_blocks VALUES(?,?)');
const insertNft = ledgerDb.prepare('INSERT INTO canonical_nfts VALUES(?,?,?,?,?)');
chain.state.blocks.forEach((block, i) => {
const hash = i === chain.state.blocks.length - 1 ? chain.state.tip : (i + 1).toString(16).padStart(64, '0');
insertBlock.run(hash, JSON.stringify(block)); insertCanonicalBlock.run(block.height, hash);
insertNft.run(block.tokenId, alice.actor, 0, block.height, hash);
});
ledgerDb.close();
}
const syncWorkerEvents = () => {
if (!workerLedger) return;
const ledgerDb = new DatabaseSync(ledgerFile);
try {
ledgerDb.exec('BEGIN IMMEDIATE'); ledgerDb.exec('DELETE FROM canonical_events');
const insert = ledgerDb.prepare('INSERT INTO canonical_events(sequence,type) VALUES(?,?)');
fEvents.forEach(event => insert.run(event.sequence, event.type)); ledgerDb.exec('COMMIT');
} finally { ledgerDb.close(); }
};
const adapter = indexed ? { ...chain,
snapshot: (): TestnetSnapshot => { throw new Error('Indexed market must not request a full chain snapshot'); },
summary: () => ({ tip: chain.state.tip, revision: fEvents.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: () => fEvents.length,
canonicalEvents: (after: number, limit = 64) => {
if (after > fEvents.length) throw new Error('Canonical cursor ahead of restored ledger');
const page = fEvents.slice(after, after + limit), cursor = page.at(-1)?.sequence ?? after;
return { streamId, previousAnchor: after ? fEvents[after - 1].eventHash : GENESIS_HASH,
anchor: page.at(-1)?.eventHash ?? (after ? fEvents[after - 1].eventHash : GENESIS_HASH),
events: page, cursor, head: fEvents.length, caughtUp: cursor === fEvents.length };
},
...(workerLedger ? { reconciliationDatabasePath: () => ledgerFile } : {}),
} : chain;
let time = 1_800_000_000_000;
let market = await Market.open(dir, adapter, { feeBps: 250, confirmations: 2 }, () => time, { worker: useWorker });
t.after(async () => { await 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 = [];
};
const events = fEvents;
return { dir, chain, events, syncWorkerEvents, ledgerFile, 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 = {}) => { await market.close(); market = await Market.open(dir, adapter,
{ feeBps: 500, confirmations: 5, ...policy }, () => time, { worker: useWorker }); } };
}
async function drainWorkerReconciliation(f: Awaited<ReturnType<typeof fixture>>, maxPasses = 2000): Promise<void> {
for (let pass = 0; pass < maxPasses; pass++) {
f.market.browse();
if (!f.market.hasReconciliationBacklog() && !f.market.isReconciliationWorkerBusy()) return;
await new Promise(resolve => setTimeout(resolve, 1));
}
assert.fail(`Worker reconciliation did not drain within the bounded test wait: ${JSON.stringify(f.market.diagnostics())}`);
}
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(16_000);
assert.throws(() => f.market.browse(), /history changed/);
f.events[129].eventHash = anchor;
f.events.length = 0;
assert.throws(() => f.market.browse(), /cursor ahead/);
});
test('market reconciliation keyset progress is bounded, durable and resumes after restart', async t => {
const f = await fixture(t, true), db = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite'));
const ids = Array.from({ length: 260 }, (_, i) => `00000000-0000-4000-8000-${(i + 1).toString(16).padStart(12, '0')}`);
try {
const insertListing = db.prepare('INSERT INTO listings VALUES(?,?,?,?)');
const insertOrder = db.prepare('INSERT INTO orders VALUES(?,?)');
for (const [i, id] of ids.entries()) {
const listingId = `10000000-0000-4000-8000-${(i + 1).toString(16).padStart(12, '0')}`;
const cardHash = (i + 1).toString(16).padStart(64, '0');
const target = i === ids.length - 1;
insertListing.run(listingId, cardHash, target ? 'sold' : 'cancelled', JSON.stringify({ id: listingId, seller: alice.actor,
cardHash, sequence: 0, status: target ? 'sold' : 'cancelled', kind: 'fixed' }));
const order = target ? { id, termsVersion: 3, listingId, seller: alice.actor, buyer: bob.actor, cardHash, sequence: 0,
priceAtomicXmr: '100000000000', feeBps: 250, createdAt: f.now, payBy: f.now + 1000, deliverBy: f.now + 2000,
status: 'complete', payout: 'mock-paid', deliveryConfirmations: 2, requiredConfirmations: 2, transferId: '0'.repeat(64) }
: { id, listingId, seller: alice.actor, buyer: bob.actor, cardHash, sequence: 0, status: 'refunded', createdAt: f.now };
insertOrder.run(id, JSON.stringify(order));
}
} finally { db.close(); }
const disconnectedTx = transferId({ network: NETWORK, chainId: CHAIN_ID,
cardHash: (ids.length).toString(16).padStart(64, '0'), from: alice.actor, to: bob.actor, sequence: 0, publicKey: '', signature: '' });
f.events.push({ sequence: 1, type: 'disconnect', height: 1, hash: 'd'.repeat(64), previousHash: GENESIS_HASH,
transactionIds: [disconnectedTx], eventHash: 'e'.repeat(64) });
// First browse starts reconciliation and visits only the first fixed-size keyset page.
f.market.browse();
const progress = () => {
const conn = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite'));
try { return JSON.parse((conn.prepare('SELECT value FROM market_meta WHERE key=?').get('canonical_reconciliation_job') as { value: string }).value); }
finally { conn.close(); }
};
assert.equal(progress().stage, 'orders'); assert.equal(progress().afterId, ids[31]);
assert.equal(f.market.hasReconciliationBacklog(), true);
await assert.rejects(f.list(), /catching up/); // Second 32-order batch; no new listing is admitted.
assert.equal(progress().stage, 'orders'); assert.equal(progress().afterId, ids[63]);
// The ledger advances while this durable multi-batch job is in flight.
f.events.push({ sequence: 2, type: 'connect', height: 2, hash: 'f'.repeat(64), previousHash: 'd'.repeat(64),
transactionIds: [], eventHash: '2'.repeat(64) });
f.market.browse(); // Tip/revision changed: rewind and bind the updated event page.
assert.equal(progress().eventPage.cursor, 2);
assert.equal(progress().afterId, ids[31]);
const tailStatus = () => {
const conn = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite'));
try { return JSON.parse((conn.prepare('SELECT data FROM orders WHERE id=?').get(ids[259]) as { data: string }).data).status; }
finally { conn.close(); }
};
for (let i = 0; i < 12 && tailStatus() !== 'review'; i++) f.market.browse();
assert.equal(tailStatus(), 'review'); // Disconnect reached the tail order before checkpoint advancement.
assert.equal(f.market.diagnostics().canonicalEventCursor, 0);
assert.equal(f.market.hasReconciliationBacklog(), true, 'new events appended during job must stay behind the work fence');
const reviewDb = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite'));
try {
const row = reviewDb.prepare('SELECT data FROM orders WHERE id=?').get(ids[259]) as { data: string };
assert.equal(JSON.parse(row.data).status, 'review'); // Disconnect reached the tail batch before the cursor advances.
} finally { reviewDb.close(); }
assert.equal(f.market.diagnostics().canonicalEventCursor, 0);
// Simulate process death after committed batch progress, then resume using the SQLite job cursor.
await f.restart(); assert.equal(f.market.diagnostics().eventBacklog, true);
for (let i = 0; i < 24 && f.market.hasReconciliationBacklog(); i++) f.market.browse();
assert.equal(f.market.hasReconciliationBacklog(), false);
const conn = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite'));
try { assert.equal(conn.prepare('SELECT 1 FROM market_meta WHERE key=?').get('canonical_reconciliation_job'), undefined); }
finally { conn.close(); }
assert.equal(f.market.diagnostics().canonicalEventCursor, 2);
});
test('bounded disconnect review batch commits before checkpoint advance and resumes after 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) });
const initiallyCompleted = (await f.orders())[0];
assert.equal(initiallyCompleted.status, 'complete', JSON.stringify({ order: initiallyCompleted,
job: new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite')).prepare('SELECT value FROM market_meta WHERE key=?').get('canonical_reconciliation_job') }));
// 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, 'review');
const job = JSON.parse((db.prepare("SELECT value FROM market_meta WHERE key='canonical_reconciliation_job'").get() as { value: string }).value);
assert.equal(job.stage, 'listings'); // Order effects and work cursor committed atomically; event anchor still has not advanced.
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('market reconciliation batch failure rolls back order effects and resumes from durable keyset', 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: 4, transactions: [] });
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');
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_review_batch BEFORE UPDATE ON orders WHEN json_extract(NEW.data,'$.status')='review' BEGIN SELECT RAISE(ABORT,'injected reconciliation batch failure'); END");
assert.throws(() => f.market.browse(), /injected reconciliation batch failure/);
const row = db.prepare('SELECT data FROM orders WHERE id=?').get(order.id) as { data: string };
assert.equal(JSON.parse(row.data).status, 'complete');
const job = JSON.parse((db.prepare("SELECT value FROM market_meta WHERE key='canonical_reconciliation_job'").get() as { value: string }).value);
assert.equal(job.stage, 'orders'); assert.equal(job.afterId, null);
assert.equal(f.market.diagnostics().canonicalEventCursor, 1);
db.exec('DROP TRIGGER fail_review_batch');
} finally { db.close(); }
await f.restart();
assert.equal((await f.orders())[0].status, 'review');
assert.equal(f.market.diagnostics().canonicalEventCursor, 2);
assert.equal(f.market.hasReconciliationBacklog(), false);
});
test('worker-backed reconciliation commits reorg holds atomically and resumes after restart', async t => {
const f = await fixture(t, true, true);
await drainWorkerReconciliation(f);
const 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: 4, transactions: [] });
f.events.push({ sequence: 1, type: 'connect', height: 3, hash: 'd'.repeat(64), previousHash: 'e'.repeat(64),
transactionIds: [transferId(delivery)], eventHash: '1'.repeat(64) });
await f.orders(); await drainWorkerReconciliation(f);
assert.equal((await f.orders())[0].status, 'complete');
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_worker_review BEFORE UPDATE ON orders WHEN json_extract(NEW.data,'$.status')='review' BEGIN SELECT RAISE(ABORT,'injected worker batch failure'); END");
} finally { db.close(); }
f.market.browse();
assert.equal(f.market.isReconciliationWorkerBusy(), true);
assert.equal(f.market.diagnostics().eventBacklog, true);
for (let i = 0; i < 100 && (f.market.isReconciliationWorkerBusy() || !f.market.diagnostics().reconciliationWorkerFailed); i++) {
await new Promise(resolve => setTimeout(resolve, 1));
}
assert.equal(f.market.diagnostics().reconciliationWorkerFailed, true);
assert.equal(f.market.isReconciliationWorkerBusy(), false);
await assert.rejects(f.list(), /catching up/); // Do not admit new business while the worker commit is uncertain.
assert.equal(f.market.isReconciliationWorkerBusy(), false); // Failure stays paused instead of hot-loop retrying.
const check = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite'));
try {
assert.equal(JSON.parse((check.prepare('SELECT data FROM orders WHERE id=?').get(order.id) as { data: string }).data).status, 'complete');
assert.equal(f.market.diagnostics().canonicalEventCursor, 1);
check.exec('DROP TRIGGER fail_worker_review');
} finally { check.close(); }
await f.restart();
await drainWorkerReconciliation(f);
const reviewed = (await f.orders())[0];
assert.equal(reviewed.status, 'review'); assert.equal(reviewed.payout, 'review');
assert.equal(f.market.diagnostics().canonicalEventCursor, 2);
assert.equal(f.market.hasReconciliationBacklog(), false);
});
test('worker reconciliation re-fences a canonical event appended during an in-flight batch', async t => {
const f = await fixture(t, true, true), db = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite'));
const ids = Array.from({ length: 70 }, (_, i) => `20000000-0000-4000-8000-${(i + 1).toString(16).padStart(12, '0')}`);
try {
const insertListing = db.prepare('INSERT INTO listings VALUES(?,?,?,?)');
const insertOrder = db.prepare('INSERT INTO orders VALUES(?,?)');
for (const [i, id] of ids.entries()) {
const listingId = `30000000-0000-4000-8000-${(i + 1).toString(16).padStart(12, '0')}`;
const cardHash = (i + 1).toString(16).padStart(64, '0');
insertListing.run(listingId, cardHash, 'cancelled', JSON.stringify({ id: listingId, cardHash, seller: alice.actor,
sequence: 0, kind: 'fixed', status: 'cancelled' }));
insertOrder.run(id, JSON.stringify({ id, listingId, seller: alice.actor, buyer: bob.actor, cardHash,
sequence: 0, status: 'refunded' }));
}
} finally { db.close(); }
f.events.push({ sequence: 1, type: 'connect', height: 1, hash: 'd'.repeat(64), previousHash: GENESIS_HASH,
transactionIds: [], eventHash: '1'.repeat(64) });
f.market.browse();
assert.equal(f.market.isReconciliationWorkerBusy(), true);
f.events.push({ sequence: 2, type: 'connect', height: 2, hash: 'e'.repeat(64), previousHash: 'd'.repeat(64),
transactionIds: [], eventHash: '2'.repeat(64) });
await drainWorkerReconciliation(f, 100);
assert.equal(f.market.diagnostics().canonicalEventCursor, 2);
assert.equal(f.market.hasReconciliationBacklog(), false);
});
test('worker reads canonical facts from a separate read-only Ledger snapshot and re-fences event appends', async t => {
const f = await fixture(t, true, true, true), db = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite'));
const soldCard = 'a'.repeat(64), deliveryId = transferId({ network: NETWORK, chainId: CHAIN_ID, cardHash: soldCard,
from: alice.actor, to: bob.actor, sequence: 0, publicKey: '', signature: '' });
try {
const insertListing = db.prepare('INSERT INTO listings VALUES(?,?,?,?)');
const insertOrder = db.prepare('INSERT INTO orders VALUES(?,?)');
for (let i = 0; i < 70; i++) {
const id = `40000000-0000-4000-8000-${(i + 1).toString(16).padStart(12, '0')}`;
const listingId = `50000000-0000-4000-8000-${(i + 1).toString(16).padStart(12, '0')}`;
const active = i === 0, cardHash = active ? soldCard : (i + 1).toString(16).padStart(64, '0');
const listingStatus = active ? 'reserved' : 'cancelled';
insertListing.run(listingId, cardHash, listingStatus, JSON.stringify({ id: listingId, cardHash, seller: alice.actor,
sequence: 0, kind: 'fixed', status: listingStatus }));
insertOrder.run(id, JSON.stringify({ id, listingId, seller: alice.actor, buyer: bob.actor, cardHash,
sequence: 0, status: active ? 'paid' : 'refunded', payout: active ? 'not-started' : undefined,
createdAt: f.now, payBy: f.now + 1000, deliverBy: f.now + 2000,
requiredConfirmations: 2, deliveryConfirmations: 0 }));
}
} finally { db.close(); }
const ledgerDb = new DatabaseSync(f.ledgerFile);
try {
ledgerDb.prepare('UPDATE canonical_nfts SET owner=?,sequence=1 WHERE token=?').run(bob.actor, soldCard);
ledgerDb.prepare('INSERT INTO canonical_transactions VALUES(?,?,?)').run(deliveryId, 1, 'b'.repeat(64));
} finally { ledgerDb.close(); }
f.events.push({ sequence: 1, type: 'connect', height: 1, hash: 'd'.repeat(64), previousHash: GENESIS_HASH,
transactionIds: [deliveryId], eventHash: '1'.repeat(64) });
f.syncWorkerEvents();
f.market.browse();
assert.equal(f.market.isReconciliationWorkerBusy(), true);
f.events.push({ sequence: 2, type: 'connect', height: 2, hash: 'e'.repeat(64), previousHash: 'd'.repeat(64),
transactionIds: [], eventHash: '2'.repeat(64) });
f.syncWorkerEvents();
await drainWorkerReconciliation(f, 100);
assert.equal(f.market.diagnostics().canonicalEventCursor, 2);
assert.equal(f.market.hasReconciliationBacklog(), false);
const orders = await f.orders();
const completed = orders.find(order => order.cardHash === soldCard);
assert.ok(completed);
assert.equal(completed.status, 'complete'); assert.equal(completed.payout, 'mock-paid');
const verifyDb = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite'));
try {
assert.equal((verifyDb.prepare('SELECT status FROM listings WHERE id=?').get(completed.listingId) as { status: string }).status, 'sold');
} finally { verifyDb.close(); }
});
test('worker reconciliation yields to event-loop timers during a large durable backlog', async t => {
const f = await fixture(t, true, true, true), db = new DatabaseSync(join(f.dir, 'testnet', 'market.sqlite'));
try {
const insertListing = db.prepare('INSERT INTO listings VALUES(?,?,?,?)');
const insertOrder = db.prepare('INSERT INTO orders VALUES(?,?)');
db.exec('BEGIN IMMEDIATE');
try {
for (let i = 0; i < 3000; i++) {
const id = `60000000-0000-4000-8000-${(i + 1).toString(16).padStart(12, '0')}`;
const listingId = `70000000-0000-4000-8000-${(i + 1).toString(16).padStart(12, '0')}`;
const cardHash = (i + 1).toString(16).padStart(64, '0');
const listing = { id: listingId, cardHash, seller: alice.actor, sequence: 0, kind: 'fixed', status: 'cancelled' };
insertListing.run(listingId, cardHash, 'cancelled', JSON.stringify(listing));
insertOrder.run(id, JSON.stringify({ id, listingId, seller: alice.actor, buyer: bob.actor,
cardHash, sequence: 0, status: 'refunded' }));
}
db.exec('COMMIT');
} catch (error) { db.exec('ROLLBACK'); throw error; }
} finally { db.close(); }
f.market.browse();
assert.equal(f.market.isReconciliationWorkerBusy(), true);
let timerRan = false;
await new Promise<void>(resolve => setTimeout(() => { timerRan = true; resolve(); }, 0));
assert.equal(timerRan, true);
assert.equal(f.market.isReconciliationWorkerBusy(), true,
'large reconciliation should still be in flight while the main-loop timer has run');
});
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','processor','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');
});