Environment variables
This commit is contained in:
@@ -11,6 +11,7 @@ import { CUSTODY_POLICY, MOCK_CREDIT_ATOMIC, custodyCommandSchema, custodyMessag
|
||||
type CustodyCommand, type CustodyActivity, type CustodyInfo, type CustodyListing,
|
||||
type CustodyOrder, type CustodyWithdrawal, type CustodyPage } from '../../shared/custody.ts';
|
||||
import { CHAIN_ID, verifySignature } from '../testnet/consensus.ts';
|
||||
import { resolvedMarketPolicy, validateMarketPolicy, permitsMarketAction, type MarketPolicy } from './policy.ts';
|
||||
|
||||
export function custodyMode(env: NodeJS.ProcessEnv = process.env): 'off' | 'sandbox' {
|
||||
const mode = env.WIKIDECK_CUSTODY_MODE ?? 'off';
|
||||
@@ -20,7 +21,7 @@ export function custodyMode(env: NodeJS.ProcessEnv = process.env): 'off' | 'sand
|
||||
}
|
||||
export class CustodyError extends Error {}
|
||||
type Card = { title: string };
|
||||
type Options = { feeBps: number; card(cardHash: string, owner: string, sequence: number): Card | undefined; now?: () => number };
|
||||
type Options = { feeBps: number; card(cardHash: string, owner: string, sequence: number): Card | undefined; now?: () => number } & MarketPolicy;
|
||||
type Account = { id: string; normal: 'debit' | 'credit'; balance: string };
|
||||
type Line = { account: string; normal: 'debit' | 'credit'; debit?: bigint; credit?: bigint };
|
||||
type Result = CustodyActivity | CustodyListing | CustodyOrder | CustodyWithdrawal;
|
||||
@@ -47,6 +48,7 @@ export class CustodyMarket {
|
||||
}
|
||||
static async open(directory: string, options: Options): Promise<CustodyMarket> {
|
||||
if (IS_MAINNET) throw new Error('Custody sandbox cannot run on mainnet');
|
||||
await validateMarketPolicy(options);
|
||||
if (!Number.isSafeInteger(options.feeBps) || options.feeBps < 0 || options.feeBps > 5000) throw new Error('Invalid custody fee');
|
||||
const folder = join(directory, 'testnet');
|
||||
await mkdir(folder, { recursive: true, mode: 0o700 });
|
||||
@@ -102,7 +104,7 @@ export class CustodyMarket {
|
||||
close() { this.db.close(); }
|
||||
info(): CustodyInfo {
|
||||
return { enabled: true, mode: 'sandbox', policy: CUSTODY_POLICY, network: NETWORK, chainId: CHAIN_ID,
|
||||
custodianId: this.id, feeBps: this.options.feeBps, liveDeposits: false, liveWithdrawals: false, nftSettlement: false };
|
||||
custodianId: this.id, feeBps: this.options.feeBps, ...resolvedMarketPolicy(this.options), liveDeposits: false, liveWithdrawals: false, nftSettlement: false };
|
||||
}
|
||||
private atomic<T>(fn: () => T): T {
|
||||
this.db.exec('BEGIN IMMEDIATE');
|
||||
@@ -154,6 +156,7 @@ export class CustodyMarket {
|
||||
}
|
||||
browse(offset = 0): CustodyPage {
|
||||
requireState(Number.isSafeInteger(offset) && offset >= 0 && offset <= LIMIT, 'Invalid custody page');
|
||||
if (!resolvedMarketPolicy(this.options).marketEnabled) return { info: this.info(), listings: [], next: null };
|
||||
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 { info: this.info(), listings: rows.slice(0, 24).map(r => JSON.parse(r.data)), next: rows.length > 24 ? offset + 24 : null };
|
||||
@@ -181,6 +184,7 @@ export class CustodyMarket {
|
||||
const key = `${c.actor}:${c.nonce}`, hash = digest(message);
|
||||
const prior = this.db.prepare('SELECT digest,data FROM receipts WHERE id=?').get(key) as { digest: string; data: string } | undefined;
|
||||
if (prior) { requireState(prior.digest === hash, 'Nonce already used for different custody terms'); return JSON.parse(prior.data) as Result; }
|
||||
requireState(permitsMarketAction(this.options, c.action.type), 'New market trading is disabled by this host; existing balances and resolution remain available');
|
||||
if (!['cancel-listing', 'cancel-reservation', 'cancel-withdrawal'].includes(c.action.type)) {
|
||||
requireState((this.db.prepare('SELECT COUNT(*) AS n FROM receipts').get() as { n: number }).n < LIMIT,
|
||||
'Custody sandbox command budget reached; existing reservations can still be cancelled');
|
||||
@@ -200,11 +204,13 @@ export class CustodyMarket {
|
||||
}
|
||||
if (a.type === 'list') {
|
||||
requireState(a.feeBps === this.options.feeBps, 'Custody hosting fee changed; review again');
|
||||
const hostFeeAddress = resolvedMarketPolicy(this.options).hostFeeAddress;
|
||||
requireState((a.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address) === hostFeeAddress, 'Host fee recipient changed; review and sign again');
|
||||
const card = this.options.card(a.cardHash, c.actor, a.sequence);
|
||||
requireState(card, 'Card is missing, stale, immature, or pending');
|
||||
requireState(!this.db.prepare("SELECT 1 FROM listings WHERE card=? AND status IN ('open','reserved')").get(a.cardHash), 'Card already has a custody listing');
|
||||
const l: CustodyListing = { id: randomUUID(), seller: c.actor, cardHash: a.cardHash, sequence: a.sequence,
|
||||
title: card.title, priceAtomicXmr: a.priceAtomicXmr, feeBps: a.feeBps, status: 'open', createdAt: this.now() };
|
||||
title: card.title, priceAtomicXmr: a.priceAtomicXmr, feeBps: a.feeBps, hostFeeAddress, status: 'open', createdAt: this.now() };
|
||||
this.saveListing(l); return l;
|
||||
}
|
||||
if (a.type === 'cancel-listing' || a.type === 'reserve') {
|
||||
@@ -216,12 +222,14 @@ export class CustodyMarket {
|
||||
}
|
||||
requireState(c.actor !== l.seller, 'Cannot buy your own card');
|
||||
requireState(l.priceAtomicXmr === a.priceAtomicXmr && l.feeBps === a.feeBps, 'Custody sale terms changed');
|
||||
const hostFeeAddress = l.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address;
|
||||
requireState((a.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address) === hostFeeAddress, 'Host fee recipient does not match listing terms');
|
||||
requireState(this.options.card(l.cardHash, l.seller, l.sequence), 'Seller card is no longer available');
|
||||
const allocation = allocateXmrSale(parseAtomicXmr(a.priceAtomicXmr), a.feeBps);
|
||||
const order: CustodyOrder = { id: randomUUID(), listingId: l.id, seller: l.seller, buyer: c.actor,
|
||||
cardHash: l.cardHash, sequence: l.sequence, grossAtomicXmr: a.priceAtomicXmr,
|
||||
sellerAtomicXmr: allocation.sellerBeforeNetworkCosts.toString(), authorAtomicXmr: allocation.author.toString(),
|
||||
hostAtomicXmr: allocation.hosting.toString(), authorAddress: ADDITIONAL_MARKET_FEE.address,
|
||||
hostAtomicXmr: allocation.hosting.toString(), authorAddress: ADDITIONAL_MARKET_FEE.address, hostFeeAddress,
|
||||
status: 'awaiting-lock', createdAt: this.now() };
|
||||
this.post(`reserve:${order.id}`, 'purchase-reservation', [debit(available(c.actor), allocation.gross), credit(orderAccount(order), allocation.gross)]);
|
||||
l.status = 'reserved'; this.saveListing(l); this.saveOrder(order);
|
||||
@@ -274,7 +282,7 @@ export class CustodyMarket {
|
||||
requireState(o.status === 'awaiting-lock', 'Order is not eligible for fixture settlement');
|
||||
this.post(`settle:${o.id}`, 'mock-sale-settlement', [debit(orderAccount(o), BigInt(o.grossAtomicXmr)),
|
||||
credit(available(o.seller), BigInt(o.sellerAtomicXmr)), credit(`author:${o.authorAddress}`, BigInt(o.authorAtomicXmr)),
|
||||
credit('revenue:host', BigInt(o.hostAtomicXmr))]);
|
||||
credit(o.hostFeeAddress ? `revenue:host:${o.hostFeeAddress}` : 'revenue:host', BigInt(o.hostAtomicXmr))]);
|
||||
o.status = 'settled'; this.saveOrder(o);
|
||||
const l = this.get<CustodyListing>('listings', o.listingId); l.status = 'sold'; this.saveListing(l);
|
||||
this.db.prepare("UPDATE outbox SET status='fixture-complete',data=? WHERE id=?").run(evidenceId, o.id);
|
||||
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
import { ADDITIONAL_MARKET_FEE } from '../../shared/market.ts';
|
||||
import { validateXmrAddress } from './xmr.ts';
|
||||
|
||||
export type MarketPolicy = { marketEnabled?: boolean; tradingEnabled?: boolean; hostFeeAddress?: string };
|
||||
export function operatorMarketPolicy(env: NodeJS.ProcessEnv = process.env): Required<MarketPolicy> {
|
||||
const flag = (name: string, fallback: boolean) => {
|
||||
const value = env[name] ?? (fallback ? '1' : '0');
|
||||
if (value !== '0' && value !== '1') throw new Error(`${name} must be 0 or 1`);
|
||||
return value === '1';
|
||||
};
|
||||
const hostFeeAddress = env.WIKIDECK_MARKET_HOST_FEE_ADDRESS ?? ADDITIONAL_MARKET_FEE.address;
|
||||
if (!/^[1-9A-HJ-NP-Za-km-z]{95}$/.test(hostFeeAddress)) throw new Error('Invalid WIKIDECK_MARKET_HOST_FEE_ADDRESS');
|
||||
return { marketEnabled: flag('WIKIDECK_MARKET_ENABLED', true),
|
||||
tradingEnabled: flag('WIKIDECK_MARKET_TRADING_ENABLED', true), hostFeeAddress };
|
||||
}
|
||||
export function resolvedMarketPolicy(policy: MarketPolicy): Required<MarketPolicy> {
|
||||
return { marketEnabled: policy.marketEnabled ?? true, tradingEnabled: policy.tradingEnabled ?? true,
|
||||
hostFeeAddress: policy.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address };
|
||||
}
|
||||
export async function validateMarketPolicy(policy: MarketPolicy): Promise<void> {
|
||||
await validateXmrAddress(resolvedMarketPolicy(policy).hostFeeAddress);
|
||||
}
|
||||
/** Pausing new business never hides liabilities or blocks safe resolution/withdrawal requests. */
|
||||
export function permitsMarketAction(policy: MarketPolicy, action: string): boolean {
|
||||
return !['list', 'bid', 'buy', 'reserve', 'mock-credit'].includes(action) ||
|
||||
(resolvedMarketPolicy(policy).marketEnabled && resolvedMarketPolicy(policy).tradingEnabled);
|
||||
}
|
||||
@@ -10,8 +10,9 @@ import { CUSTODY_POLICY, MOCK_CREDIT_ATOMIC, custodyCommandSchema, custodyMessag
|
||||
type CustodyOrder, type CustodyPage, type CustodyWithdrawal } from '../../shared/custody.ts';
|
||||
import { CustodyError } from './custody.ts';
|
||||
import { CUSTODY_WRITE_LOCK, verifyCustodySchema } from './postgres-schema.ts';
|
||||
import { resolvedMarketPolicy, validateMarketPolicy, permitsMarketAction, type MarketPolicy } from './policy.ts';
|
||||
|
||||
export type CustodyOptions = { feeBps: number; card(hash: string, owner: string, sequence: number): { title: string } | undefined; now?: () => number };
|
||||
export type CustodyOptions = { feeBps: number; card(hash: string, owner: string, sequence: number): { title: string } | undefined; now?: () => number } & MarketPolicy;
|
||||
type Result = CustodyActivity | CustodyListing | CustodyOrder | CustodyWithdrawal;
|
||||
export type CustodyAudit = { journals: number; assetsAtomicXmr: string; claimsAndRevenueAtomicXmr: string };
|
||||
type Line = { account: string; normal?: 'debit' | 'credit'; debit?: bigint; credit?: bigint };
|
||||
@@ -29,6 +30,7 @@ export class PostgresCustody {
|
||||
private constructor(pool: Pool, options: CustodyOptions, id: string) { this.pool = pool; this.options = options; this.id = id; }
|
||||
static async open(config: PoolConfig, options: CustodyOptions): Promise<PostgresCustody> {
|
||||
if (IS_MAINNET) throw new Error('Custody sandbox cannot run on mainnet');
|
||||
await validateMarketPolicy(options);
|
||||
if (!Number.isSafeInteger(options.feeBps) || options.feeBps < 0 || options.feeBps > 5000) throw new Error('Invalid custody fee');
|
||||
const pool = new Pool(config);
|
||||
// Never log connection details or let idle-client socket errors become uncaught process exceptions.
|
||||
@@ -67,7 +69,7 @@ export class PostgresCustody {
|
||||
close(): Promise<void> { return this.pool.end(); }
|
||||
info(): CustodyInfo {
|
||||
return { enabled: true, mode: 'sandbox', policy: CUSTODY_POLICY, network: NETWORK, chainId: CHAIN_ID,
|
||||
custodianId: this.id, feeBps: this.options.feeBps, liveDeposits: false, liveWithdrawals: false, nftSettlement: false };
|
||||
custodianId: this.id, feeBps: this.options.feeBps, ...resolvedMarketPolicy(this.options), liveDeposits: false, liveWithdrawals: false, nftSettlement: false };
|
||||
}
|
||||
private async transaction<T>(write: boolean, fn: (c: PoolClient) => Promise<T>): Promise<T> {
|
||||
check(this.active < 16, 'Custody database busy; retry the original command shortly');
|
||||
@@ -93,6 +95,7 @@ export class PostgresCustody {
|
||||
}
|
||||
async browse(offset = 0): Promise<CustodyPage> {
|
||||
check(Number.isSafeInteger(offset) && offset >= 0 && offset <= 10000, 'Invalid custody page');
|
||||
if (!resolvedMarketPolicy(this.options).marketEnabled) return { info: this.info(), listings: [], next: null };
|
||||
return this.transaction(false, async c => {
|
||||
const { rows } = await c.query("SELECT data FROM wk_custody.listings WHERE status IN ('open','reserved') ORDER BY sequence DESC LIMIT 25 OFFSET $1", [offset]);
|
||||
return { info: this.info(), listings: rows.slice(0, 24).map(r => r.data), next: rows.length > 24 ? offset + 24 : null };
|
||||
@@ -144,6 +147,7 @@ export class PostgresCustody {
|
||||
const key = `${cmd.actor}:${cmd.nonce}`, hash = createHash('sha256').update(message).digest('hex');
|
||||
const old = await c.query('SELECT digest,data FROM wk_custody.receipts WHERE id=$1', [key]);
|
||||
if (old.rowCount) { check(old.rows[0].digest === hash, 'Nonce already used for different custody terms'); return old.rows[0].data as Result; }
|
||||
check(permitsMarketAction(this.options, a.type), 'New market trading is disabled by this host; existing balances and resolution remain available');
|
||||
// Sandbox admission budget matches SQLite. Receipt/history are NEVER pruned; cancellation stays available.
|
||||
if (!['cancel-listing', 'cancel-reservation', 'cancel-withdrawal'].includes(a.type)) {
|
||||
const count = await c.query('SELECT count(*)::integer AS n FROM (SELECT 1 FROM wk_custody.receipts LIMIT 10000) b');
|
||||
@@ -165,11 +169,13 @@ export class PostgresCustody {
|
||||
}
|
||||
if (a.type === 'list') {
|
||||
check(a.feeBps === this.options.feeBps, 'Custody hosting fee changed; review again');
|
||||
const hostFeeAddress = resolvedMarketPolicy(this.options).hostFeeAddress;
|
||||
check((a.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address) === hostFeeAddress, 'Host fee recipient changed; review and sign again');
|
||||
const card = this.options.card(a.cardHash, actor, a.sequence);
|
||||
check(card, 'Card is missing, stale, immature, or pending');
|
||||
check(!(await c.query("SELECT 1 FROM wk_custody.listings WHERE card=$1 AND status IN ('open','reserved')", [a.cardHash])).rowCount, 'Card already has a custody listing');
|
||||
const l: CustodyListing = { id: randomUUID(), seller: actor, cardHash: a.cardHash, sequence: a.sequence,
|
||||
title: card.title, priceAtomicXmr: a.priceAtomicXmr, feeBps: a.feeBps, status: 'open', createdAt };
|
||||
title: card.title, priceAtomicXmr: a.priceAtomicXmr, feeBps: a.feeBps, hostFeeAddress, status: 'open', createdAt };
|
||||
await c.query('INSERT INTO wk_custody.listings(id,card,status,data) VALUES($1,$2,$3,$4)', [l.id, l.cardHash, l.status, JSON.stringify(l)]); return l;
|
||||
}
|
||||
if (a.type === 'reserve' || a.type === 'cancel-listing') {
|
||||
@@ -180,11 +186,13 @@ export class PostgresCustody {
|
||||
}
|
||||
check(actor !== l.seller, 'Cannot buy your own card');
|
||||
check(a.priceAtomicXmr === l.priceAtomicXmr && a.feeBps === l.feeBps, 'Custody sale terms changed');
|
||||
const hostFeeAddress = l.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address;
|
||||
check((a.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address) === hostFeeAddress, 'Host fee recipient does not match listing terms');
|
||||
check(this.options.card(l.cardHash, l.seller, l.sequence), 'Seller card is no longer available');
|
||||
const allocation = allocateXmrSale(parseAtomicXmr(a.priceAtomicXmr), a.feeBps);
|
||||
const o: CustodyOrder = { id: randomUUID(), listingId: l.id, seller: l.seller, buyer: actor, cardHash: l.cardHash,
|
||||
sequence: l.sequence, grossAtomicXmr: a.priceAtomicXmr, sellerAtomicXmr: allocation.sellerBeforeNetworkCosts.toString(),
|
||||
authorAtomicXmr: allocation.author.toString(), hostAtomicXmr: allocation.hosting.toString(), authorAddress: ADDITIONAL_MARKET_FEE.address,
|
||||
authorAtomicXmr: allocation.author.toString(), hostAtomicXmr: allocation.hosting.toString(), authorAddress: ADDITIONAL_MARKET_FEE.address, hostFeeAddress,
|
||||
status: 'awaiting-lock', createdAt };
|
||||
await this.post(c, `reserve:${o.id}`, 'purchase-reservation', [{ account: available(actor), debit: allocation.gross }, { account: reserved(o), credit: allocation.gross }]);
|
||||
l.status = 'reserved'; await this.update(c, 'listings', l);
|
||||
|
||||
+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;
|
||||
|
||||
@@ -21,6 +21,7 @@ import { custodyCommandSchema } from '../../shared/custody.ts';
|
||||
import { CustodyMarket, CustodyError, custodyMode } from '../market/custody.ts';
|
||||
import { openCustodyStorage } from '../market/custody-storage.ts';
|
||||
import type { PostgresCustody } from '../market/postgres-custody.ts';
|
||||
import { validateMarketPolicy } from '../market/policy.ts';
|
||||
import { IS_MAINNET, PROFILE } from '../../shared/network.ts';
|
||||
import { assertLaunchAllowed, launchManifest } from '../launch.ts';
|
||||
import { resourceConfig, RpcBudget, RpcError } from '../resources.ts';
|
||||
@@ -29,6 +30,7 @@ import { trustedProxies, operationsPort, startOperations } from '../operations.t
|
||||
const root = fileURLToPath(new URL('../../', import.meta.url));
|
||||
assertLaunchAllowed();
|
||||
const paymentConfig = marketConfig(); // Fail before opening node resources if live payments are requested.
|
||||
await validateMarketPolicy(paymentConfig); // Reject invalid recipient checksum/network before opening node resources.
|
||||
const custodyConfig = custodyMode(); // Explicit opt-in sandbox only. Unknown/live modes fail before opening resources.
|
||||
const limits = resourceConfig();
|
||||
const proxyTrust=trustedProxies();
|
||||
@@ -56,7 +58,7 @@ try { market = await Market.open(dataDir, ledger, paymentConfig); }
|
||||
catch(e) {peers.close();ledger.close();engine.close();await lock.close();await unlink(lockPath);throw e;}
|
||||
let custody: CustodyMarket | PostgresCustody | undefined;
|
||||
try {
|
||||
if (custodyConfig === 'sandbox') custody = await openCustodyStorage(dataDir, { feeBps: paymentConfig.feeBps,
|
||||
if (custodyConfig === 'sandbox') custody = await openCustodyStorage(dataDir, { ...paymentConfig,
|
||||
card: (hash, owner, sequence) => {
|
||||
const nft = ledger.nft(hash);
|
||||
if (!nft || nft.owner !== owner || nft.confirmations < CONSENSUS.coinbaseMaturity || nft.sequence !== sequence ||
|
||||
|
||||
@@ -45,6 +45,9 @@ export function termsForCustodyOrder(order: CustodyOrder, context: SaleContext,
|
||||
custodianId: string; host: string; feeBps: number; lockUntilHeight: number; expiresHeight: number;
|
||||
}): SaleTerms {
|
||||
check(order.status === 'awaiting-lock', 'Only awaiting-lock custody orders can draft candidate terms');
|
||||
// The unactivated candidate predates configurable hosting recipients. Never silently discard one.
|
||||
check(!order.hostFeeAddress || order.hostFeeAddress === ADDITIONAL_MARKET_FEE.address,
|
||||
'Custom host fee recipient requires a reviewed sale-candidate terms extension');
|
||||
const allocation = allocateXmrSale(parseAtomicXmr(order.grossAtomicXmr), options.feeBps);
|
||||
check(order.sellerAtomicXmr === allocation.sellerBeforeNetworkCosts.toString() && order.hostAtomicXmr === allocation.hosting.toString() &&
|
||||
order.authorAtomicXmr === allocation.author.toString() && order.authorAddress === ADDITIONAL_MARKET_FEE.address,
|
||||
|
||||
Reference in New Issue
Block a user