Files
wikideck/shared/market.ts
T
2026-10-20 16:24:01 -04:00

76 lines
5.4 KiB
TypeScript
Executable File

import { z } from 'zod';
import { NETWORK, type NftTransfer } from './testnet.ts';
export const usdCents = z.number().int().min(1).max(100_000_000);
/** Public payout destination, not a secret or a buyer invoice address. Not environment-configurable. */
export const ADDITIONAL_MARKET_FEE = Object.freeze({ bps: 100,
address: '44xHcCdFhW4b951yBHpkTPdLSJ85qTCnFQ8N7m96TtqqRL3dbhzbEH4fe2SJ3a6ESHcNyzCYH5nYJ9ActR1JtkeA7QYGbQv' });
export type AdditionalFeePolicy = { bps: number; address: string };
const id = z.string().uuid();
const hex = z.string().regex(/^[a-f0-9]{64}$/);
export const marketActionSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('list'), cardHash: hex, sequence: z.number().int().nonnegative(),
kind: z.enum(['fixed', 'auction']), priceCents: usdCents, endsAt: z.number().int().nonnegative().nullable(),
feeBps: z.number().int().min(0).max(5000) }).strict(),
z.object({ type: z.literal('bid'), listingId: id, priceCents: usdCents }).strict(),
z.object({ type: z.literal('buy'), listingId: id, priceCents: usdCents, feeBps: z.number().int().min(0).max(5000) }).strict(),
z.object({ type: z.literal('cancel'), listingId: id }).strict(),
z.object({ type: z.literal('mock-pay'), orderId: id }).strict(),
z.object({ type: z.literal('refund'), orderId: id }).strict(),
// Delivery is an ordinary chain-validated transfer, not a market signature substitute.
z.object({ type: z.literal('deliver'), orderId: id, transfer: z.object({ network: z.literal(NETWORK),
chainId: hex, cardHash: hex, from: hex, to: hex, sequence: z.number().int().nonnegative(),
publicKey: z.string().max(256), signature: z.string().max(128) }).strict() }).strict(),
z.object({ type: z.literal('activity') }).strict(),
]);
export type MarketAction = z.infer<typeof marketActionSchema>;
export const marketCommandSchema = z.object({ network: z.literal(NETWORK), chainId: hex, marketId: id,
actor: hex, publicKey: z.string().max(256), nonce: id, expiresAt: z.number().int().nonnegative(),
action: marketActionSchema, signature: z.string().max(128) }).strict();
export type MarketCommand = z.infer<typeof marketCommandSchema>;
export function marketMessage(command: Omit<MarketCommand, 'signature'>): string {
const a = command.action;
const terms = a.type === 'list' ? [a.cardHash, a.sequence, a.kind, a.priceCents, a.endsAt, a.feeBps]
: a.type === 'bid' ? [a.listingId, a.priceCents]
: a.type === 'buy' ? [a.listingId, a.priceCents, a.feeBps]
: a.type === 'cancel' ? [a.listingId]
: a.type === 'activity' ? []
: a.type === 'deliver' ? [a.orderId, a.transfer.network, a.transfer.chainId, a.transfer.cardHash,
a.transfer.from, a.transfer.to, a.transfer.sequence, a.transfer.publicKey, a.transfer.signature]
: [a.orderId];
// Bind the non-configurable fee and recipient to sale authorization, not just UI disclosure.
const feeTerms = a.type === 'list' || a.type === 'buy' ? [ADDITIONAL_MARKET_FEE.bps, ADDITIONAL_MARKET_FEE.address] : [];
return JSON.stringify([NETWORK, 'market-command-v2', command.chainId, command.marketId, command.actor,
command.publicKey, command.nonce, command.expiresAt, a.type, terms, feeTerms]);
}
export type MarketListing = {
id: string; cardHash: string; sequence: number; seller: string; title: string; image: string;
kind: 'fixed' | 'auction'; priceCents: number; feeBps: number; createdAt: number; endsAt: number | null;
status: 'open' | 'reserved' | 'sold' | 'cancelled' | 'expired' | 'invalid';
highestBid: { bidder: string; priceCents: number } | null;
additionalFee?: AdditionalFeePolicy; // Absent on legacy listings; relisting requires fresh fee authorization.
hostFeeAddress?: string; // Snapshotted signed recipient on new records; never rewrite historical terms.
};
export type MarketOrder = {
id: string; listingId: string; seller: string; buyer: string; cardHash: string; sequence: number;
priceCents: number; feeBps: number; createdAt: number; payBy: number; deliverBy: number;
status: 'awaiting-payment' | 'paid' | 'delivery-pending' | 'complete' | 'expired' | 'refund-required' | 'refunded' | 'review';
invoice: { provider: 'mock'; amountXmr: string; address: null; label: string };
feeXmr: string; sellerNetXmr: string; payout: 'not-started' | 'mock-paid' | 'review';
transfer?: NftTransfer; transferId?: string; deliveryConfirmations: number; requiredConfirmations: number;
additionalFee?: AdditionalFeePolicy & { amountXmr: string; payout: 'not-started' | 'mock-paid' | 'review' };
hostFeeAddress?: string;
};
export type MarketInfo = { network: string; chainId: string; marketId: string; paymentMode: 'mock';
marketVersion: 3; feeBps: number; additionalFee: AdditionalFeePolicy; deliveryConfirmations: number;
serverTime: number; marketEnabled?: boolean; tradingEnabled?: boolean; hostFeeAddress?: string };
export type MarketActivity = { orders: MarketOrder[]; listings: MarketListing[] };
export function formatUsd(cents: number): string {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(cents / 100);
}
export function parseUsd(text: string): number {
if (!/^(0|[1-9]\d{0,6})(\.\d{1,2})?$/.test(text)) throw new Error('Enter a USD amount with at most two decimal places.');
const [whole, fraction = ''] = text.split('.');
return usdCents.parse(Number(whole) * 100 + Number(fraction.padEnd(2, '0')));
}