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; // 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(); 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 }; }