Improvements

This commit is contained in:
2026-10-20 04:07:37 -04:00
parent 0cc5032857
commit 3cee89d023
20 changed files with 511 additions and 23 deletions
+29 -11
View File
@@ -9,7 +9,7 @@ import { ADDITIONAL_MARKET_FEE, marketCommandSchema, marketMessage, type MarketC
import { CHAIN_ID, transactionId, transferSchema, verifySignature } from '../testnet/consensus.ts';
import { mockQuote, paymentMode } from './payments.ts';
type Chain = { snapshot(): TestnetSnapshot; submitTransfer(raw: unknown): Promise<string> };
type Chain = { snapshot(): TestnetSnapshot; summary?(): { tip: string; revision: number }; submitTransfer(raw: unknown): Promise<string> };
type Config = { feeBps: number; confirmations: number; usdCentsPerXmr: number };
export function marketConfig(env: NodeJS.ProcessEnv = process.env): Config {
paymentMode(env);
@@ -31,6 +31,7 @@ const DELIVERY_WINDOW = 48 * 60 * 60_000;
export class Market {
private queue: Promise<unknown> = 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;
@@ -52,6 +53,10 @@ export class Market {
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) {
@@ -88,11 +93,11 @@ export class Market {
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')
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').run(o.id, JSON.stringify(o));
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<T>(fn: () => T): T {
this.db.exec('BEGIN IMMEDIATE');
@@ -114,12 +119,15 @@ export class Market {
/** Reconcile from canonical transaction identity, never from a mempool acknowledgement. */
private reconcile() {
const s = this.chain.snapshot(), now = this.now();
const deliveries = new Map<string, TestnetSnapshot['blocks'][number]>();
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<MarketOrder>('orders')) {
const l = this.listing(o.listingId);
if (o.status === 'refunded' || o.status === 'expired') continue;
const deliveryId = this.deliveryId(o);
const delivery = s.blocks.find(b => b.transactions.some(tx => transactionId(tx) === deliveryId));
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') {
@@ -143,7 +151,7 @@ export class Market {
} 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 = s.pendingTransfers.some(tx => transactionId(tx) === deliveryId);
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';
@@ -160,13 +168,18 @@ export class Market {
}
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.
if (!this.queued) this.reconcile();
const all = this.rows<MarketListing>('listings').filter(l => l.status === 'open' || l.status === 'reserved');
return { listings: all.slice(offset, offset + 24), next: offset + 24 < all.length ? offset + 24 : null, info: this.info() };
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<MarketActivity | MarketListing | MarketOrder> {
const cmd = marketCommandSchema.parse(raw);
@@ -189,10 +202,15 @@ export class Market {
if (old.digest !== digest) throw new Error('Nonce already used for different terms');
return JSON.parse(old.data);
}
this.reconcile();
const a = c.action;
if (a.type === 'activity') return { orders: this.rows<MarketOrder>('orders').filter(o => o.seller === c.actor || o.buyer === c.actor),
listings: this.rows<MarketListing>('listings').filter(l => l.seller === c.actor) };
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');