From 7734d8ae903490355877754882147c56ec06ebde Mon Sep 17 00:00:00 2001 From: Eric Meehan Date: Tue, 20 Oct 2026 16:24:01 -0400 Subject: [PATCH] Environment variables --- README.md | 2 + compose.custody.yaml | 5 ++- compose.mainnet.yaml | 3 ++ compose.testnet.yaml | 3 ++ compose.yaml | 3 ++ deploy/postgres/Dockerfile | 2 +- deploy/postgres/database.Dockerfile | 2 +- deploy/postgres/pg_hba.conf | 2 +- deploy/postgres/setup.mjs | 2 +- docs/market-host-configuration.md | 35 +++++++++++++++++ docs/persistent-custody-sandbox.md | 2 +- scripts/custody-deployment-check.ts | 2 +- scripts/custody-deployment-init.ts | 2 +- server/market/custody.ts | 18 ++++++--- server/market/policy.ts | 27 ++++++++++++++ server/market/postgres-custody.ts | 16 ++++++-- server/market/store.ts | 17 +++++++-- server/testnet/http.ts | 4 +- server/testnet/sale-candidate.ts | 3 ++ shared/custody.ts | 16 +++++--- shared/market-v3.ts | 10 +++-- shared/market.ts | 4 +- src/CustodyPanel.tsx | 17 ++++++++- src/Marketplace.tsx | 17 ++++++++- tests/custody.test.ts | 58 ++++++++++++++++++++++++++++- tests/market.test.ts | 15 +++++++- tests/postgres-deployment.test.ts | 2 +- tests/postgres/custody.test.ts | 15 ++++++++ tests/sale-candidate.test.ts | 1 + 29 files changed, 267 insertions(+), 38 deletions(-) create mode 100755 docs/market-host-configuration.md create mode 100755 server/market/policy.ts diff --git a/README.md b/README.md index 1d5f3c5..e63bf4f 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,8 @@ For an existing host install, keep the old data directory intact and set `WIKIDE ### Optional custody accounting preview +**Optional market hosting:** set `WIKIDECK_MARKET_ENABLED=0` to suppress public listings/new trading, or `WIKIDECK_MARKET_TRADING_ENABLED=0` for a browsable paused market. Configure hosting-fee payouts with `WIKIDECK_MARKET_HOST_FEE_ADDRESS` (defaults to the author wallet); the fixed 1% author fee remains separate. Existing orders/balance resolution stay accessible. See [host configuration](docs/market-host-configuration.md) for strict values, signed recipient snapshots and safe container recreation. + For a **persistent PostgreSQL-backed local stack**, use [the deployment guide](docs/persistent-custody-sandbox.md) and [compose.custody.yaml](compose.custody.yaml), or the VS Code **Start persistent custody sandbox** task. It starts PostgreSQL plus Wikideck at port 3422, with generated scoped credentials, verified database TLS and explicit migrations. Existing single/three-node commands are unchanged. This uses separate volumes and does not migrate existing SQLite custody or enable real payments. The marketplace's **Custody preview** tab is separate from existing mock checkout. Explicit `WIKIDECK_CUSTODY_MODE=sandbox` on the server enables fictitious test balances, signed fixed-price reservations and withdrawal holds on testnet only. Default is `off`; live modes and mainnet sandbox fail closed. Use a disposable data directory and configure `PUBLIC_ORIGIN` to the exact browser origin. No Monero addresses, wallet RPC or real withdrawals are available. [PostgreSQL financial storage](docs/postgres-financial-storage.md) is selectable with `WIKIDECK_CUSTODY_STORAGE=postgres`, explicit schema/role setup and a secret-file connection configuration; existing SQLite custody requires a stopped-node import, never automatic fallback. diff --git a/compose.custody.yaml b/compose.custody.yaml index 4535330..7de33be 100755 --- a/compose.custody.yaml +++ b/compose.custody.yaml @@ -73,6 +73,9 @@ services: environment: WIKIDECK_NETWORK: testnet WIKIDECK_PAYMENT_MODE: mock + WIKIDECK_MARKET_ENABLED: "${WIKIDECK_MARKET_ENABLED:-1}" + WIKIDECK_MARKET_TRADING_ENABLED: "${WIKIDECK_MARKET_TRADING_ENABLED:-1}" + WIKIDECK_MARKET_HOST_FEE_ADDRESS: "${WIKIDECK_MARKET_HOST_FEE_ADDRESS:-44xHcCdFhW4b951yBHpkTPdLSJ85qTCnFQ8N7m96TtqqRL3dbhzbEH4fe2SJ3a6ESHcNyzCYH5nYJ9ActR1JtkeA7QYGbQv}" WIKIDECK_CUSTODY_MODE: sandbox WIKIDECK_CUSTODY_STORAGE: postgres WIKIDECK_CUSTODY_PG_URL_FILE: /secrets/runtime/url @@ -100,4 +103,4 @@ volumes: pg-owner: pg-runtime: pg-tls: - custody-node: \ No newline at end of file + custody-node: diff --git a/compose.mainnet.yaml b/compose.mainnet.yaml index 6455582..4dc4fb4 100755 --- a/compose.mainnet.yaml +++ b/compose.mainnet.yaml @@ -13,6 +13,9 @@ services: environment: WIKIDECK_NETWORK: mainnet WIKIDECK_PAYMENT_MODE: mock + WIKIDECK_MARKET_ENABLED: "${WIKIDECK_MARKET_ENABLED:-1}" + WIKIDECK_MARKET_TRADING_ENABLED: "${WIKIDECK_MARKET_TRADING_ENABLED:-1}" + WIKIDECK_MARKET_HOST_FEE_ADDRESS: "${WIKIDECK_MARKET_HOST_FEE_ADDRESS:-44xHcCdFhW4b951yBHpkTPdLSJ85qTCnFQ8N7m96TtqqRL3dbhzbEH4fe2SJ3a6ESHcNyzCYH5nYJ9ActR1JtkeA7QYGbQv}" PUBLIC_ORIGIN: "${WIKIDECK_MAINNET_ORIGIN:-}" WIKIDECK_PEERS: "${WIKIDECK_MAINNET_PEERS:-}" WIKIDECK_MAINNET_LAUNCH: "${WIKIDECK_MAINNET_LAUNCH:-}" diff --git a/compose.testnet.yaml b/compose.testnet.yaml index a815a4d..4f79b13 100644 --- a/compose.testnet.yaml +++ b/compose.testnet.yaml @@ -2,6 +2,9 @@ x-market-environment: &market-environment WIKIDECK_NETWORK: testnet WIKIDECK_PAYMENT_MODE: mock + WIKIDECK_MARKET_ENABLED: "${WIKIDECK_MARKET_ENABLED:-1}" + WIKIDECK_MARKET_TRADING_ENABLED: "${WIKIDECK_MARKET_TRADING_ENABLED:-1}" + WIKIDECK_MARKET_HOST_FEE_ADDRESS: "${WIKIDECK_MARKET_HOST_FEE_ADDRESS:-44xHcCdFhW4b951yBHpkTPdLSJ85qTCnFQ8N7m96TtqqRL3dbhzbEH4fe2SJ3a6ESHcNyzCYH5nYJ9ActR1JtkeA7QYGbQv}" WIKIDECK_MARKET_FEE_BPS: "${WIKIDECK_MARKET_FEE_BPS:-250}" WIKIDECK_MARKET_CONFIRMATIONS: "${WIKIDECK_MARKET_CONFIRMATIONS:-10}" services: diff --git a/compose.yaml b/compose.yaml index 866d45b..7d79e10 100644 --- a/compose.yaml +++ b/compose.yaml @@ -12,6 +12,9 @@ services: WIKIDECK_PEERS: "${WIKIDECK_PEERS:-}" WIKIDECK_ALLOW_PRIVATE_PEERS: "${WIKIDECK_ALLOW_PRIVATE_PEERS:-0}" WIKIDECK_PAYMENT_MODE: mock + WIKIDECK_MARKET_ENABLED: "${WIKIDECK_MARKET_ENABLED:-1}" + WIKIDECK_MARKET_TRADING_ENABLED: "${WIKIDECK_MARKET_TRADING_ENABLED:-1}" + WIKIDECK_MARKET_HOST_FEE_ADDRESS: "${WIKIDECK_MARKET_HOST_FEE_ADDRESS:-44xHcCdFhW4b951yBHpkTPdLSJ85qTCnFQ8N7m96TtqqRL3dbhzbEH4fe2SJ3a6ESHcNyzCYH5nYJ9ActR1JtkeA7QYGbQv}" WIKIDECK_MARKET_FEE_BPS: "${WIKIDECK_MARKET_FEE_BPS:-250}" WIKIDECK_MARKET_CONFIRMATIONS: "${WIKIDECK_MARKET_CONFIRMATIONS:-10}" volumes: diff --git a/deploy/postgres/Dockerfile b/deploy/postgres/Dockerfile index 52bd669..6040460 100755 --- a/deploy/postgres/Dockerfile +++ b/deploy/postgres/Dockerfile @@ -1,4 +1,4 @@ FROM node:24-alpine RUN apk add --no-cache openssl COPY deploy/postgres/setup.mjs /setup.mjs -CMD ["node", "/setup.mjs"] \ No newline at end of file +CMD ["node", "/setup.mjs"] diff --git a/deploy/postgres/database.Dockerfile b/deploy/postgres/database.Dockerfile index 1f29cec..d2b9b9b 100755 --- a/deploy/postgres/database.Dockerfile +++ b/deploy/postgres/database.Dockerfile @@ -1,2 +1,2 @@ FROM postgres:17-alpine -COPY deploy/postgres/pg_hba.conf /etc/wikideck/pg_hba.conf \ No newline at end of file +COPY deploy/postgres/pg_hba.conf /etc/wikideck/pg_hba.conf diff --git a/deploy/postgres/pg_hba.conf b/deploy/postgres/pg_hba.conf index 389e10c..4ada7e9 100755 --- a/deploy/postgres/pg_hba.conf +++ b/deploy/postgres/pg_hba.conf @@ -4,4 +4,4 @@ hostnossl all all all reject hostssl wikideck_custody postgres all scram-sha-256 hostssl wikideck_custody custody_owner all scram-sha-256 hostssl wikideck_custody custody_runtime all scram-sha-256 -host all all all reject \ No newline at end of file +host all all all reject diff --git a/deploy/postgres/setup.mjs b/deploy/postgres/setup.mjs index af967e6..2af9141 100755 --- a/deploy/postgres/setup.mjs +++ b/deploy/postgres/setup.mjs @@ -41,4 +41,4 @@ if (existsSync(marker)) { chmodSync('/secrets/tls/server.crt', 0o644); put(marker, 'wikideck-local-pg-v1', 0o644); console.log('Provisioned fresh local sandbox credentials and verified-TLS certificate; no secrets logged.'); -} \ No newline at end of file +} diff --git a/docs/market-host-configuration.md b/docs/market-host-configuration.md new file mode 100755 index 0000000..9887df6 --- /dev/null +++ b/docs/market-host-configuration.md @@ -0,0 +1,35 @@ +# Optional market hosting and host fee wallet + +These are **server runtime** environment variables, forwarded by the single-node, three-node, gated mainnet and persistent custody Compose files. They do not enable real payments. + +| Variable | Default | Effect | +| --- | --- | --- | +| `WIKIDECK_MARKET_ENABLED` | `1` | `0` removes public listings and rejects new listings, bids, purchases, custody reservations and fictitious funding. Node validation, mining, peer sync and free transfers/trades continue unchanged. | +| `WIKIDECK_MARKET_TRADING_ENABLED` | `1` | `0` pauses new trading/funding but keeps public listings browsable. Useful for maintenance/read-only operation. | +| `WIKIDECK_MARKET_HOST_FEE_ADDRESS` | Fixed project author XMR address | Public Monero recipient for the **hosting commission**, independently of the fixed additional author commission. | +| `WIKIDECK_MARKET_FEE_BPS` | `250` | Existing hosting commission rate, in basis points; range 0–5000. | +| `WIKIDECK_CUSTODY_MODE` | `off` in the application | Existing custody-service control: `off` or testnet-only `sandbox`. The dedicated custody Compose stack deliberately enables `sandbox` and provisions PostgreSQL. | + +Booleans accept exactly `0`/`1`; malformed or empty flags fail startup. The fee address must be a checksum-valid **mainnet Monero standard address or subaddress** under the current XMR policy; integrated addresses, URLs, other networks and blank values are rejected. It is not a private key, buyer deposit address, or proof of ownership. No RPC/wallet access is needed to validate it. + +Default wallet: + +44xHcCdFhW4b951yBHpkTPdLSJ85qTCnFQ8N7m96TtqqRL3dbhzbEH4fe2SJ3a6ESHcNyzCYH5nYJ9ActR1JtkeA7QYGbQv + +Set the variables in the shell/Compose environment or a local Compose environment file, then recreate the intended app container. They are not browser build variables. Merely restarting a container does not replace its environment. Existing Compose defaults preserve current hosting behavior. In the three-node configuration shared values apply to all nodes; per-service environment overrides allow different hosts/fees. Do not point independent markets at a shared financial schema. + +## Existing obligations remain available + +Disabling market hosting is **not a funds freeze or deletion**. Signed private activity, existing-order mock payment/delivery/refund, listing retirement/cancellation, custody cancellation and withdrawal-hold requests remain available under their existing checks. Reconciliation continues and data is retained. PostgreSQL remains required if that node already has PostgreSQL-backed liabilities; the switch is not a shortcut to abandoning custody responsibilities. Existing valid command receipts remain replayable without creating another mutation. + +A peer that never wants custody can leave `WIKIDECK_CUSTODY_MODE=off` and use the ordinary single/three-node stack with `WIKIDECK_MARKET_ENABLED=0`; it does not need the dedicated PostgreSQL stack. Do not turn custody off merely to hide pre-existing liabilities; first resolve them under a reviewed shutdown procedure. The marketplace UI retains an activity/resolution surface and displays the disabled/paused policy rather than hiding recovery controls. + +## Fee recipient and signature compatibility + +New native-XMR listings and custody listings snapshot the configured host fee address. Buyer checkout/reservation must authorize that same recipient. Both UIs show it during review and include it in signed terms. Recipient tampering invalidates the signature, and an unsigned/omitted recipient is accepted **only as the historical default author address**, never as authorization for an arbitrary configured wallet. + +Existing signature bytes remain valid: omission retains the original message; explicitly supplied recipients add a domain-tagged fee tuple. Old USD/native-XMR records are not rewritten. Changing the environment changes **new listings**, not existing listings/orders or accumulated revenue. New checkout against an old listing follows its original recipient. For a new recipient, cancel/relist with fresh seller authorization. Hosting rate and the fixed 1% author policy remain separate; defaulting both recipients to the same wallet does not merge the rates. + +The custody SQLite fixture records newly settled simulated host revenue under its snapshotted recipient. PostgreSQL production settlement/host withdrawals are still unimplemented: configuring an address does not create automatic transfers. Historical unallocated host revenue remains historical; it is not silently reassigned. The offline sale-lock candidate currently refuses custom-recipient drafts until its separate reviewed terms extension is specified, rather than dropping that authorization. + +Validation: TypeScript/Vite build, 168 regression tests and 11 PostgreSQL integration tests passed. The full 168-test regression run was repeated after the candidate fail-closed guard. Tests cover strict flag/address validation, paused/disabled behavior, continuing existing-order delivery, preserved balances/cancellations, signed-recipient tampering and recipient snapshots across config changes. Real XMR remains disabled. diff --git a/docs/persistent-custody-sandbox.md b/docs/persistent-custody-sandbox.md index 7994f3d..84b5534 100755 --- a/docs/persistent-custody-sandbox.md +++ b/docs/persistent-custody-sandbox.md @@ -51,4 +51,4 @@ Rehearsal: provisioned a fresh isolated stack, observed healthy PostgreSQL and s TypeScript/Vite runtime/test builds pass. Final offline regression run: **164 tests passed, zero failures/skips**. Deployment regression tests verify sandbox configuration, no published database port, runtime secret isolation, persistent data, TLS/SCRAM HBA and fail-closed partial provisioning. This is not independent certification of secrets provisioning or production hardening. -Still absent: real Monero observer/signer/deposits/withdrawals, NFT candidate activation/settlement, managed certificates/rotation, automatic coordinated backups, production capacity/availability guarantees and release approval. The existing production plan remains authoritative. \ No newline at end of file +Still absent: real Monero observer/signer/deposits/withdrawals, NFT candidate activation/settlement, managed certificates/rotation, automatic coordinated backups, production capacity/availability guarantees and release approval. The existing production plan remains authoritative. diff --git a/scripts/custody-deployment-check.ts b/scripts/custody-deployment-check.ts index d016727..baba08a 100755 --- a/scripts/custody-deployment-check.ts +++ b/scripts/custody-deployment-check.ts @@ -47,4 +47,4 @@ try { } finally { c.release(); } } catch { console.error('Sandbox deployment check failed; no connection secrets logged.'); process.exitCode = 1; -} finally { await pool.end(); } \ No newline at end of file +} finally { await pool.end(); } diff --git a/scripts/custody-deployment-init.ts b/scripts/custody-deployment-init.ts index 6b45afd..fda6106 100755 --- a/scripts/custody-deployment-init.ts +++ b/scripts/custody-deployment-init.ts @@ -27,4 +27,4 @@ try { } catch { console.error('Local PostgreSQL bootstrap failed; inspect roles, secret volumes, TLS and schema privately. No reset or fallback performed.'); process.exitCode = 1; -} \ No newline at end of file +} diff --git a/server/market/custody.ts b/server/market/custody.ts index 9ba9f2e..452ac3a 100755 --- a/server/market/custody.ts +++ b/server/market/custody.ts @@ -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 { 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(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('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); diff --git a/server/market/policy.ts b/server/market/policy.ts new file mode 100755 index 0000000..0c6515e --- /dev/null +++ b/server/market/policy.ts @@ -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 { + 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 { + return { marketEnabled: policy.marketEnabled ?? true, tradingEnabled: policy.tradingEnabled ?? true, + hostFeeAddress: policy.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address }; +} +export async function validateMarketPolicy(policy: MarketPolicy): Promise { + 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); +} diff --git a/server/market/postgres-custody.ts b/server/market/postgres-custody.ts index 9796e01..17f374a 100755 --- a/server/market/postgres-custody.ts +++ b/server/market/postgres-custody.ts @@ -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 { 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 { 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(write: boolean, fn: (c: PoolClient) => Promise): Promise { check(this.active < 16, 'Custody database busy; retry the original command shortly'); @@ -93,6 +95,7 @@ export class PostgresCustody { } async browse(offset = 0): Promise { 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); diff --git a/server/market/store.ts b/server/market/store.ts index 96dd97d..b5c65db 100755 --- a/server/market/store.ts +++ b/server/market/store.ts @@ -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; 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 { + 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('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; diff --git a/server/testnet/http.ts b/server/testnet/http.ts index 93eeb5e..455ec10 100644 --- a/server/testnet/http.ts +++ b/server/testnet/http.ts @@ -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 || diff --git a/server/testnet/sale-candidate.ts b/server/testnet/sale-candidate.ts index fe31c4c..3b27df3 100755 --- a/server/testnet/sale-candidate.ts +++ b/server/testnet/sale-candidate.ts @@ -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, diff --git a/shared/custody.ts b/shared/custody.ts index c299dcc..e45c8b3 100755 --- a/shared/custody.ts +++ b/shared/custody.ts @@ -19,10 +19,11 @@ export const custodyActionSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('activity'), offset: z.number().int().min(0).max(10000).default(0) }).strict(), z.object({ type: z.literal('mock-credit') }).strict(), z.object({ type: z.literal('list'), cardHash: hex, sequence: z.number().int().nonnegative().safe(), - priceAtomicXmr: amount, feeBps: z.number().int().min(0).max(5000) }).strict(), + priceAtomicXmr: amount, feeBps: z.number().int().min(0).max(5000), + hostFeeAddress: z.string().regex(/^[1-9A-HJ-NP-Za-km-z]{95}$/).optional() }).strict(), z.object({ type: z.literal('cancel-listing'), listingId: id }).strict(), z.object({ type: z.literal('reserve'), listingId: id, priceAtomicXmr: amount, - feeBps: z.number().int().min(0).max(5000) }).strict(), + feeBps: z.number().int().min(0).max(5000), hostFeeAddress: z.string().regex(/^[1-9A-HJ-NP-Za-km-z]{95}$/).optional() }).strict(), z.object({ type: z.literal('cancel-reservation'), orderId: id }).strict(), // No real Monero address accepted or generated in this phase. z.object({ type: z.literal('withdraw'), destination: z.literal('mock-external'), amountAtomicXmr: amount, @@ -44,18 +45,21 @@ export function custodyMessage(c: Omit): string { : a.type === 'withdraw' ? [a.destination, 'XMR', a.amountAtomicXmr, a.maxFeeAtomicXmr] : a.type === 'cancel-withdrawal' ? [a.withdrawalId] : a.type === 'activity' ? [a.offset] : [MOCK_CREDIT_ATOMIC]; + const fees: (string | number)[] = [ADDITIONAL_MARKET_FEE.bps, ADDITIONAL_MARKET_FEE.address]; + if ((a.type === 'list' || a.type === 'reserve') && a.hostFeeAddress !== undefined) fees.push('host-fee-recipient-v1', a.hostFeeAddress); return JSON.stringify(['wikideck-custody-command-v1', c.version, c.mode, c.policy, c.network, c.chainId, c.custodianId, c.actor, c.publicKey, c.nonce, c.expiresAt, a.type, terms, - [ADDITIONAL_MARKET_FEE.bps, ADDITIONAL_MARKET_FEE.address]]); + fees]); } export type CustodyInfo = { enabled: true; mode: 'sandbox'; policy: typeof CUSTODY_POLICY; network: typeof NETWORK; chainId: string; custodianId: string; feeBps: number; - liveDeposits: false; liveWithdrawals: false; nftSettlement: false }; + liveDeposits: false; liveWithdrawals: false; nftSettlement: false; + marketEnabled?: boolean; tradingEnabled?: boolean; hostFeeAddress?: string }; export type CustodyListing = { id: string; seller: string; cardHash: string; sequence: number; - title: string; priceAtomicXmr: string; feeBps: number; status: 'open' | 'reserved' | 'cancelled' | 'sold'; createdAt: number }; + title: string; priceAtomicXmr: string; feeBps: number; status: 'open' | 'reserved' | 'cancelled' | 'sold'; createdAt: number; hostFeeAddress?: string }; export type CustodyOrder = { id: string; listingId: string; seller: string; buyer: string; cardHash: string; sequence: number; grossAtomicXmr: string; sellerAtomicXmr: string; authorAtomicXmr: string; - hostAtomicXmr: string; authorAddress: string; status: 'awaiting-lock' | 'cancelled' | 'settled'; createdAt: number }; + hostAtomicXmr: string; authorAddress: string; status: 'awaiting-lock' | 'cancelled' | 'settled'; createdAt: number; hostFeeAddress?: string }; export type CustodyWithdrawal = { id: string; actor: string; destination: 'mock-external'; amountAtomicXmr: string; maxFeeAtomicXmr: string; status: 'reserved' | 'prepared' | 'complete' | 'cancelled'; attemptId?: string; transactionId?: string; feeAtomicXmr?: string; createdAt: number }; diff --git a/shared/market-v3.ts b/shared/market-v3.ts index 4c3ed31..988873e 100755 --- a/shared/market-v3.ts +++ b/shared/market-v3.ts @@ -15,9 +15,11 @@ const feeBps = z.number().int().min(0).max(5000); export const marketActionV3Schema = z.discriminatedUnion('type', [ z.object({ type: z.literal('list'), cardHash: hex, sequence: z.number().int().nonnegative().safe(), - kind: z.enum(['fixed', 'auction']), priceAtomicXmr: atomicPrice, endsAt: z.number().int().nonnegative().nullable(), feeBps }).strict(), + kind: z.enum(['fixed', 'auction']), priceAtomicXmr: atomicPrice, endsAt: z.number().int().nonnegative().nullable(), feeBps, + hostFeeAddress: z.string().regex(/^[1-9A-HJ-NP-Za-km-z]{95}$/).optional() }).strict(), z.object({ type: z.literal('bid'), listingId: id, priceAtomicXmr: atomicPrice }).strict(), - z.object({ type: z.literal('buy'), listingId: id, priceAtomicXmr: atomicPrice, feeBps }).strict(), + z.object({ type: z.literal('buy'), listingId: id, priceAtomicXmr: atomicPrice, feeBps, + hostFeeAddress: z.string().regex(/^[1-9A-HJ-NP-Za-km-z]{95}$/).optional() }).strict(), z.object({ type: z.literal('cancel'), listingId: id }).strict(), z.object({ type: z.literal('retire-legacy-listing'), listingId: id }).strict(), z.object({ type: z.literal('mock-pay'), orderId: id }).strict(), @@ -44,7 +46,9 @@ export function marketMessageV3(command: Omit): st : 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]; - const feeTerms = a.type === 'list' || a.type === 'buy' ? [ADDITIONAL_MARKET_FEE.bps, ADDITIONAL_MARKET_FEE.address] : []; + const feeTerms: (string | number)[] = a.type === 'list' || a.type === 'buy' ? [ADDITIONAL_MARKET_FEE.bps, ADDITIONAL_MARKET_FEE.address] : []; + // Preserve original v3 message bytes if omitted; omission authorizes ONLY the default author recipient. + if ((a.type === 'list' || a.type === 'buy') && a.hostFeeAddress !== undefined) feeTerms.push('host-fee-recipient-v1', a.hostFeeAddress); return JSON.stringify([NETWORK, 'market-command-v3', command.version, command.chainId, command.marketId, command.actor, command.publicKey, command.nonce, command.expiresAt, a.type, terms, feeTerms]); } diff --git a/shared/market.ts b/shared/market.ts index f8d5630..3070e3e 100755 --- a/shared/market.ts +++ b/shared/market.ts @@ -49,6 +49,7 @@ export type MarketListing = { 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; @@ -58,10 +59,11 @@ export type MarketOrder = { 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 }; + 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); diff --git a/src/CustodyPanel.tsx b/src/CustodyPanel.tsx index d63b5d1..46d2464 100755 --- a/src/CustodyPanel.tsx +++ b/src/CustodyPanel.tsx @@ -65,7 +65,20 @@ export function CustodyPanel({ wallet, snapshot, rpcAvailable }: Props) { return () => { generation.current++; }; }, [refresh, rpcAvailable]); useEffect(() => { if (review) reviewElement.current?.focus(); }, [review]); - const prepare = (action: CustodyAction, description: string) => { setError(''); setNotice(''); setReview({ action, description }); }; + const prepare = (action: CustodyAction, description: string) => { + if (['list','reserve','mock-credit'].includes(action.type) && (page?.info.marketEnabled === false || page?.info.tradingEnabled === false)) { + setError('This host is not accepting new trading or funding. Existing balances and cancellation/withdrawal requests remain accessible.'); return; + } + if (action.type === 'list' || action.type === 'reserve') { + const listingId = action.type === 'reserve' ? action.listingId : undefined; + const recipient = action.type === 'reserve' + ? page?.listings.find(l => l.id === listingId)?.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address + : page?.info.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address; + action = { ...action, hostFeeAddress: recipient }; + description += ` Host fee recipient: ${recipient}. The separate fixed author fee is unchanged.`; + } + setError(''); setNotice(''); setReview({ action, description }); + }; const submit = async (again?: CustodyCommand) => { if (!wallet || !page || pending.current || (!review && !again)) return; pending.current = true; setBusy(true); setError(''); @@ -93,6 +106,8 @@ export function CustodyPanel({ wallet, snapshot, rpcAvailable }: Props) {

A balance for this market.

A future custodian would hold pooled XMR and track what each customer is owed. Purchases would move ledger balances, not XMR between subaddresses. You would trust the operator with your deposits.

{enabled === false &&

This host has not enabled the custody sandbox. Real custody is unavailable.

} + {page&&(page.info.marketEnabled===false||page.info.tradingEnabled===false)&&

{page.info.marketEnabled===false?'Public market disabled on this host.':'New trading paused on this host.'} Existing balances, cancellations and withdrawal requests remain accessible.

} + {page?.info.hostFeeAddress&&

Host fee wallet (not a deposit address): {page.info.hostFeeAddress}

} {!rpcAvailable &&

Reconnect to the node to use this preview.

} {!wallet &&

Open your Wikideck wallet to view private balances and authorize sandbox commands.

} {page &&

Sandbox custodian {page.info.custodianId} · {page.info.network} · host {page.info.feeBps / 100}% + author 1%. Fresh custody signatures are required; legacy mock authorizations do not debit balances.

} diff --git a/src/Marketplace.tsx b/src/Marketplace.tsx index 17cb81a..44c48c4 100755 --- a/src/Marketplace.tsx +++ b/src/Marketplace.tsx @@ -86,7 +86,20 @@ export function Marketplace({ wallet, snapshot, rpcAvailable, onNotice }: Props) const timer = setInterval(() => { setNow(Date.now()); if (rpcAvailable && !inFlight.current) load(); }, 15000); return () => { active = false; version.current++; clearInterval(timer); }; }, [refresh, rpcAvailable]); - const prepare = (action: MarketAction | MarketActionV3, text: string, legacy = false) => { setError(''); setReview({ action, text, legacy }); }; + const prepare = (action: MarketAction | MarketActionV3, text: string, legacy = false) => { + if (['list','bid','buy'].includes(action.type) && (page?.info.marketEnabled === false || page?.info.tradingEnabled === false)) { + setError('This host is not accepting new trading. Existing orders can still be resolved.'); return; + } + if (!legacy && (action.type === 'list' || action.type === 'buy')) { + const listingId = action.type === 'buy' ? action.listingId : undefined; + const recipient = action.type === 'buy' + ? page?.listings.find(l => l.id === listingId)?.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address + : page?.info.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address; + action = { ...action, hostFeeAddress: recipient } as MarketActionV3; + text += ` Host fee recipient: ${recipient}. The separate fixed author fee is unchanged.`; + } + setError(''); setReview({ action, text, legacy }); + }; const submit = async () => { if (!wallet || !page || !review || inFlight.current || !rpcAvailable) return; const currentVersion = version.current; @@ -139,6 +152,8 @@ export function Marketplace({ wallet, snapshot, rpcAvailable, onNotice }: Props) }; const disabled = busy || !wallet || !rpcAvailable || !page; return
+ {page&&(page.info.marketEnabled===false||page.info.tradingEnabled===false)&&

{page.info.marketEnabled===false?'This peer does not offer a public marketplace.':'This peer has paused new trading.'} Existing orders, cancellations and resolution remain accessible through Your activity.

} + {page?.info.hostFeeAddress&&

Host fee wallet (public payout recipient, not a deposit address): {page.info.hostFeeAddress}

}
THIS PEER’S COLLECTOR EXCHANGE

Your next find.
Your kind of market.

Buy a fixed-price card or join a timed auction. Prices are listed directly in XMR atomic units (12 decimal places). Every peer can host its own market; listings and orders remain local to that host.

PEER-HOSTED · COMMUNITY-DRIVEN

Sales support the hosting peer’s operator through a configurable fee and the project author through a hardcoded 1% fee. Both are deducted from seller proceeds. Collection-specific curation tools and peer-hosted custom TCGs are planned, not available yet.

Mock payments only · never send real XMR

Quotes, payment confirmations, refunds, fees, and automatic seller payouts are simulated. Card delivery is a real on-chain transfer. There is no payable Monero address. Listings are local to this marketplace node, not shared by peers.

SANDBOX
diff --git a/tests/custody.test.ts b/tests/custody.test.ts index 224c141..e4a17e8 100755 --- a/tests/custody.test.ts +++ b/tests/custody.test.ts @@ -11,6 +11,9 @@ import { CUSTODY_POLICY, MOCK_CREDIT_ATOMIC, custodyCommandSchema, custodyMessag type CustodyWithdrawal, type CustodyCommand } from '../shared/custody.ts'; import { NETWORK } from '../shared/testnet.ts'; import { address, CHAIN_ID } from '../server/testnet/consensus.ts'; +import { operatorMarketPolicy, validateMarketPolicy, type MarketPolicy } from '../server/market/policy.ts'; +import { ADDITIONAL_MARKET_FEE } from '../shared/market.ts'; +import { keccak } from 'hash-wasm'; function identity() { const keys = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }); @@ -42,9 +45,62 @@ async function fixture(t: { after(fn: () => Promise): void }) { priceAtomicXmr: l.priceAtomicXmr, feeBps: l.feeBps }); return { dir, envelope, execute, activity, credit, listing, reserve, get market() { return market; }, changeCard: () => { present = false; }, advance: (ms: number) => { now += ms; }, - restart: async () => { market.close(); market = await CustodyMarket.open(dir, options); } }; + restart: async (policy: MarketPolicy = {}) => { market.close(); market = await CustodyMarket.open(dir, { ...options, ...policy }); } }; } +async function feeAddress() { + const prefix = Uint8Array.from([18, ...Array(64).fill(2)]); + const data = Buffer.concat([prefix, Buffer.from((await keccak(prefix, 256)).slice(0, 8), 'hex')]); + const alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; + const sizes: Record = { 1: 2, 2: 3, 3: 5, 4: 6, 5: 7, 6: 9, 7: 10, 8: 11 }; + let encoded = ''; + for (let i = 0; i < data.length; i += 8) { + const chunk = data.subarray(i, i + 8); let n = BigInt(`0x${chunk.toString('hex')}`), text = ''; + while (n) { text = alphabet[Number(n % 58n)] + text; n /= 58n; } + encoded += text.padStart(sizes[chunk.length], '1'); + } + return encoded; +} +test('operator market switches are strict and host fee defaults to fixed author with checksum validation', async () => { + assert.deepEqual(operatorMarketPolicy({}), { marketEnabled: true, tradingEnabled: true, hostFeeAddress: ADDITIONAL_MARKET_FEE.address }); + for (const v of ['', 'true', 'false', 'yes', '2']) { + assert.throws(() => operatorMarketPolicy({ WIKIDECK_MARKET_ENABLED: v })); + assert.throws(() => operatorMarketPolicy({ WIKIDECK_MARKET_TRADING_ENABLED: v })); + } + await validateMarketPolicy(operatorMarketPolicy({})); + await assert.rejects(validateMarketPolicy({ hostFeeAddress: '4'.repeat(95) }), /checksum|network/); +}); +test('market disable hides public listings and rejects new business but retains private balances and safe release', async t => { + const f = await fixture(t); f.credit(); const l = f.listing(), o = f.reserve(l); + const w = f.execute(buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '100', maxFeeAtomicXmr: '1' }); + await f.restart({ marketEnabled: false }); + assert.deepEqual(f.market.browse().listings, []); assert.equal(f.market.info().marketEnabled, false); + assert.throws(() => f.listing('2'), /disabled/); + assert.throws(() => f.credit(stranger), /disabled/); + f.execute(buyer, { type: 'cancel-reservation', orderId: o.id }); + f.execute(buyer, { type: 'cancel-withdrawal', withdrawalId: w.id }); + assert.equal(f.activity().availableAtomicXmr, MOCK_CREDIT_ATOMIC); + const withdrawal = f.execute(buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '1', maxFeeAtomicXmr: '0' }); + assert.equal(withdrawal.status, 'reserved'); +}); +test('paused trading keeps browse and cancellations available, and fee recipients are signed and snapshotted', async t => { + const f = await fixture(t), recipient = await feeAddress(); f.credit(); + await f.restart({ hostFeeAddress: recipient }); + assert.throws(() => f.listing(), /recipient/); + const action = { type: 'list' as const, cardHash, sequence: 0, priceAtomicXmr: '1000', feeBps: 250, hostFeeAddress: recipient }; + const c = f.envelope(seller, action); + assert.throws(() => f.market.execute({ ...c, action: { ...action, hostFeeAddress: ADDITIONAL_MARKET_FEE.address } }), /signature/); + const l = f.market.execute(c) as CustodyListing; + await f.restart({ hostFeeAddress: ADDITIONAL_MARKET_FEE.address, tradingEnabled: false }); + assert.equal(f.market.browse().listings[0].hostFeeAddress, recipient); + assert.throws(() => f.reserve(l), /disabled/); + await f.restart(); + assert.throws(() => f.reserve(l), /recipient/); + const o = f.execute(buyer, { type: 'reserve', listingId: l.id, priceAtomicXmr: l.priceAtomicXmr, feeBps: 250, hostFeeAddress: recipient }); + assert.equal(o.hostFeeAddress, recipient); assert.equal(o.authorAddress, ADDITIONAL_MARKET_FEE.address); + f.market.settleFixture(o.id, evidence); assert.equal(f.market.audit().assetsAtomicXmr, MOCK_CREDIT_ATOMIC); +}); + test('custody mode is off by default and rejects all live configurations', () => { assert.equal(custodyMode({}), 'off'); assert.equal(custodyMode({ WIKIDECK_CUSTODY_MODE: 'sandbox' }), 'sandbox'); for (const mode of ['live', 'mainnet', '', 'shkeeper']) assert.throws(() => custodyMode({ WIKIDECK_CUSTODY_MODE: mode })); diff --git a/tests/market.test.ts b/tests/market.test.ts index f92e42e..34b942a 100755 --- a/tests/market.test.ts +++ b/tests/market.test.ts @@ -12,6 +12,7 @@ import { marketActionV3Schema, marketMessageV3, type MarketActionV3, type Market type MarketListingV3, type MarketOrderV3 } from '../shared/market-v3.ts'; import { NETWORK, nftTransferMessage, testnetOwnerOf, transactionCardIds, type NftTransfer, type TestnetSnapshot, type TestnetBlock } from '../shared/testnet.ts'; import type { CanonicalEvent } from '../server/testnet/canonical-index.ts'; +import type { MarketPolicy } from '../server/market/policy.ts'; import { address, CHAIN_ID, GENESIS_HASH, transferId, verifySignature } from '../server/testnet/consensus.ts'; function identity() { @@ -105,9 +106,19 @@ async function fixture(t: { after(fn: () => Promise): void }, indexed = fa }; return { dir, chain, events, envelope, envelopeV3, run, list, buy, orders, delivery, confirm, get market() { return market; }, get now() { return time; }, advance: (ms: number) => { time += ms; }, - restart: async () => { market.close(); market = await Market.open(dir, adapter, - { feeBps: 500, confirmations: 5 }, () => time); } }; + restart: async (policy: MarketPolicy = {}) => { market.close(); market = await Market.open(dir, adapter, + { feeBps: 500, confirmations: 5, ...policy }, () => time); } }; } +test('mock market switches block new listings/bids/checkout while allowing paid-order delivery and private activity', async t => { + const f = await fixture(t), l = await f.list(), o = await f.buy(l); + await f.run(bob, { type: 'mock-pay', orderId: o.id }); + await f.restart({ marketEnabled: false }); + assert.equal(f.market.info().marketEnabled, false); assert.deepEqual(f.market.browse().listings, []); + await assert.rejects(f.list(), /disabled/); + assert.equal((await f.orders())[0].id, o.id); + const tx = f.delivery(o); await f.run(alice, { type: 'deliver', orderId: o.id, transfer: tx }); + assert.equal(f.chain.state.pendingTransfers.length, 1); +}); test('indexed market uses bounded durable event catch-up and never requests full snapshots', async t => { const f = await fixture(t, true); diff --git a/tests/postgres-deployment.test.ts b/tests/postgres-deployment.test.ts index d60568e..a5f5ba6 100755 --- a/tests/postgres-deployment.test.ts +++ b/tests/postgres-deployment.test.ts @@ -27,4 +27,4 @@ test('database HBA requires TLS+SCRAM over TCP and provisioning refuses partial assert.match(setup, /Partial secret initialization/); assert.match(setup, /subjectAltName=DNS:database/); assert.match(setup, /-checkend/); -}); \ No newline at end of file +}); diff --git a/tests/postgres/custody.test.ts b/tests/postgres/custody.test.ts index d2be176..cbab60f 100755 --- a/tests/postgres/custody.test.ts +++ b/tests/postgres/custody.test.ts @@ -211,6 +211,21 @@ test('configuration requires secret files, verified TLS, literal backend, and lo await assert.rejects(postgresConfig({ WIKIDECK_CUSTODY_PG_URL_FILE: file }), /configuration/); }); +test('PostgreSQL market disable preserves existing claims and signed withdrawal cancellation', async t => { + const f = await fixture(t); await f.execute(buyer, { type: 'mock-credit' }); + const listing = await f.execute(seller, { type: 'list', cardHash: hash, sequence: 0, priceAtomicXmr: '100', feeBps: 250 }) as CustodyListing; + const order = await f.execute(buyer, { type: 'reserve', listingId: listing.id, priceAtomicXmr: '100', feeBps: 250 }) as CustodyOrder; + const disabled = await PostgresCustody.open(f.runtime, { ...options, marketEnabled: false }); + try { + assert.equal(disabled.info().marketEnabled, false); assert.deepEqual((await disabled.browse()).listings, []); + await assert.rejects(disabled.execute(envelope(disabled.id, other, { type: 'mock-credit' })), /disabled/); + await disabled.execute(envelope(disabled.id, buyer, { type: 'cancel-reservation', orderId: order.id })); + const w = await disabled.execute(envelope(disabled.id, buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '1', maxFeeAtomicXmr: '0' })) as CustodyWithdrawal; + await disabled.execute(envelope(disabled.id, buyer, { type: 'cancel-withdrawal', withdrawalId: w.id })); + assert.equal((await disabled.audit()).assetsAtomicXmr, '1000000000000'); + } finally { await disabled.close(); } +}); + test('full PostgreSQL dump/isolated restore preserves custody identity, receipts and journal backing', async t => { const f = await fixture(t); await f.execute(buyer, { type: 'mock-credit' }); const cmd = envelope(f.market.id, buyer, { type: 'withdraw', destination: 'mock-external', amountAtomicXmr: '500', maxFeeAtomicXmr: '20' }); diff --git a/tests/sale-candidate.test.ts b/tests/sale-candidate.test.ts index c3aff18..799846a 100755 --- a/tests/sale-candidate.test.ts +++ b/tests/sale-candidate.test.ts @@ -206,6 +206,7 @@ test('candidate drafts from real sandbox reservations without signing, changing assert.equal(market.info().nftSettlement, false); assert.throws(() => termsForCustodyOrder({ ...order, authorAtomicXmr: '1' }, context, options), /allocations/); assert.throws(() => termsForCustodyOrder({ ...order, status: 'cancelled' }, context, options), /awaiting-lock/); + assert.throws(() => termsForCustodyOrder({ ...order, hostFeeAddress: 'different-recipient' }, context, options), /terms extension/); }); test('enumerated adversarial histories preserve exclusivity and deterministic replay across snapshot restores', () => {