Files
wikideck/server/testnet/sale-candidate.ts
T

195 lines
15 KiB
TypeScript
Executable File

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<typeof policySchema>;
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<string, SaleRecord>;
locks: Record<string, string>; orders: Record<string, string> };
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<string>();
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<CandidateTransaction, { type: 'native-transfer' | 'native-trade' }>, 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]])]));
}