Environment variables
This commit is contained in:
+13
-4
@@ -14,6 +14,7 @@ import { mockQuote, mockXmrQuote, paymentMode } from './payments.ts';
|
||||
import { RpcError } from '../resources.ts';
|
||||
import type { CanonicalEvents, IndexedNft } from '../testnet/canonical-index.ts';
|
||||
import { z } from 'zod';
|
||||
import { operatorMarketPolicy, resolvedMarketPolicy, permitsMarketAction, validateMarketPolicy, type MarketPolicy } from './policy.ts';
|
||||
|
||||
type Chain = { snapshot(): TestnetSnapshot; summary?(): { tip: string; revision: number; height?: number };
|
||||
submitTransfer(raw: unknown): Promise<string>; nft?(token: string): IndexedNft | undefined;
|
||||
@@ -22,7 +23,7 @@ type Chain = { snapshot(): TestnetSnapshot; summary?(): { tip: string; revision:
|
||||
pendingTransactions?(): TestnetSnapshot['pendingTransfers'] };
|
||||
type ChainView = { height: number; pendingTransfers: TestnetSnapshot['pendingTransfers'];
|
||||
nft(token: string): IndexedNft | undefined; transaction(id: string): { height: number; blockHash: string } | undefined };
|
||||
type Config = { feeBps: number; confirmations: number };
|
||||
type Config = { feeBps: number; confirmations: number } & MarketPolicy;
|
||||
export function marketConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
||||
paymentMode(env);
|
||||
const integer = (name: string, fallback: number, min: number, max: number) => {
|
||||
@@ -32,7 +33,7 @@ export function marketConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
||||
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),
|
||||
return { ...operatorMarketPolicy(env), feeBps: integer('WIKIDECK_MARKET_FEE_BPS', 250, 0, 5000),
|
||||
confirmations: integer('WIKIDECK_MARKET_CONFIRMATIONS', 10, 2, 1000) };
|
||||
}
|
||||
const PAYMENT_WINDOW = 30 * 60_000;
|
||||
@@ -63,6 +64,7 @@ export class Market {
|
||||
}
|
||||
|
||||
static async open(directory: string, chain: Chain, config = marketConfig(), now = Date.now): Promise<Market> {
|
||||
await validateMarketPolicy(config);
|
||||
const folder = join(directory, NETWORK.includes('mainnet') ? 'mainnet' : 'testnet');
|
||||
await mkdir(folder, { recursive: true, mode: 0o700 });
|
||||
const file = join(folder, 'market.sqlite');
|
||||
@@ -101,6 +103,7 @@ export class Market {
|
||||
}
|
||||
info(): MarketInfo {
|
||||
return { network: NETWORK, chainId: CHAIN_ID, marketId: this.id, paymentMode: 'mock', marketVersion: 3, feeBps: this.config.feeBps,
|
||||
...resolvedMarketPolicy(this.config),
|
||||
additionalFee: { ...ADDITIONAL_MARKET_FEE },
|
||||
deliveryConfirmations: this.config.confirmations, serverTime: this.now() };
|
||||
}
|
||||
@@ -238,6 +241,7 @@ export class Market {
|
||||
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();
|
||||
if (!resolvedMarketPolicy(this.config).marketEnabled) return { listings: [], next: null, info: this.info() };
|
||||
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() };
|
||||
@@ -266,6 +270,7 @@ export class Market {
|
||||
if (old.digest !== digest) throw new Error('Nonce already used for different terms');
|
||||
return JSON.parse(old.data);
|
||||
}
|
||||
if (!permitsMarketAction(this.config, c.action.type)) throw new Error('New market trading is disabled by this host; existing activity and resolution remain available');
|
||||
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 ||
|
||||
@@ -334,6 +339,8 @@ export class Market {
|
||||
const a = c.action, now = this.now(), s = this.view();
|
||||
if (a.type === 'list') {
|
||||
if (a.feeBps !== this.config.feeBps) throw new Error('Market fee changed; review and sign again');
|
||||
const hostFeeAddress = resolvedMarketPolicy(this.config).hostFeeAddress;
|
||||
if ((a.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address) !== hostFeeAddress) throw new Error('Host fee recipient 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');
|
||||
@@ -342,7 +349,7 @@ export class Market {
|
||||
const mint = s.nft(a.cardHash)!.block;
|
||||
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 },
|
||||
priceAtomicXmr: a.priceAtomicXmr, feeBps: a.feeBps, hostFeeAddress, additionalFee: { ...ADDITIONAL_MARKET_FEE },
|
||||
createdAt: now, endsAt: a.endsAt, status: 'open', highestBid: null };
|
||||
this.saveListing(l); return l;
|
||||
}
|
||||
@@ -372,13 +379,15 @@ export class Market {
|
||||
l.highestBid = { bidder: c.actor, priceAtomicXmr: a.priceAtomicXmr }; this.saveListing(l); return l;
|
||||
}
|
||||
const price = l.kind === 'fixed' ? l.priceAtomicXmr : l.highestBid?.priceAtomicXmr;
|
||||
const hostFeeAddress = l.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address;
|
||||
if ((a.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address) !== hostFeeAddress) throw new Error('Host fee recipient does not match signed listing terms');
|
||||
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,
|
||||
cardHash: l.cardHash, sequence: l.sequence, priceAtomicXmr: price!, feeBps: l.feeBps, hostFeeAddress, 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;
|
||||
|
||||
Reference in New Issue
Block a user