772 lines
50 KiB
TypeScript
Executable File
772 lines
50 KiB
TypeScript
Executable File
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','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');
|
||
});
|