Paid-sale workflow

This commit is contained in:
2026-10-20 17:38:34 -04:00
parent 621611ecd9
commit ca8ff48628
11 changed files with 414 additions and 13 deletions
+25
View File
@@ -4,6 +4,7 @@ import { ADDITIONAL_MARKET_FEE } from '../../shared/market.ts';
import { parseAtomicXmr } from '../../shared/xmr-amount.ts';
import { allocateXmrSale } from '../../shared/xmr-pricing.ts';
import { validateXmrAddress } from './xmr.ts';
import { replayReservedSale } from './sale-rehearsal.ts';
/** Codes only: never attach customer IDs, payloads, SQL errors or addresses to operational diagnostics. */
export class CustodyIntegrityError extends Error {
@@ -96,6 +97,30 @@ async function checkOrders(client: PoolClient, rows: OrderRow[]) {
expectJournal(posting, `settle:${o.id}`, 'mock-sale-settlement', o.status === 'settled' ? [d(account, o.grossAtomicXmr),
c(`customer:${o.seller}:available`, o.sellerAtomicXmr), c(`author:${o.authorAddress}`, o.authorAtomicXmr),
c(o.hostFeeAddress ? `revenue:host:${o.hostFeeAddress}` : 'revenue:host', o.hostAtomicXmr)] : null);
const evidenceRows = await client.query('SELECT key,value FROM wk_custody.custody_meta WHERE key=ANY($1::text[])',
[[`sale-rehearsal-binding:${o.id}`, `sale-rehearsal-observation:${o.id}`, `sale-rehearsal-terminal:${o.id}`]]);
if (evidenceRows.rowCount) {
try {
const records = new Map<string, string>(evidenceRows.rows.map(r => [r.key, r.value]));
const binding = records.get(`sale-rehearsal-binding:${o.id}`);
const identity = await client.query("SELECT value FROM wk_custody.custody_meta WHERE key='identity'");
const verify = (key: string) => {
const evidence = JSON.parse(records.get(key) ?? '');
const replay = replayReservedSale({ ...o, status: 'awaiting-lock' }, JSON.parse(identity.rows[0].value).id, evidence.input, l.feeBps);
if (evidence.version !== 1 || evidence.binding !== binding || replay.binding !== binding ||
replay.stateRoot !== evidence.stateRoot) fail('sale-rehearsal-evidence-mismatch');
return replay;
};
verify(`sale-rehearsal-observation:${o.id}`);
if (o.status === 'awaiting-lock') {
if (records.has(`sale-rehearsal-terminal:${o.id}`)) fail('sale-rehearsal-evidence-mismatch');
} else {
const replay = verify(`sale-rehearsal-terminal:${o.id}`);
if (replay.decision.action !== (o.status === 'settled' ? 'settle' : 'refund') ||
(o.status === 'settled' && row.box_data !== `fixture:${replay.stateRoot}`)) fail('sale-rehearsal-evidence-mismatch');
}
} catch { fail('sale-rehearsal-evidence-mismatch'); }
}
}
await validateWallets(wallets);
}
+58 -1
View File
@@ -12,8 +12,10 @@ import { CustodyError } from './custody.ts';
import { CUSTODY_WRITE_LOCK, verifyCustodySchema } from './postgres-schema.ts';
import { resolvedMarketPolicy, validateMarketPolicy, permitsMarketAction, type MarketPolicy } from './policy.ts';
import { CustodyIntegrityError, auditBusinessLiabilities, auditOrder, auditWithdrawal } from './custody-integrity.ts';
import { replayReservedSale } from './sale-rehearsal.ts';
export type CustodyOptions = { feeBps: number; card(hash: string, owner: string, sequence: number): { title: string } | undefined; now?: () => number } & MarketPolicy;
export type CustodyOptions = { feeBps: number; card(hash: string, owner: string, sequence: number): { title: string } | undefined;
now?: () => number; disposableSaleRehearsal?: boolean } & 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 };
@@ -42,6 +44,9 @@ export class PostgresCustody {
}
static async open(config: PoolConfig, options: CustodyOptions): Promise<PostgresCustody> {
if (IS_MAINNET) throw new Error('Custody sandbox cannot run on mainnet');
if (options.disposableSaleRehearsal && (config.database !== 'custody_test' || process.env.WIKIDECK_PG_DISPOSABLE_TEST !== '1')) {
throw new Error('Sale rehearsal requires the explicitly isolated disposable database');
}
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);
@@ -51,6 +56,9 @@ export class PostgresCustody {
const c = await pool.connect();
let id: string;
try {
if (options.disposableSaleRehearsal && (await c.query('SELECT current_database() AS name')).rows[0].name !== 'custody_test') {
throw new Error('Sale rehearsal requires the explicitly isolated disposable database');
}
await c.query('BEGIN');
await c.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]);
await verifyCustodySchema(c);
@@ -255,6 +263,8 @@ export class PostgresCustody {
}
if (a.type === 'cancel-reservation') {
const o = await this.record<CustodyOrder>(c, 'orders', a.orderId);
check(!(await c.query('SELECT 1 FROM wk_custody.custody_meta WHERE key=$1', [`sale-rehearsal-binding:${o.id}`])).rowCount,
'Rehearsal sale authorization exists; cancellation requires replayed terminal evidence');
const intent = await c.query('SELECT status FROM wk_custody.outbox WHERE id=$1 FOR UPDATE', [o.id]);
check(o.buyer === actor && o.status === 'awaiting-lock' && intent.rows[0]?.status === 'blocked', 'Only an unstarted reservation can be cancelled by its buyer');
await this.post(c, `cancel:${o.id}`, 'purchase-cancellation', [{ account: reserved(o), debit: BigInt(o.grossAtomicXmr) }, { account: available(actor), credit: BigInt(o.grossAtomicXmr) }]);
@@ -280,6 +290,53 @@ export class PostgresCustody {
}
throw new CustodyError('Unsupported custody action');
}
/** Disposable database ONLY. Not wired to HTTP, configuration flags, live chain or a signer. */
observeSaleRehearsal(orderId: string, transcript: unknown) {
check(this.options.disposableSaleRehearsal === true, 'Sale rehearsal is unavailable on this custody instance');
return this.transaction(true, async c => {
check(!this.diagnostics().mutationHold, 'Custody reconciliation hold');
check(!(await c.query("SELECT 1 FROM wk_custody.custody_meta WHERE key='integrity_incident_v1'")).rowCount, 'Custody integrity incident unresolved');
const o = await this.record<CustodyOrder>(c, 'orders', orderId);
await auditOrder(c, o.id);
const l = await this.record<CustodyListing>(c, 'listings', o.listingId);
const replay = replayReservedSale(o, this.id, transcript, l.feeBps);
const key = `sale-rehearsal-binding:${o.id}`;
const old = await c.query('SELECT value FROM wk_custody.custody_meta WHERE key=$1', [key]);
if (old.rowCount) check(old.rows[0].value === replay.binding, 'Rehearsal authorization changed');
else {
check(o.status === 'awaiting-lock', 'Cannot bind an already resolved order to a rehearsal');
await c.query('INSERT INTO wk_custody.custody_meta VALUES($1,$2)', [key, replay.binding]);
}
if (replay.decision.action === 'review') throw new CustodyIntegrityError('sale-rehearsal-outcome-reorg');
// Retain the first signed observation plus terminal evidence, not a canonical event inbox.
// Runtime has INSERT but intentionally no UPDATE privilege on these metadata records.
const evidenceKey = `sale-rehearsal-observation:${o.id}`;
await c.query('INSERT INTO wk_custody.custody_meta VALUES($1,$2) ON CONFLICT(key) DO NOTHING', [evidenceKey,
JSON.stringify({ version: 1, binding: replay.binding, stateRoot: replay.stateRoot, input: replay.input })]);
if (replay.decision.action === 'settle' || replay.decision.action === 'refund') {
check(o.status === 'awaiting-lock', 'Rehearsal order already resolved');
if (replay.decision.action === 'settle') {
await this.post(c, `settle:${o.id}`, 'mock-sale-settlement', [
{ account: reserved(o), debit: BigInt(o.grossAtomicXmr) },
{ account: available(o.seller), credit: BigInt(o.sellerAtomicXmr) },
{ account: `author:${o.authorAddress}`, credit: BigInt(o.authorAtomicXmr) },
{ account: o.hostFeeAddress ? `revenue:host:${o.hostFeeAddress}` : 'revenue:host', credit: BigInt(o.hostAtomicXmr) }]);
o.status = 'settled'; l.status = 'sold';
await c.query("UPDATE wk_custody.outbox SET status='fixture-complete',data=$1 WHERE id=$2", [`fixture:${replay.stateRoot}`, o.id]);
} else {
await this.post(c, `cancel:${o.id}`, 'purchase-cancellation', [
{ account: reserved(o), debit: BigInt(o.grossAtomicXmr) }, { account: available(o.buyer), credit: BigInt(o.grossAtomicXmr) }]);
o.status = 'cancelled'; l.status = 'cancelled';
await c.query("UPDATE wk_custody.outbox SET status='cancelled' WHERE id=$1", [o.id]);
}
await this.update(c, 'orders', o); await this.update(c, 'listings', l);
await c.query('INSERT INTO wk_custody.custody_meta VALUES($1,$2)', [`sale-rehearsal-terminal:${o.id}`,
JSON.stringify({ version: 1, binding: replay.binding, stateRoot: replay.stateRoot, input: replay.input })]);
await auditOrder(c, o.id);
}
return { order: o, action: replay.decision.action, reservationDigest: replay.reservationDigest, stateRoot: replay.stateRoot };
});
}
audit(): Promise<CustodyAudit> {
if (this.auditInFlight) return this.auditInFlight;
this.auditState.lastAttemptAt = Date.now();
+84
View File
@@ -0,0 +1,84 @@
import { z } from 'zod';
import { ADDITIONAL_MARKET_FEE } from '../../shared/market.ts';
import type { CustodyOrder } from '../../shared/custody.ts';
import { saleTransactionSchema, saleTermsMessage } from '../../shared/sale-candidate.ts';
import { applySaleBlock, initialSaleState, saleContext, saleDecision, saleId, saleStateRoot,
termsForCustodyOrder, type SaleContext } from '../testnet/sale-candidate.ts';
import { sha256, verifySignature } from '../testnet/consensus.ts';
const height = z.number().int().min(0).max(1000000);
export const rehearsalSchema = z.object({
policy: z.object({ coinbaseMaturity: height.positive(), lockDepth: height.positive(), outcomeDepth: height.positive(),
maxLockBlocks: height.positive(), maxTransactions: z.number().int().min(1).max(64) }).strict(),
seed: z.object({ height, mintedHeight: height, pageId: height.positive() }).strict(),
lock: saleTransactionSchema.options[0],
blocks: z.array(z.array(saleTransactionSchema).max(64)).min(1).max(128),
}).strict();
export type SaleRehearsal = z.infer<typeof rehearsalSchema>;
// This is an internal JSON-only seam, but still bound traversal before Zod or serialization.
function checkEvidenceBudget(raw: unknown): void {
let nodes = 0, bytes = 0;
const seen = new WeakSet<object>();
const visit = (value: unknown, depth: number): void => {
if (++nodes > 32768 || depth > 12) throw new Error('Rehearsal evidence exceeds complexity budget');
if (typeof value === 'string') {
if (value.length > 256 * 1024) throw new Error('Rehearsal evidence exceeds budget');
bytes += Buffer.byteLength(value) + 2;
} else if (value !== null && typeof value === 'object') {
if (seen.has(value)) throw new Error('Rehearsal evidence must be acyclic JSON');
seen.add(value);
if (!Array.isArray(value) && ![Object.prototype, null].includes(Object.getPrototypeOf(value))) throw new Error('Rehearsal evidence must be JSON');
const keys = Object.keys(value);
if (keys.length > (Array.isArray(value) ? 128 : 64)) throw new Error('Rehearsal evidence exceeds collection budget');
for (const key of keys) {
bytes += Buffer.byteLength(key) + 4;
const property = Object.getOwnPropertyDescriptor(value, key)!;
if (!('value' in property)) throw new Error('Rehearsal evidence must not contain accessors');
visit(property.value, depth + 1);
}
seen.delete(value);
} else if (value !== undefined && value !== null && typeof value !== 'boolean' &&
!(typeof value === 'number' && Number.isFinite(value))) throw new Error('Rehearsal evidence must be JSON');
if (bytes > 256 * 1024) throw new Error('Rehearsal evidence exceeds budget');
};
visit(raw, 0);
}
/** Replays signed synthetic blocks from a caller-supplied seed, NOT a canonical-chain verifier.
* No RPC/PoW, Monero, clock, signing key or HTTP route. Limited to one actual sandbox reservation.
*/
export function replayReservedSale(order: CustodyOrder, custodianId: string, raw: unknown, listingFeeBps: number) {
checkEvidenceBudget(raw);
const input = rehearsalSchema.parse(raw), context: SaleContext = saleContext(input.policy);
if (Buffer.byteLength(JSON.stringify(input)) > 256 * 1024) throw new Error('Rehearsal evidence exceeds budget');
if (input.lock.type !== 'sale-lock') throw new Error('Rehearsal requires signed lock authorization');
const terms = input.lock.terms;
if (terms.feeBps !== listingFeeBps) throw new Error('Rehearsal fee rate does not match reserved listing');
const expected = termsForCustodyOrder({ ...order, status: 'awaiting-lock' }, context, { custodianId,
host: terms.host, feeBps: terms.feeBps, lockUntilHeight: terms.lockUntilHeight, expiresHeight: terms.expiresHeight });
if (saleTermsMessage(expected) !== saleTermsMessage(terms)) throw new Error('Rehearsal does not match reserved order terms');
if (input.seed.mintedHeight > input.seed.height) throw new Error('Invalid rehearsal issuance height');
const message = saleTermsMessage(terms);
if (!verifySignature(input.lock.sellerAuthorization.publicKey, order.seller, message, input.lock.sellerAuthorization.signature) ||
!verifySignature(input.lock.buyerAuthorization.publicKey, order.buyer, message, input.lock.buyerAuthorization.signature)) {
throw new Error('Rehearsal requires trader consent');
}
const binding = sha256(JSON.stringify(['custody-sale-rehearsal-v1', custodianId, message, input.seed, input.policy]));
const reservationDigest = sha256(JSON.stringify(['custody-reservation-rehearsal-v1', custodianId, order.id,
order.buyer, order.seller, order.cardHash, order.sequence, order.grossAtomicXmr,
order.sellerAtomicXmr, order.authorAtomicXmr, order.hostAtomicXmr, order.authorAddress,
order.hostFeeAddress ?? ADDITIONAL_MARKET_FEE.address, binding]));
let state = initialSaleState({ [order.cardHash]: { owner: order.seller, sequence: order.sequence,
mintedHeight: input.seed.mintedHeight, pageId: input.seed.pageId } }, input.seed.height);
for (const block of input.blocks) {
for (const tx of block) {
if (tx.type === 'sale-lock' ? saleId(tx.terms) !== saleId(terms) : tx.saleId !== saleId(terms)) throw new Error('Unrelated sale in rehearsal');
if (tx.type === 'sale-complete' && tx.reservationDigest !== reservationDigest) throw new Error('Completion reservation commitment mismatch');
}
state = applySaleBlock(state, block, context);
}
const previous = order.status === 'settled' ? 'settled' : order.status === 'cancelled' ? 'refunded' : 'reserved';
return { input, binding, reservationDigest, stateRoot: saleStateRoot(state, context),
decision: saleDecision(state, terms, context, previous), context };
}
+2 -4
View File
@@ -45,9 +45,6 @@ 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,
@@ -55,7 +52,8 @@ export function termsForCustodyOrder(order: CustodyOrder, context: SaleContext,
return saleTermsSchema.parse({ protocol: SALE_CANDIDATE, network: NETWORK, chainId: context.chainId,
policyDigest: context.policyDigest, custodianId: options.custodianId, orderId: order.id, cardHash: order.cardHash,
sequence: order.sequence, seller: order.seller, buyer: order.buyer, host: options.host,
priceAtomicXmr: order.grossAtomicXmr, feeBps: options.feeBps, lockUntilHeight: options.lockUntilHeight, expiresHeight: options.expiresHeight });
priceAtomicXmr: order.grossAtomicXmr, feeBps: options.feeBps, hostFeeAddress: order.hostFeeAddress,
lockUntilHeight: options.lockUntilHeight, expiresHeight: options.expiresHeight });
}
export function initialSaleState(nfts: NftState, height = 0): SaleState {
check(Number.isSafeInteger(height) && height >= 0, 'Invalid candidate height');