Planning
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { ADDITIONAL_MARKET_FEE } from '../../shared/market.ts';
|
||||
import { formatXmr, XMR_ATOMIC_UNITS } from '../../shared/xmr-amount.ts';
|
||||
import { formatXmr, parseAtomicXmr, XMR_ATOMIC_UNITS } from '../../shared/xmr-amount.ts';
|
||||
import { allocateXmrSale } from '../../shared/xmr-pricing.ts';
|
||||
|
||||
/** No live payment adapter is reachable in this release. Never return a payable mock address. */
|
||||
@@ -21,3 +21,14 @@ export function mockQuote(priceCents: number, feeBps: number, usdCentsPerXmr: nu
|
||||
feeXmr: xmrString(fee), sellerNetXmr: xmrString(sellerBeforeNetworkCosts),
|
||||
additionalFee: { ...ADDITIONAL_MARKET_FEE, amountXmr: xmrString(additionalFee), payout: 'not-started' as const } };
|
||||
}
|
||||
|
||||
/** Native-XMR sandbox quote: exact listed gross, with no exchange-rate conversion. */
|
||||
export function mockXmrQuote(priceAtomicXmr: string, feeBps: number) {
|
||||
const gross = parseAtomicXmr(priceAtomicXmr);
|
||||
if (gross === 0n) throw new Error('Sale price must be positive');
|
||||
const { hosting: fee, author: additionalFee, sellerBeforeNetworkCosts } = allocateXmrSale(gross, feeBps);
|
||||
return { invoice: { provider: 'mock' as const, amountXmr: formatXmr(gross), address: null,
|
||||
label: 'SIMULATION ONLY — no Monero payment address. Never send real XMR.' },
|
||||
feeXmr: formatXmr(fee), sellerNetXmr: formatXmr(sellerBeforeNetworkCosts),
|
||||
additionalFee: { ...ADDITIONAL_MARKET_FEE, amountXmr: formatXmr(additionalFee), payout: 'not-started' as const } };
|
||||
}
|
||||
|
||||
+116
-65
@@ -5,12 +5,15 @@ 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';
|
||||
type MarketOrder, type MarketInfo } from '../../shared/market.ts';
|
||||
import { isXmrListing, isXmrOrder, marketCommandV3Schema, marketMessageV3,
|
||||
type MarketActivityV3, type MarketCommandV3, type MarketListingRecord, type MarketListingV3,
|
||||
type MarketOrderRecord, type MarketOrderV3 } from '../../shared/market-v3.ts';
|
||||
import { CHAIN_ID, transactionId, transferSchema, verifySignature } from '../testnet/consensus.ts';
|
||||
import { mockQuote, paymentMode } from './payments.ts';
|
||||
import { mockQuote, mockXmrQuote, paymentMode } from './payments.ts';
|
||||
|
||||
type Chain = { snapshot(): TestnetSnapshot; summary?(): { tip: string; revision: number }; submitTransfer(raw: unknown): Promise<string> };
|
||||
type Config = { feeBps: number; confirmations: number; usdCentsPerXmr: number };
|
||||
type Config = { feeBps: number; confirmations: number };
|
||||
export function marketConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
||||
paymentMode(env);
|
||||
const integer = (name: string, fallback: number, min: number, max: number) => {
|
||||
@@ -21,8 +24,7 @@ export function marketConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
||||
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) };
|
||||
confirmations: integer('WIKIDECK_MARKET_CONFIRMATIONS', 10, 2, 1000) };
|
||||
}
|
||||
const PAYMENT_WINDOW = 30 * 60_000;
|
||||
const DELIVERY_WINDOW = 48 * 60 * 60_000;
|
||||
@@ -78,29 +80,29 @@ export class Market {
|
||||
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,
|
||||
return { network: NETWORK, chainId: CHAIN_ID, marketId: this.id, paymentMode: 'mock', marketVersion: 3, feeBps: this.config.feeBps,
|
||||
additionalFee: { ...ADDITIONAL_MARKET_FEE },
|
||||
deliveryConfirmations: this.config.confirmations, mockUsdPerXmr: (this.config.usdCentsPerXmr / 100).toFixed(2), serverTime: this.now() };
|
||||
deliveryConfirmations: this.config.confirmations, serverTime: this.now() };
|
||||
}
|
||||
private rows<T>(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 {
|
||||
private listing(id: string): MarketListingRecord {
|
||||
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);
|
||||
return JSON.parse(row.data) as MarketListingRecord;
|
||||
}
|
||||
private order(id: string): MarketOrder {
|
||||
private order(id: string): MarketOrderRecord {
|
||||
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);
|
||||
return JSON.parse(row.data) as MarketOrderRecord;
|
||||
}
|
||||
private saveListing(l: MarketListing) {
|
||||
private saveListing(l: MarketListingRecord) {
|
||||
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) {
|
||||
private saveOrder(o: MarketOrderRecord) {
|
||||
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 {
|
||||
@@ -114,7 +116,7 @@ export class Market {
|
||||
snapshot.transfers.filter(tx => transactionCardIds(tx).includes(hash)).length === sequence &&
|
||||
snapshot.blocks.length + 1 - block.height >= CONSENSUS.coinbaseMaturity;
|
||||
}
|
||||
private deliveryId(order: MarketOrder): string {
|
||||
private deliveryId(order: MarketOrderRecord): 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,
|
||||
@@ -127,7 +129,7 @@ export class Market {
|
||||
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')) {
|
||||
for (const o of this.rows<MarketOrderRecord>('orders')) {
|
||||
const l = this.listing(o.listingId);
|
||||
if (o.status === 'refunded' || o.status === 'expired') continue;
|
||||
const deliveryId = this.deliveryId(o);
|
||||
@@ -162,7 +164,7 @@ export class Market {
|
||||
}
|
||||
this.saveOrder(o); this.saveListing(l);
|
||||
}
|
||||
for (const l of this.rows<MarketListing>('listings')) {
|
||||
for (const l of this.rows<MarketListingRecord>('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';
|
||||
@@ -175,7 +177,7 @@ export class Market {
|
||||
const summary = this.chain.summary?.();
|
||||
if (summary) this.reconciled = { ...summary, at: now };
|
||||
}
|
||||
browse(offset = 0): { listings: MarketListing[]; next: number | null; info: MarketInfo } {
|
||||
browse(offset = 0): { listings: MarketListingRecord[]; 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?.();
|
||||
@@ -185,8 +187,9 @@ export class Market {
|
||||
.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);
|
||||
execute(raw: unknown): Promise<MarketActivityV3 | MarketListingRecord | MarketOrderRecord> {
|
||||
const current = marketCommandV3Schema.safeParse(raw);
|
||||
const cmd = current.success ? current.data : marketCommandSchema.parse(raw);
|
||||
if (this.queued >= 64) return Promise.reject(new Error('Marketplace busy; retry shortly'));
|
||||
this.queued++;
|
||||
const result = this.queue.then(async () => {
|
||||
@@ -196,11 +199,13 @@ export class Market {
|
||||
this.queue = result.catch(() => undefined);
|
||||
return result;
|
||||
}
|
||||
private async run(c: MarketCommand): Promise<MarketActivity | MarketListing | MarketOrder> {
|
||||
private async run(c: MarketCommand | MarketCommandV3): Promise<MarketActivityV3 | MarketListingRecord | MarketOrderRecord> {
|
||||
const now = this.now();
|
||||
const isV3 = 'version' in c;
|
||||
const message = isV3 ? marketMessageV3(c) : marketMessage(c);
|
||||
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);
|
||||
!verifySignature(c.publicKey, c.actor, message, c.signature)) throw new Error('Invalid or expired marketplace signature');
|
||||
const key = `${c.actor}:${c.nonce}`, digest = message;
|
||||
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');
|
||||
@@ -212,12 +217,12 @@ export class Market {
|
||||
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)),
|
||||
.all(c.actor,c.actor) as { data: string }[]).map(r=>JSON.parse(r.data) as MarketOrderRecord),
|
||||
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)) };
|
||||
.all(c.actor) as { data: string }[]).map(r=>JSON.parse(r.data) as MarketListingRecord) };
|
||||
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 (isV3 && !isXmrOrder(o) || !isV3 && isXmrOrder(o) || o.seller !== c.actor || !['paid', 'delivery-pending'].includes(o.status)) throw new Error('Only the paid order seller can deliver under the order terms');
|
||||
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.
|
||||
@@ -226,7 +231,7 @@ export class Market {
|
||||
return this.receipt(key, digest, c.expiresAt, o);
|
||||
}
|
||||
return this.atomic(() => {
|
||||
const result = this.mutate(c);
|
||||
const result = isV3 ? this.mutateV3(c) : this.mutateV2(c);
|
||||
return this.receipt(key, digest, c.expiresAt, result);
|
||||
});
|
||||
}
|
||||
@@ -234,51 +239,20 @@ export class Market {
|
||||
this.db.prepare('INSERT INTO receipts VALUES(?,?,?,?)').run(key, digest, expires, JSON.stringify(value));
|
||||
return value;
|
||||
}
|
||||
private mutate(c: MarketCommand): MarketListing | MarketOrder {
|
||||
private mutateV2(c: MarketCommand): MarketActivityV3 | MarketListingRecord | MarketOrderRecord {
|
||||
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<MarketListing>('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;
|
||||
throw new Error('USD listing terms are retired; create a new native-XMR listing');
|
||||
}
|
||||
if (a.type === 'bid' || a.type === 'buy' || a.type === 'cancel') {
|
||||
if (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 (isXmrListing(l) || l.status !== 'open') throw new Error('USD legacy listing cannot be changed under this command');
|
||||
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 (a.type === 'mock-pay' || a.type === 'refund') {
|
||||
const o = this.order(a.orderId);
|
||||
if (isXmrOrder(o)) throw new Error('Use native-XMR signed terms for this order');
|
||||
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');
|
||||
@@ -291,6 +265,83 @@ export class Market {
|
||||
}
|
||||
this.saveOrder(o); return o;
|
||||
}
|
||||
throw new Error('Unsupported market action');
|
||||
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) as MarketOrderRecord),
|
||||
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) as MarketListingRecord) };
|
||||
if (a.type === 'deliver') throw new Error('Legacy delivery is handled by the common signed-order delivery path');
|
||||
throw new Error('Legacy USD bids and checkout are retired; only already-created orders may proceed');
|
||||
}
|
||||
|
||||
private mutateV3(c: MarketCommandV3): MarketActivityV3 | MarketListingRecord | MarketOrderRecord {
|
||||
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<MarketListingRecord>('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: MarketListingV3 = { id: randomUUID(), termsVersion: 3, cardHash: a.cardHash, sequence: a.sequence,
|
||||
seller: c.actor, title: mint.article.title, image: mint.article.image!, kind: a.kind,
|
||||
priceAtomicXmr: a.priceAtomicXmr, feeBps: a.feeBps, additionalFee: { ...ADDITIONAL_MARKET_FEE },
|
||||
createdAt: now, endsAt: a.endsAt, status: 'open', highestBid: null };
|
||||
this.saveListing(l); return l;
|
||||
}
|
||||
if (a.type === 'retire-legacy-listing') {
|
||||
const listing = this.listing(a.listingId);
|
||||
if (isXmrListing(listing)) throw new Error('Native-XMR listings use the signed cancel action');
|
||||
if (listing.seller !== c.actor || !['open','invalid'].includes(listing.status)) throw new Error('Only the seller can retire an unreserved legacy listing');
|
||||
if (listing.highestBid) throw new Error('Legacy auction has bids; resolve or wait for its original terms before retirement');
|
||||
if (this.db.prepare("SELECT 1 FROM orders WHERE json_extract(data,'$.listingId')=? AND json_extract(data,'$.status') IN ('awaiting-payment','paid','delivery-pending','refund-required','review') LIMIT 1").get(listing.id)) {
|
||||
throw new Error('Legacy listing has an in-flight order; preserve that order under its original terms');
|
||||
}
|
||||
listing.status = 'cancelled'; this.saveListing(listing); return listing;
|
||||
}
|
||||
if (a.type === 'bid' || a.type === 'buy' || a.type === 'cancel') {
|
||||
const l = this.listing(a.listingId);
|
||||
if (!isXmrListing(l)) throw new Error('Legacy USD listings are read-only; seller must retire and relist with fresh XMR authorization');
|
||||
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 ? BigInt(l.highestBid.priceAtomicXmr) + 1n : BigInt(l.priceAtomicXmr);
|
||||
if (BigInt(a.priceAtomicXmr) < minimum) throw new Error('Bid must meet the start price and exceed the current bid by one atomic unit');
|
||||
l.highestBid = { bidder: c.actor, priceAtomicXmr: a.priceAtomicXmr }; this.saveListing(l); return l;
|
||||
}
|
||||
const price = l.kind === 'fixed' ? l.priceAtomicXmr : l.highestBid?.priceAtomicXmr;
|
||||
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.priceAtomicXmr || 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 order: MarketOrderV3 = { id: randomUUID(), termsVersion: 3, listingId: l.id, seller: l.seller, buyer: c.actor,
|
||||
cardHash: l.cardHash, sequence: l.sequence, priceAtomicXmr: price!, feeBps: l.feeBps, createdAt: now, payBy,
|
||||
deliverBy: payBy + DELIVERY_WINDOW, status: 'awaiting-payment', ...mockXmrQuote(price!, l.feeBps),
|
||||
payout: 'not-started', deliveryConfirmations: 0, requiredConfirmations: this.config.confirmations };
|
||||
l.status = 'reserved'; this.saveListing(l); this.saveOrder(order); return order;
|
||||
}
|
||||
if (a.type === 'mock-pay' || a.type === 'refund') {
|
||||
const order = this.order(a.orderId);
|
||||
if (!isXmrOrder(order)) throw new Error('Legacy USD orders keep their original terms and cannot be changed through native-XMR actions');
|
||||
if (order.buyer !== c.actor) throw new Error('Only the order buyer can perform this action');
|
||||
if (a.type === 'mock-pay') {
|
||||
if (order.status !== 'awaiting-payment' || now >= order.payBy) throw new Error('Invoice is not payable');
|
||||
if (!this.card(s, order.cardHash, order.seller, order.sequence) || s.pendingTransfers.some(tx => transactionCardIds(tx).includes(order.cardHash))) throw new Error('Seller card changed; do not pay');
|
||||
order.status = 'paid'; order.deliverBy = now + DELIVERY_WINDOW;
|
||||
} else {
|
||||
if (order.status !== 'refund-required' || s.pendingTransfers.some(tx => transactionCardIds(tx).includes(order.cardHash)) ||
|
||||
s.blocks.some(b => b.transactions.some(tx => transactionId(tx) === this.deliveryId(order)))) throw new Error('Refund requires failed delivery and no pending/canonical sale transfer');
|
||||
order.status = 'refunded'; const listing = this.listing(order.listingId); listing.status = 'cancelled'; this.saveListing(listing);
|
||||
}
|
||||
this.saveOrder(order); return order;
|
||||
}
|
||||
throw new Error('Unsupported native-XMR market action');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { snapshotStats } from '../../shared/enrichment.ts';
|
||||
import { wikipediaArticles } from '../wiki.ts';
|
||||
import { Market, marketConfig } from '../market/store.ts';
|
||||
import { marketCommandSchema } from '../../shared/market.ts';
|
||||
import { marketCommandV3Schema } from '../../shared/market-v3.ts';
|
||||
import { IS_MAINNET, PROFILE } from '../../shared/network.ts';
|
||||
import { assertLaunchAllowed, launchManifest } from '../launch.ts';
|
||||
import { resourceConfig, RpcBudget, RpcError } from '../resources.ts';
|
||||
@@ -83,7 +84,8 @@ app.get('/api/chain',(_req,res)=>res.json(ledger.legacySnapshot(limits.legacyBlo
|
||||
app.get('/api/peers',(_req,res)=>res.json({peers:peers.status()}));
|
||||
app.get('/api/market', (req,res)=>res.json(market.browse(Number(req.query.offset ?? 0))));
|
||||
app.post('/api/market/command',rateLimit({windowMs:60000,limit:60,standardHeaders:'draft-8',legacyHeaders:false}),async(req,res)=>{
|
||||
const command = marketCommandSchema.parse(req.body);
|
||||
const current = marketCommandV3Schema.safeParse(req.body);
|
||||
const command = current.success ? current.data : marketCommandSchema.parse(req.body);
|
||||
try {
|
||||
const result = await market.execute(command);
|
||||
res.json(result);
|
||||
|
||||
Reference in New Issue
Block a user