Files
wikideck/server/market/sale-rehearsal.ts
T

88 lines
6.0 KiB
TypeScript
Executable File

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,
saleCandidateTransactionId, 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);
let lockTransactionId: string | undefined, terminalTransactionId: string | undefined;
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');
if (tx.type === 'sale-lock') lockTransactionId = saleCandidateTransactionId(tx);
else terminalTransactionId = saleCandidateTransactionId(tx);
}
state = applySaleBlock(state, block, context);
}
const previous = order.status === 'settled' ? 'settled' : order.status === 'cancelled' ? 'refunded' : 'reserved';
return { input, binding, reservationDigest, lockTransactionId, terminalTransactionId, stateRoot: saleStateRoot(state, context),
decision: saleDecision(state, terms, context, previous), context };
}