import { z } from 'zod'; import { NETWORK } from '../../shared/network.ts'; import { ADDITIONAL_MARKET_FEE } from '../../shared/market.ts'; import { allocateXmrSale } from '../../shared/xmr-pricing.ts'; import { parseAtomicXmr } from '../../shared/xmr-amount.ts'; import { nftTransferMessage, nftTradeMessage, type NftState, type NftTransfer, type NftTrade } from '../../shared/testnet.ts'; import { SALE_CANDIDATE, saleTransactionSchema, saleTermsSchema, saleTermsMessage, saleCompletionMessage, saleCancellationMessage, type SaleTerms, type SaleTransaction } from '../../shared/sale-candidate.ts'; import { CHAIN_ID, sha256, verifySignature, transferSchema, tradeSchema } from './consensus.ts'; import type { CustodyOrder } from '../../shared/custody.ts'; const integer = z.number().int().min(1).max(1000000); const policySchema = z.object({ coinbaseMaturity: integer, lockDepth: integer, outcomeDepth: integer, maxLockBlocks: integer, maxTransactions: z.number().int().min(1).max(64) }).strict(); export type SalePolicy = z.infer; export type SaleContext = { chainId: string; policyDigest: string; policy: SalePolicy }; export type SaleRecord = { terms: SaleTerms; lockHeight: number; status: 'locked' | 'completed' | 'cancelled' | 'expired'; outcomeHeight?: number; reservationDigest?: string }; export type SaleState = { height: number; nfts: NftState; sales: Record; locks: Record; orders: Record }; export type CandidateTransaction = SaleTransaction | { type: 'native-transfer'; transfer: NftTransfer } | { type: 'native-trade'; trade: NftTrade }; const genericChain = z.string().regex(/^[a-f0-9]{64}$/); const candidateNativeTransfer = transferSchema.extend({ chainId: genericChain }); // Reuse the existing two-party signature/uniqueness checks, but NEVER the active chain ID. const candidateNativeTrade = tradeSchema.safeExtend({ chainId: genericChain }); const candidateTransactionSchema = z.union([saleTransactionSchema, z.object({ type: z.literal('native-transfer'), transfer: candidateNativeTransfer }).strict(), z.object({ type: z.literal('native-trade'), trade: candidateNativeTrade }).strict()]); function check(condition: unknown, message: string): asserts condition { if (!condition) throw new Error(message); } const orderKey = (t: SaleTerms) => `${t.custodianId}:${t.orderId}`; /** Explicit policy, no invented production thresholds. Digest creates an OFFLINE domain, not a new genesis. */ export function saleContext(raw: unknown): SaleContext { const policy = policySchema.parse(raw); check(policy.maxLockBlocks > policy.lockDepth, 'Lock window must exceed lock depth'); const policyDigest = sha256(JSON.stringify([SALE_CANDIDATE, policy.coinbaseMaturity, policy.lockDepth, policy.outcomeDepth, policy.maxLockBlocks, policy.maxTransactions, ADDITIONAL_MARKET_FEE.bps, ADDITIONAL_MARKET_FEE.address])); return { policy, policyDigest, chainId: sha256(JSON.stringify([SALE_CANDIDATE, NETWORK, CHAIN_ID, policyDigest])) }; } export function saleId(terms: SaleTerms): string { return sha256(saleTermsMessage(saleTermsSchema.parse(terms))); } /** Pure bridge from an actual reservation record to candidate terms; signs/submits/debits NOTHING. */ export function termsForCustodyOrder(order: CustodyOrder, context: SaleContext, options: { 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'); 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, 'Custody order allocations do not match candidate terms'); 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, 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'); return { height, nfts: Object.assign(Object.create(null), structuredClone(nfts)), sales: Object.create(null), locks: Object.create(null), orders: Object.create(null) }; } function advance(sequence: number): number { check(Number.isSafeInteger(sequence) && sequence < Number.MAX_SAFE_INTEGER, 'NFT sequence exhausted'); return sequence + 1; } /** Offline deterministic block transition; all changes roll back by discarding the clone on any error. * No HTTP caller, Ledger import, wallet RPC, time, mutable configuration or custody DB access here. */ export function applySaleBlock(parent: SaleState, raw: unknown[], context: SaleContext): SaleState { const canonicalContext = saleContext(context.policy); check(context.chainId === canonicalContext.chainId && context.policyDigest === canonicalContext.policyDigest && context.chainId !== CHAIN_ID, 'Invalid candidate context'); check(Number.isSafeInteger(parent.height) && parent.height < Number.MAX_SAFE_INTEGER, 'Candidate height exhausted'); check(Array.isArray(raw) && raw.length <= context.policy.maxTransactions, 'Candidate transaction budget exceeded'); const txs = raw.map(tx => candidateTransactionSchema.parse(tx)); const state = structuredClone(parent); state.height++; const seen = new Set(); for (const tx of txs) { const identity = candidateIdentity(tx); check(!seen.has(identity), 'Duplicate candidate transaction'); seen.add(identity); if (tx.type === 'native-transfer' || tx.type === 'native-trade') { applyNative(state, tx, context); continue; } if (tx.type === 'sale-lock') { const t = tx.terms, id = saleId(t), nft = state.nfts[t.cardHash]; check(t.chainId === context.chainId && t.policyDigest === context.policyDigest, 'Sale context mismatch'); check(t.seller !== t.buyer && t.host !== t.seller && t.host !== t.buyer, 'Host and trading identities must be separate'); check(nft && nft.owner === t.seller && nft.sequence === t.sequence && !state.locks[t.cardHash], 'Card missing, locked, or stale'); check(nft.sequence <= Number.MAX_SAFE_INTEGER - 2, 'NFT sequence exhausted for lock and terminal outcome'); check(state.height - nft.mintedHeight >= context.policy.coinbaseMaturity, 'Immature sale card'); check(state.height <= t.lockUntilHeight && t.lockUntilHeight + context.policy.lockDepth < t.expiresHeight && Number.isSafeInteger(t.lockUntilHeight + context.policy.lockDepth) && t.expiresHeight - state.height <= context.policy.maxLockBlocks, 'Invalid lock height window'); check(!state.sales[id] && !state.orders[orderKey(t)], 'Sale/order authorization already used'); const message = saleTermsMessage(t); check(verifySignature(tx.sellerAuthorization.publicKey, t.seller, message, tx.sellerAuthorization.signature) && verifySignature(tx.buyerAuthorization.publicKey, t.buyer, message, tx.buyerAuthorization.signature), 'Sale lock requires both trader signatures'); // Sequence changes at lock AND terminal outcome invalidate previously signed ordinary transfers. state.nfts[t.cardHash] = { ...nft, sequence: advance(nft.sequence) }; state.sales[id] = { terms: t, lockHeight: state.height, status: 'locked' }; state.locks[t.cardHash] = id; state.orders[orderKey(t)] = id; continue; } check(tx.chainId === context.chainId, 'Sale context mismatch'); const sale = state.sales[tx.saleId]; check(sale?.status === 'locked' && state.locks[sale.terms.cardHash] === tx.saleId, 'No active matching sale lock'); const t = sale.terms, nft = state.nfts[t.cardHash]; check(nft.owner === t.seller && nft.sequence === t.sequence + 1, 'Sale ownership/sequence mismatch'); if (tx.type === 'sale-complete') { check(state.height < t.expiresHeight && state.height - sale.lockHeight >= context.policy.lockDepth, 'Sale completion outside mature lock window'); check(verifySignature(tx.hostAuthorization.publicKey, t.host, saleCompletionMessage(tx), tx.hostAuthorization.signature), 'Invalid funding host authorization'); sale.status = 'completed'; sale.reservationDigest = tx.reservationDigest; state.nfts[t.cardHash] = { ...nft, owner: t.buyer, sequence: advance(nft.sequence) }; } else if (tx.type === 'sale-cancel') { check(state.height < t.expiresHeight, 'Use permissionless expiry at or after expiry height'); const message = saleCancellationMessage(tx); check(verifySignature(tx.hostAuthorization.publicKey, t.host, message, tx.hostAuthorization.signature) && verifySignature(tx.buyerAuthorization.publicKey, t.buyer, message, tx.buyerAuthorization.signature), 'Cancellation requires host and buyer authorization'); sale.status = 'cancelled'; state.nfts[t.cardHash] = { ...nft, sequence: advance(nft.sequence) }; } else { check(state.height >= t.expiresHeight, 'Sale has not expired'); sale.status = 'expired'; state.nfts[t.cardHash] = { ...nft, sequence: advance(nft.sequence) }; } sale.outcomeHeight = state.height; delete state.locks[t.cardHash]; } return state; } /** Candidate transaction IDs exclude malleable signature bytes, matching the active chain convention. * These IDs are an unactivated candidate format and are NOT accepted by the existing Ledger schema. */ export function saleCandidateTransactionId(tx: SaleTransaction): string { if (tx.type === 'sale-lock') return sha256(JSON.stringify([SALE_CANDIDATE, 'transaction', 'lock', saleTermsMessage(tx.terms)])); if (tx.type === 'sale-complete') return sha256(JSON.stringify([SALE_CANDIDATE, 'transaction', 'complete', saleCompletionMessage(tx)])); if (tx.type === 'sale-cancel') return sha256(JSON.stringify([SALE_CANDIDATE, 'transaction', 'cancel', saleCancellationMessage(tx)])); return sha256(JSON.stringify([SALE_CANDIDATE, 'transaction', 'expire', tx.chainId, tx.saleId])); } function candidateIdentity(tx: CandidateTransaction): string { if (tx.type === 'sale-lock' || tx.type === 'sale-complete' || tx.type === 'sale-cancel' || tx.type === 'sale-expire') { return saleCandidateTransactionId(tx); } return sha256(tx.type === 'native-transfer' ? nftTransferMessage(tx.transfer) : nftTradeMessage(tx.trade)); } function applyNative(state: SaleState, tx: Extract, context: SaleContext) { const native = tx.type === 'native-transfer' ? tx.transfer : tx.trade; check(native.chainId === context.chainId, 'Native candidate context mismatch'); const legs = tx.type === 'native-transfer' ? [{ cardHash: tx.transfer.cardHash, sequence: tx.transfer.sequence, from: tx.transfer.from, to: tx.transfer.to }] : [ ...tx.trade.offered.map(l => ({ ...l, from: tx.trade.proposer, to: tx.trade.counterparty })), ...tx.trade.requested.map(l => ({ ...l, from: tx.trade.counterparty, to: tx.trade.proposer }))]; for (const l of legs) { const nft = state.nfts[l.cardHash]; check(nft && !state.locks[l.cardHash] && nft.owner === l.from && nft.sequence === l.sequence && l.from !== l.to, 'Native candidate card locked, missing or stale'); check(state.height - nft.mintedHeight >= context.policy.coinbaseMaturity, 'Immature native card'); } if (tx.type === 'native-transfer') check(verifySignature(tx.transfer.publicKey, tx.transfer.from, nftTransferMessage(tx.transfer), tx.transfer.signature), 'Invalid native transfer signature'); // Trade signatures/unique cards have already been checked by candidateNativeTrade. for (const l of legs) state.nfts[l.cardHash] = { ...state.nfts[l.cardHash], owner: l.to, sequence: advance(l.sequence) }; } export type SaleDecision = { action: 'hold' | 'settle' | 'refund' | 'review' | 'none'; reason: string }; /** Pure financial observation policy: recommendation ONLY, not authority to post money or a canonical-chain proof. */ export function saleDecision(state: SaleState, terms: SaleTerms, context: SaleContext, previous: 'reserved' | 'settled' | 'refunded' | 'review' = 'reserved'): SaleDecision { check(terms.chainId === context.chainId && terms.policyDigest === context.policyDigest, 'Observation context mismatch'); const sale = state.sales[saleId(terms)]; const mature = sale?.outcomeHeight !== undefined && state.height - sale.outcomeHeight + 1 >= context.policy.outcomeDepth; if (previous === 'review') return { action: 'review', reason: 'Existing incident requires evidence-backed resolution' }; if (previous === 'settled' || previous === 'refunded') { const expected = previous === 'settled' ? sale?.status === 'completed' : sale?.status === 'cancelled' || sale?.status === 'expired' || (!sale && state.height - terms.lockUntilHeight >= context.policy.outcomeDepth); return expected && (sale ? mature : true) ? { action: 'none', reason: 'Already posted; never duplicate' } : { action: 'review', reason: 'Previously posted outcome lost canonical maturity' }; } if (!sale) return state.height - terms.lockUntilHeight >= context.policy.outcomeDepth ? { action: 'refund', reason: 'Lock admission deadline is mature without canonical lock' } : { action: 'hold', reason: 'A withheld lock authorization can still be included' }; if (!mature) return { action: 'hold', reason: 'Lock or terminal outcome is not mature' }; return sale.status === 'completed' ? { action: 'settle', reason: 'Canonical completion reached configured depth' } : { action: 'refund', reason: 'Canonical cancellation/expiry reached configured depth' }; } /** Candidate commitment includes locks AND permanent order-use tombstones, not only current NFT ownership. */ export function saleStateRoot(state: SaleState, context: SaleContext): string { return sha256(JSON.stringify([SALE_CANDIDATE, 'state', context.chainId, state.height, Object.keys(state.nfts).sort().map(id => { const n = state.nfts[id]; return [id, n.owner, n.sequence, n.mintedHeight, n.pageId]; }), Object.keys(state.sales).sort().map(id => { const s = state.sales[id]; return [id, saleTermsMessage(s.terms), s.lockHeight, s.status, s.outcomeHeight ?? null, s.reservationDigest ?? null]; }), Object.keys(state.locks).sort().map(id => [id, state.locks[id]]), Object.keys(state.orders).sort().map(id => [id, state.orders[id]])])); }