import { DatabaseSync } from 'node:sqlite'; import { randomUUID } from 'node:crypto'; import { mkdir, chmod } from 'node:fs/promises'; import { join } from 'node:path'; import { NETWORK, CONSENSUS, nftTransferMessage, testnetOwnerOf, transactionCardIds, type TestnetSnapshot, type NftTransfer } from '../../shared/testnet.ts'; import { ADDITIONAL_MARKET_FEE, marketCommandSchema, marketMessage, type MarketCommand, type MarketListing, type MarketOrder, type MarketInfo, type MarketActivity } from '../../shared/market.ts'; import { CHAIN_ID, transactionId, transferSchema, verifySignature } from '../testnet/consensus.ts'; import { mockQuote, paymentMode } from './payments.ts'; type Chain = { snapshot(): TestnetSnapshot; summary?(): { tip: string; revision: number }; submitTransfer(raw: unknown): Promise }; type Config = { feeBps: number; confirmations: number; usdCentsPerXmr: number }; export function marketConfig(env: NodeJS.ProcessEnv = process.env): Config { paymentMode(env); const integer = (name: string, fallback: number, min: number, max: number) => { const text = env[name] ?? String(fallback); if (!/^\d+$/.test(text)) throw new Error(`Invalid ${name}`); const n = Number(text); if (!Number.isSafeInteger(n) || n < min || n > max) throw new Error(`Invalid ${name}`); return n; }; return { feeBps: integer('WIKIDECK_MARKET_FEE_BPS', 250, 0, 5000), confirmations: integer('WIKIDECK_MARKET_CONFIRMATIONS', 10, 2, 1000), usdCentsPerXmr: integer('WIKIDECK_MOCK_USD_CENTS_PER_XMR', 15000, 1, 100_000_000) }; } const PAYMENT_WINDOW = 30 * 60_000; const DELIVERY_WINDOW = 48 * 60 * 60_000; /** Node-local marketplace, NOT consensus escrow. Its SQLite journal is backed up with the ledger. */ export class Market { private queue: Promise = Promise.resolve(); private queued = 0; private reconciled: { tip: string; revision: number; at: number } | undefined; private readonly db: DatabaseSync; private readonly chain: Chain; readonly config: Config; readonly id: string; private readonly now: () => number; private constructor(db: DatabaseSync, chain: Chain, config: Config, id: string, now: () => number) { this.db = db; this.chain = chain; this.config = config; this.id = id; this.now = now; } static async open(directory: string, chain: Chain, config = marketConfig(), now = Date.now): Promise { const folder = join(directory, NETWORK.includes('mainnet') ? 'mainnet' : 'testnet'); await mkdir(folder, { recursive: true, mode: 0o700 }); const file = join(folder, 'market.sqlite'); const db = new DatabaseSync(file); try { db.exec(`PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA busy_timeout=5000; CREATE TABLE IF NOT EXISTS market_meta(key TEXT PRIMARY KEY, value TEXT NOT NULL); CREATE TABLE IF NOT EXISTS listings(id TEXT PRIMARY KEY, card TEXT NOT NULL, status TEXT NOT NULL, data TEXT NOT NULL); CREATE UNIQUE INDEX IF NOT EXISTS one_active_listing ON listings(card) WHERE status IN ('open','reserved'); CREATE TABLE IF NOT EXISTS orders(id TEXT PRIMARY KEY, data TEXT NOT NULL); CREATE TABLE IF NOT EXISTS receipts(id TEXT PRIMARY KEY, digest TEXT NOT NULL, expires INTEGER NOT NULL, data TEXT NOT NULL);`); db.exec(`CREATE INDEX IF NOT EXISTS listings_by_status ON listings(status); CREATE INDEX IF NOT EXISTS listings_by_seller ON listings(json_extract(data,'$.seller')); CREATE INDEX IF NOT EXISTS orders_by_seller ON orders(json_extract(data,'$.seller')); CREATE INDEX IF NOT EXISTS orders_by_buyer ON orders(json_extract(data,'$.buyer'));`); const meta = db.prepare('SELECT value FROM market_meta WHERE key=?').get('identity') as { value: string } | undefined; let id: string; if (meta) { const identity = JSON.parse(meta.value); if (identity.network !== NETWORK || identity.chainId !== CHAIN_ID || identity.version !== 1 || typeof identity.id !== 'string') throw new Error('Marketplace network/schema mismatch'); id = identity.id; } else { id = randomUUID(); db.prepare('INSERT INTO market_meta VALUES(?,?)').run('identity', JSON.stringify({ version: 1, network: NETWORK, chainId: CHAIN_ID, id })); } if (process.platform !== 'win32') await chmod(file, 0o600); return new Market(db, chain, config, id, now); } catch (error) { db.close(); throw error; } } close() { this.db.close(); } diagnostics() { const states=this.db.prepare("SELECT json_extract(data,'$.status') AS status,COUNT(*) AS count FROM orders GROUP BY json_extract(data,'$.status')").all(); return { queued:this.queued,lastReconciledAt:this.reconciled?.at??null,orderStates:states }; } info(): MarketInfo { return { network: NETWORK, chainId: CHAIN_ID, marketId: this.id, paymentMode: 'mock', feeBps: this.config.feeBps, additionalFee: { ...ADDITIONAL_MARKET_FEE }, deliveryConfirmations: this.config.confirmations, mockUsdPerXmr: (this.config.usdCentsPerXmr / 100).toFixed(2), serverTime: this.now() }; } private rows(table: 'listings' | 'orders'): T[] { return (this.db.prepare(`SELECT data FROM ${table} ORDER BY rowid DESC LIMIT 10000`).all() as { data: string }[]) .map(row => JSON.parse(row.data) as T); } private listing(id: string): MarketListing { const row = this.db.prepare('SELECT data FROM listings WHERE id=?').get(id) as { data: string } | undefined; if (!row) throw new Error('Listing not found'); return JSON.parse(row.data); } private order(id: string): MarketOrder { const row = this.db.prepare('SELECT data FROM orders WHERE id=?').get(id) as { data: string } | undefined; if (!row) throw new Error('Order not found'); return JSON.parse(row.data); } private saveListing(l: MarketListing) { this.db.prepare('INSERT INTO listings VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET status=excluded.status,data=excluded.data WHERE listings.data<>excluded.data') .run(l.id, l.cardHash, l.status, JSON.stringify(l)); } private saveOrder(o: MarketOrder) { this.db.prepare('INSERT INTO orders VALUES(?,?) ON CONFLICT(id) DO UPDATE SET data=excluded.data WHERE orders.data<>excluded.data').run(o.id, JSON.stringify(o)); } private atomic(fn: () => T): T { this.db.exec('BEGIN IMMEDIATE'); try { const result = fn(); this.db.exec('COMMIT'); return result; } catch (error) { this.db.exec('ROLLBACK'); throw error; } } private card(snapshot: TestnetSnapshot, hash: string, owner: string, sequence: number): boolean { const block = snapshot.blocks.find(b => b.tokenId === hash); return !!block && testnetOwnerOf(snapshot, hash) === owner && snapshot.transfers.filter(tx => transactionCardIds(tx).includes(hash)).length === sequence && snapshot.blocks.length + 1 - block.height >= CONSENSUS.coinbaseMaturity; } private deliveryId(order: MarketOrder): string { // Identity excludes signature bytes. Recognize exact sale delivery through ANY submission route, // including peer gossip or a direct /api/transfer, even before the market records delivery intent. return transactionId({ network: NETWORK, chainId: CHAIN_ID, cardHash: order.cardHash, from: order.seller, to: order.buyer, sequence: order.sequence, publicKey: '', signature: '' }); } /** Reconcile from canonical transaction identity, never from a mempool acknowledgement. */ private reconcile() { const s = this.chain.snapshot(), now = this.now(); const deliveries = new Map(); for (const b of s.blocks) for (const tx of b.transactions) deliveries.set(transactionId(tx), b); const pendingIds = new Set(s.pendingTransfers.map(transactionId)); this.atomic(() => { for (const o of this.rows('orders')) { const l = this.listing(o.listingId); if (o.status === 'refunded' || o.status === 'expired') continue; const deliveryId = this.deliveryId(o); const delivery = deliveries.get(deliveryId); const confirmations = delivery ? s.blocks.length - delivery.height + 1 : 0; o.deliveryConfirmations = confirmations; if (o.status === 'complete' || o.status === 'review') { if (!delivery || confirmations < o.requiredConfirmations) { o.status = 'review'; o.payout = 'review'; if (o.additionalFee) o.additionalFee.payout = 'review'; } this.saveOrder(o); continue; // Never automatically pay a second time after a reorganization. } if (delivery && o.status === 'awaiting-payment') { // A gift/direct delivery is not evidence that payment was received. Never pay an unpaid order. o.status = 'review'; o.payout = 'review'; if (o.additionalFee) o.additionalFee.payout = 'review'; } else if (delivery) { o.transferId = deliveryId; o.status = 'delivery-pending'; if (confirmations >= o.requiredConfirmations) { o.status = 'complete'; o.payout = 'mock-paid'; l.status = 'sold'; if (o.additionalFee) o.additionalFee.payout = 'mock-paid'; } } else if (o.status === 'awaiting-payment' && now >= o.payBy) { o.status = 'expired'; l.status = l.kind === 'fixed' ? 'open' : 'expired'; } else if (o.status !== 'awaiting-payment') { const pending = pendingIds.has(deliveryId); const conflict = s.pendingTransfers.some(tx => transactionCardIds(tx).includes(o.cardHash) && transactionId(tx) !== deliveryId); if (!this.card(s, o.cardHash, o.seller, o.sequence) || conflict || (now >= o.deliverBy && !pending)) o.status = 'refund-required'; else if (o.status === 'delivery-pending' && !pending) o.status = 'paid'; } this.saveOrder(o); this.saveListing(l); } for (const l of this.rows('listings')) { if (l.status !== 'open') continue; if (l.additionalFee?.bps !== ADDITIONAL_MARKET_FEE.bps || l.additionalFee?.address !== ADDITIONAL_MARKET_FEE.address || !this.card(s, l.cardHash, l.seller, l.sequence) || s.pendingTransfers.some(tx => transactionCardIds(tx).includes(l.cardHash))) l.status = 'invalid'; else if (l.kind === 'auction' && now >= l.endsAt! + PAYMENT_WINDOW) l.status = 'expired'; else if (l.kind === 'auction' && now >= l.endsAt! && !l.highestBid) l.status = 'expired'; this.saveListing(l); } this.db.prepare('DELETE FROM receipts WHERE expires < ?').run(now - 86400_000); }); const summary = this.chain.summary?.(); if (summary) this.reconciled = { ...summary, at: now }; } browse(offset = 0): { listings: MarketListing[]; next: number | null; info: MarketInfo } { if (!Number.isSafeInteger(offset) || offset < 0 || offset > 10000) throw new Error('Invalid marketplace page'); // Reconciliation must not interleave with delivery submission while its chain await is outstanding. const summary = this.chain.summary?.(); const last = this.reconciled; if (!this.queued && (!summary || !last || summary.tip !== last.tip || summary.revision !== last.revision || this.now() - last.at >= 1000)) this.reconcile(); const rows = this.db.prepare("SELECT data FROM listings WHERE status IN ('open','reserved') ORDER BY rowid DESC LIMIT 25 OFFSET ?") .all(offset) as { data: string }[]; return { listings: rows.slice(0,24).map(r=>JSON.parse(r.data)), next: rows.length>24?offset+24:null, info: this.info() }; } execute(raw: unknown): Promise { const cmd = marketCommandSchema.parse(raw); if (this.queued >= 64) return Promise.reject(new Error('Marketplace busy; retry shortly')); this.queued++; const result = this.queue.then(async () => { try { return await this.run(cmd); } finally { this.queued--; } }); this.queue = result.catch(() => undefined); return result; } private async run(c: MarketCommand): Promise { const now = this.now(); if (c.chainId !== CHAIN_ID || c.marketId !== this.id || c.expiresAt < now || c.expiresAt > now + 5 * 60_000 || !verifySignature(c.publicKey, c.actor, marketMessage(c), c.signature)) throw new Error('Invalid or expired marketplace signature'); const key = `${c.actor}:${c.nonce}`, digest = marketMessage(c); const old = this.db.prepare('SELECT digest,data FROM receipts WHERE id=?').get(key) as { digest: string; data: string } | undefined; if (old) { if (old.digest !== digest) throw new Error('Nonce already used for different terms'); return JSON.parse(old.data); } const a = c.action; const summary = this.chain.summary?.(), last = this.reconciled; if (a.type !== 'activity' || !summary || !last || summary.tip !== last.tip || summary.revision !== last.revision || this.now() - last.at >= 1000) this.reconcile(); if (a.type === 'activity') return { orders: (this.db.prepare("SELECT data FROM orders WHERE json_extract(data,'$.seller')=? OR json_extract(data,'$.buyer')=? ORDER BY rowid DESC") .all(c.actor,c.actor) as { data: string }[]).map(r=>JSON.parse(r.data)), listings: (this.db.prepare("SELECT data FROM listings WHERE json_extract(data,'$.seller')=? ORDER BY rowid DESC") .all(c.actor) as { data: string }[]).map(r=>JSON.parse(r.data)) }; if (a.type === 'deliver') { const o = this.order(a.orderId), tx = transferSchema.parse(a.transfer); if (o.seller !== c.actor || !['paid', 'delivery-pending'].includes(o.status)) throw new Error('Only the paid order seller can deliver'); if (tx.cardHash !== o.cardHash || tx.sequence !== o.sequence || tx.from !== o.seller || tx.to !== o.buyer || !verifySignature(tx.publicKey, tx.from, nftTransferMessage(tx), tx.signature)) throw new Error('Delivery does not match this sale'); // Persist intent before the ledger await. A crash/restart reconciles by deterministic tx ID. o.transfer = tx; o.transferId = transactionId(tx); o.status = 'delivery-pending'; this.saveOrder(o); await this.chain.submitTransfer(tx); return this.receipt(key, digest, c.expiresAt, o); } return this.atomic(() => { const result = this.mutate(c); return this.receipt(key, digest, c.expiresAt, result); }); } private receipt(key: string, digest: string, expires: number, value: T): T { this.db.prepare('INSERT INTO receipts VALUES(?,?,?,?)').run(key, digest, expires, JSON.stringify(value)); return value; } private mutate(c: MarketCommand): MarketListing | MarketOrder { const a = c.action, now = this.now(), s = this.chain.snapshot(); if (a.type === 'list') { if (a.feeBps !== this.config.feeBps) throw new Error('Market fee changed; review and sign again'); if (!this.card(s, a.cardHash, c.actor, a.sequence) || s.pendingTransfers.some(tx => transactionCardIds(tx).includes(a.cardHash))) throw new Error('Card is missing, stale, immature, or already pending'); if (a.kind === 'fixed' ? a.endsAt !== null : a.endsAt === null || a.endsAt < now + 60_000 || a.endsAt > now + 30 * 86400_000) throw new Error('Auction duration must be 1 minute to 30 days; fixed listings have no end'); if (this.rows('listings').filter(l => l.seller === c.actor && ['open','reserved'].includes(l.status)).length >= 100) throw new Error('Limit: 100 active listings per seller'); if ((this.db.prepare('SELECT COUNT(*) AS n FROM listings').get() as { n: number }).n >= 10000) throw new Error('Marketplace journal limit reached; operator archival required'); if (this.db.prepare("SELECT 1 FROM listings WHERE card=? AND status IN ('open','reserved')").get(a.cardHash)) throw new Error('Card already listed'); const mint = s.blocks.find(b => b.tokenId === a.cardHash)!; const l: MarketListing = { id: randomUUID(), cardHash: a.cardHash, sequence: a.sequence, seller: c.actor, title: mint.article.title, image: mint.article.image!, kind: a.kind, priceCents: a.priceCents, feeBps: a.feeBps, additionalFee: { ...ADDITIONAL_MARKET_FEE }, createdAt: now, endsAt: a.endsAt, status: 'open', highestBid: null }; this.saveListing(l); return l; } if (a.type === 'bid' || a.type === 'buy' || a.type === 'cancel') { const l = this.listing(a.listingId); if (l.status !== 'open') throw new Error('Listing is no longer available'); if (a.type === 'cancel') { if (l.seller !== c.actor || l.highestBid) throw new Error('Only the seller can cancel, and auctions with bids cannot be cancelled'); l.status = 'cancelled'; this.saveListing(l); return l; } if (l.seller === c.actor) throw new Error('Cannot buy or bid on your own card'); if (a.type === 'bid') { if (l.kind !== 'auction' || now >= l.endsAt!) throw new Error('Auction is not accepting bids'); const minimum = l.highestBid ? l.highestBid.priceCents + 1 : l.priceCents; if (a.priceCents < minimum) throw new Error('Bid must meet the start price and exceed the current bid by at least $0.01'); l.highestBid = { bidder: c.actor, priceCents: a.priceCents }; this.saveListing(l); return l; } const price = l.kind === 'fixed' ? l.priceCents : l.highestBid?.priceCents; if (l.kind === 'auction' && (now < l.endsAt! || now >= l.endsAt! + PAYMENT_WINDOW || l.highestBid?.bidder !== c.actor)) throw new Error('Only the auction winner can check out within 30 minutes of closing'); if (price !== a.priceCents || l.feeBps !== a.feeBps) throw new Error('Sale terms changed; review and sign again'); if (!this.card(s, l.cardHash, l.seller, l.sequence) || s.pendingTransfers.some(tx => transactionCardIds(tx).includes(l.cardHash))) throw new Error('Card is no longer available'); if ((this.db.prepare('SELECT COUNT(*) AS n FROM orders').get() as { n: number }).n >= 10000) throw new Error('Marketplace order limit reached; operator archival required'); const payBy = l.kind === 'auction' ? l.endsAt! + PAYMENT_WINDOW : now + PAYMENT_WINDOW; const o: MarketOrder = { id: randomUUID(), listingId: l.id, seller: l.seller, buyer: c.actor, cardHash: l.cardHash, sequence: l.sequence, priceCents: price!, feeBps: l.feeBps, createdAt: now, payBy, deliverBy: payBy + DELIVERY_WINDOW, status: 'awaiting-payment', ...mockQuote(price!, l.feeBps, this.config.usdCentsPerXmr), payout: 'not-started', deliveryConfirmations: 0, requiredConfirmations: this.config.confirmations }; l.status = 'reserved'; this.saveListing(l); this.saveOrder(o); return o; } if (a.type === 'mock-pay' || a.type === 'refund') { const o = this.order(a.orderId); if (o.buyer !== c.actor) throw new Error('Only the order buyer can perform this action'); if (a.type === 'mock-pay') { if (o.status !== 'awaiting-payment' || now >= o.payBy) throw new Error('Invoice is not payable'); if (!this.card(s, o.cardHash, o.seller, o.sequence) || s.pendingTransfers.some(tx => transactionCardIds(tx).includes(o.cardHash))) throw new Error('Seller card changed; do not pay'); o.status = 'paid'; o.deliverBy = now + DELIVERY_WINDOW; } else { if (o.status !== 'refund-required' || s.pendingTransfers.some(tx => transactionCardIds(tx).includes(o.cardHash)) || s.blocks.some(b => b.transactions.some(tx => transactionId(tx) === this.deliveryId(o)))) throw new Error('Refund requires failed delivery and no pending/canonical sale transfer'); o.status = 'refunded'; const l = this.listing(o.listingId); l.status = 'cancelled'; this.saveListing(l); } this.saveOrder(o); return o; } throw new Error('Unsupported market action'); } }