Improvements
This commit is contained in:
+29
-11
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user