Environment variables

This commit is contained in:
2026-10-20 16:24:01 -04:00
parent 4cac26d84b
commit 7734d8ae90
29 changed files with 267 additions and 38 deletions
+13 -5
View File
@@ -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);