From 84a505c4a536f899acc3ac53c782fce3cc98668f Mon Sep 17 00:00:00 2001 From: Eric Meehan Date: Tue, 20 Oct 2026 15:38:37 -0400 Subject: [PATCH] NFT sale-lock protocol --- docs/production-plan.md | 2 + docs/sale-lock-candidate.md | 76 ++++++++++ server/testnet/sale-candidate.ts | 185 +++++++++++++++++++++++ shared/sale-candidate.ts | 41 ++++++ tests/sale-candidate.test.ts | 246 +++++++++++++++++++++++++++++++ 5 files changed, 550 insertions(+) create mode 100755 docs/sale-lock-candidate.md create mode 100755 server/testnet/sale-candidate.ts create mode 100755 shared/sale-candidate.ts create mode 100755 tests/sale-candidate.test.ts diff --git a/docs/production-plan.md b/docs/production-plan.md index e008957..073a173 100755 --- a/docs/production-plan.md +++ b/docs/production-plan.md @@ -80,6 +80,8 @@ Acceptance: indexed-vs-full-replay differential tests; adversarial reorg/crash/c Blocked on P0 ADR; use P1 amount types and P2 durable events. Consensus work is a separately reviewed change. +**Candidate increment, 2026-10-20:** [ADR 004 — sale locks](sale-lock-candidate.md) now specifies and implements an offline deterministic signed lock/completion/cancel/expiry model, exact height boundaries, native transfer/trade lock guards, replay-resistant sequences, state commitments and a non-posting financial observation policy. A pure bridge checks terms drafted from actual sandbox reservations. Final build and 162 offline tests passed. Active v6 consensus still rejects the candidate and no host signer or balance-posting integration is enabled. Independent review, policy approval, versioned network activation, actual PoW/peer/index integration and PostgreSQL terminal workflow remain open; M3 is not accepted. + - If a reviewed trustless design is selected: implement its actual cross-chain validity/evidence rules, compatible cryptographic primitives, both-party failure/refund paths and commission semantics. Run maintained reference vectors, negative tests, fuzz/property tests and specialist audit before claiming atomicity. - If a custodial/attested fallback is explicitly approved: define chain-enforced NFT lock, order identity, authorized releaser, cancellation/refund/expiry and stale-signature rules. Separate host control keys from seller wallet and processor payout keys. Reserve/refund procedures must not permit both buyer refund and later seller delivery/release. Describe who can steal, censor or lock assets and how recovery is authorized. - Completed-sale inclusion must require the approved evidence that payment is sufficiently confirmed. A peer must not insert a completed sale merely because its local database says paid. Define threshold/policy version, proof/attestation authorization and deterministic validation; no external HTTP calls in consensus validation. diff --git a/docs/sale-lock-candidate.md b/docs/sale-lock-candidate.md new file mode 100755 index 0000000..b5e2b24 --- /dev/null +++ b/docs/sale-lock-candidate.md @@ -0,0 +1,76 @@ +# ADR 004 — Custodial NFT sale-lock candidate + +Status: **offline executable candidate; NOT activated or independently reviewed**. Evidence: 2026-10-20. Related: [production plan](production-plan.md), [custodial model](custodial-market-design.md), [PostgreSQL storage](postgres-financial-storage.md). + +## Purpose and activation boundary + +Specify the missing asset-control rules before enabling real custodial purchases. An ordinary signed NFT transfer can survive a website refund deadline; balance reservations alone cannot prevent that late delivery. This candidate couples sale authorization to explicit canonical locks and terminal outcomes. + +[Signed schemas/messages](../shared/sale-candidate.ts) and [deterministic state machine](../server/testnet/sale-candidate.ts) are deliberately separate from active consensus. The existing transaction schema rejects all candidate transactions. The candidate domain derives from the existing chain identity, explicit policy and candidate version; it is an offline signature domain, **not a reviewed network manifest/genesis**. No current chain identity, miner, peer endpoint, database balance or custody signing capability is changed. + +The pure `termsForCustodyOrder()` bridge drafts terms from an actual awaiting-lock reservation and checks exact seller/author/host allocations. It does not sign, persist, relay or debit anything. Tests exercise this bridge against the current custody engine. Public custody orders continue stopping at `awaiting-lock`. + +## Signed authorization + +Terms bind candidate protocol, network, chain/policy digest, custodian/order IDs, exact card/sequence, seller, buyer, host signing identity, native-XMR gross, hosting rate, fixed author policy, lock-admission deadline and final expiry height. Independent downward commission rounding remains unchanged. No production threshold is invented: maturity/depth/window limits are explicit policy inputs committed by the candidate digest. + +- **Lock:** both seller and buyer sign the complete terms. Host signing identity must differ from both trading identities. Distinct keys are role separation, not proof of independent humans/operators. +- **Completion:** the terms' exact host signs the sale ID plus a reservation digest, attesting that sufficiently confirmed/unlocked funds were reserved under the approved policy. Host cannot select another card/recipient/price. Validators would verify the host's authorization, **not Monero truth**. The reservation digest is an accounting commitment, not a cryptographic proof of backing or solvency. +- **Cancellation:** host and buyer sign the sale ID. Neither can unilaterally cancel early. Seller already consented to this branch in signed lock terms; seller retains card ownership on cancellation. +- **Expiry:** permissionless explicit transaction, without a private key, once the canonical expiry height is reached. Website clocks/RPC errors never cause implicit release. + +## State transitions and exact heights + +Let $h$ be the block executing a transaction, $L$ the accepted lock height, $A$ the last lock-admission height, $E$ the expiry height, $d_L$ the lock-depth policy and $d_O$ the outcome-depth policy. + +| Transition | Validity / result | +| --- | --- | +| Unlocked → locked | Both signatures, mature current seller ownership/sequence, no existing lock, unused custodian/order; $h\le A$, $A+d_L; +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, 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; +} +function candidateIdentity(tx: CandidateTransaction): string { + if (tx.type === 'sale-lock') return `lock:${saleId(tx.terms)}`; + if (tx.type === 'sale-complete') return sha256(saleCompletionMessage(tx)); + if (tx.type === 'sale-cancel') return sha256(saleCancellationMessage(tx)); + if (tx.type === 'sale-expire') return `expire:${tx.chainId}:${tx.saleId}`; + 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]])])); +} diff --git a/shared/sale-candidate.ts b/shared/sale-candidate.ts new file mode 100755 index 0000000..fb36723 --- /dev/null +++ b/shared/sale-candidate.ts @@ -0,0 +1,41 @@ +import { z } from 'zod'; +import { NETWORK } from './network.ts'; +import { ADDITIONAL_MARKET_FEE } from './market.ts'; +import { parseAtomicXmr } from './xmr-amount.ts'; + +/** REVIEW CANDIDATE ONLY. Never accepted by the active transactionSchema or custody HTTP API. */ +export const SALE_CANDIDATE = 'custodial-sale-lock-candidate-v1'; +const hex = z.string().regex(/^[a-f0-9]{64}$/); +const height = z.number().int().nonnegative().safe(); +const amount = z.string().max(22).refine(s => { try { return parseAtomicXmr(s) > 0n; } catch { return false; } }); +export const saleTermsSchema = z.object({ protocol: z.literal(SALE_CANDIDATE), network: z.literal(NETWORK), + chainId: hex, policyDigest: hex, custodianId: z.string().uuid(), orderId: z.string().uuid(), + cardHash: hex, sequence: height, seller: hex, buyer: hex, host: hex, + priceAtomicXmr: amount, feeBps: z.number().int().min(0).max(5000), + lockUntilHeight: height, expiresHeight: height }).strict(); +export type SaleTerms = z.infer; +export const saleSignatureSchema = z.object({ publicKey: z.string().min(1).max(256), + signature: z.string().regex(/^[A-Za-z0-9+/]+={0,2}$/).length(88) }).strict(); +const reference = { protocol: z.literal(SALE_CANDIDATE), chainId: hex, saleId: hex }; +export const saleTransactionSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('sale-lock'), terms: saleTermsSchema, + sellerAuthorization: saleSignatureSchema, buyerAuthorization: saleSignatureSchema }).strict(), + z.object({ type: z.literal('sale-complete'), ...reference, reservationDigest: hex, + hostAuthorization: saleSignatureSchema }).strict(), + z.object({ type: z.literal('sale-cancel'), ...reference, + hostAuthorization: saleSignatureSchema, buyerAuthorization: saleSignatureSchema }).strict(), + z.object({ type: z.literal('sale-expire'), ...reference }).strict(), +]); +export type SaleTransaction = z.infer; +export function saleTermsMessage(t: SaleTerms): string { + return JSON.stringify([SALE_CANDIDATE, 'terms', t.network, t.chainId, t.policyDigest, + t.custodianId, t.orderId, t.cardHash, t.sequence, t.seller, t.buyer, t.host, + 'XMR', t.priceAtomicXmr, t.feeBps, ADDITIONAL_MARKET_FEE.bps, ADDITIONAL_MARKET_FEE.address, + t.lockUntilHeight, t.expiresHeight]); +} +export function saleCompletionMessage(t: Pick, 'chainId' | 'saleId' | 'reservationDigest'>): string { + return JSON.stringify([SALE_CANDIDATE, 'complete-funded-reservation', t.chainId, t.saleId, t.reservationDigest]); +} +export function saleCancellationMessage(t: { chainId: string; saleId: string }): string { + return JSON.stringify([SALE_CANDIDATE, 'cancel', t.chainId, t.saleId]); +} diff --git a/tests/sale-candidate.test.ts b/tests/sale-candidate.test.ts new file mode 100755 index 0000000..c3aff18 --- /dev/null +++ b/tests/sale-candidate.test.ts @@ -0,0 +1,246 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { generateKeyPairSync, sign, randomUUID } from 'node:crypto'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { NETWORK, nftTransferMessage, nftTradeMessage, type NftTransfer, type NftTrade } from '../shared/testnet.ts'; +import { SALE_CANDIDATE, saleTermsMessage, saleCompletionMessage, saleCancellationMessage, + saleTermsSchema, type SaleTerms, type SaleTransaction } from '../shared/sale-candidate.ts'; +import { applySaleBlock, initialSaleState, saleContext, saleDecision, saleId, saleStateRoot, termsForCustodyOrder, + type SaleState, type CandidateTransaction } from '../server/testnet/sale-candidate.ts'; +import { address, transactionSchema, CHAIN_ID } from '../server/testnet/consensus.ts'; +import { CustodyMarket } from '../server/market/custody.ts'; +import { CUSTODY_POLICY, custodyMessage, type CustodyCommand, type CustodyAction, type CustodyOrder, type CustodyListing } from '../shared/custody.ts'; + +function identity() { + const k = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }); + const publicKey = k.publicKey.export({ type: 'spki', format: 'der' }).toString('base64'); + const owner = address(publicKey); + return { owner, authorization: (message: string) => ({ publicKey, signature: sign('sha256', Buffer.from(message), + { key: k.privateKey, dsaEncoding: 'ieee-p1363' }).toString('base64') }) }; +} +const seller = identity(), buyer = identity(), host = identity(), attacker = identity(); +const card = 'a'.repeat(64), second = 'b'.repeat(64); +// These heights are deliberately tiny test parameters, NOT production confirmation-policy approval. +const context = saleContext({ coinbaseMaturity: 2, lockDepth: 2, outcomeDepth: 3, maxLockBlocks: 30, maxTransactions: 64 }); +function fixture(overrides: Partial = {}) { + const terms: SaleTerms = { protocol: SALE_CANDIDATE, network: NETWORK, chainId: context.chainId, + policyDigest: context.policyDigest, custodianId: randomUUID(), orderId: randomUUID(), cardHash: card, + sequence: 0, seller: seller.owner, buyer: buyer.owner, host: host.owner, priceAtomicXmr: '100000000000', + feeBps: 250, lockUntilHeight: 12, expiresHeight: 20, ...overrides }; + const state = initialSaleState({ [card]: { owner: seller.owner, sequence: 0, mintedHeight: 1, pageId: 1 }, + [second]: { owner: buyer.owner, sequence: 0, mintedHeight: 1, pageId: 2 } }, 9); + const lock: SaleTransaction = { type: 'sale-lock', terms, sellerAuthorization: seller.authorization(saleTermsMessage(terms)), + buyerAuthorization: buyer.authorization(saleTermsMessage(terms)) }; + const ref: { protocol: typeof SALE_CANDIDATE; chainId: string; saleId: string } = { + protocol: SALE_CANDIDATE, chainId: context.chainId, saleId: saleId(terms) }; + const completion = { ...ref, reservationDigest: 'f'.repeat(64) }; + const complete: SaleTransaction = { type: 'sale-complete', ...completion, hostAuthorization: host.authorization(saleCompletionMessage(completion)) }; + const cancel: SaleTransaction = { type: 'sale-cancel', ...ref, hostAuthorization: host.authorization(saleCancellationMessage(ref)), + buyerAuthorization: buyer.authorization(saleCancellationMessage(ref)) }; + const expire: SaleTransaction = { type: 'sale-expire', ...ref }; + return { terms, state, lock, complete, cancel, expire }; +} +const apply = (s: SaleState, txs: unknown[] = []) => applySaleBlock(s, txs, context); +function at(s: SaleState, height: number) { while (s.height < height) s = apply(s); return s; } +function gift(sequence: number): CandidateTransaction { + const terms = { network: NETWORK, chainId: context.chainId, cardHash: card, from: seller.owner, to: attacker.owner, sequence }; + const signature = seller.authorization(nftTransferMessage(terms)); + return { type: 'native-transfer', transfer: { ...terms, ...signature } }; +} +function bundle(sequence: number): CandidateTransaction { + const terms = { type: 'trade' as const, network: NETWORK, chainId: context.chainId, proposer: seller.owner, counterparty: buyer.owner, + offered: [{ cardHash: card, sequence }], requested: [{ cardHash: second, sequence: 0 }] }; + const a = seller.authorization(nftTradeMessage(terms)), b = buyer.authorization(nftTradeMessage(terms)); + return { type: 'native-trade', trade: { ...terms, proposerPublicKey: a.publicKey, proposerSignature: a.signature, + counterpartyPublicKey: b.publicKey, counterpartySignature: b.signature } }; +} + +test('sale candidate is domain separated and all candidate transactions remain rejected by active consensus', () => { + const f = fixture(); assert.notEqual(context.chainId, CHAIN_ID); + for (const tx of [f.lock, f.complete, f.cancel, f.expire]) assert.equal(transactionSchema.safeParse(tx).success, false); + const transfer = gift(0) as { type: 'native-transfer'; transfer: NftTransfer }; + const trade = bundle(0) as { type: 'native-trade'; trade: NftTrade }; + assert.equal(transactionSchema.safeParse(transfer.transfer).success, false); + assert.equal(transactionSchema.safeParse(trade.trade).success, false); + assert.throws(() => applySaleBlock(f.state, [f.lock], { ...context, chainId: CHAIN_ID }), /context/); + assert.equal(saleTermsSchema.safeParse({ ...f.terms, unexpected: true }).success, false); + assert.equal(saleTermsSchema.safeParse({ ...f.terms, priceAtomicXmr: '1.0' }).success, false); +}); + +test('lock requires both parties, commits all economic/context/window fields and never changes owner', () => { + const f = fixture(), locked = apply(f.state, [f.lock]); + assert.equal(locked.nfts[card].owner, seller.owner); assert.equal(locked.nfts[card].sequence, 1); + assert.equal(locked.locks[card], saleId(f.terms)); assert.equal(f.state.nfts[card].sequence, 0); + assert.throws(() => apply(f.state, [{ ...f.lock, buyerAuthorization: attacker.authorization(saleTermsMessage(f.terms)) }]), /both trader/); + for (const patch of [{ orderId: randomUUID() }, { custodianId: randomUUID() }, { priceAtomicXmr: '1' }, + { feeBps: 500 }, { lockUntilHeight: 13 }, { expiresHeight: 21 }, { buyer: attacker.owner }, + { host: attacker.owner }, { sequence: 1 }, { policyDigest: 'c'.repeat(64) }]) { + assert.throws(() => apply(f.state, [{ ...f.lock, terms: { ...f.terms, ...patch } }])); + } + const changedPolicy = saleContext({ ...context.policy, outcomeDepth: 4 }); + assert.notEqual(changedPolicy.chainId, context.chainId); + assert.notEqual(saleId({ ...f.terms, custodianId: randomUUID() }), saleId(f.terms)); +}); + +test('host cannot redirect delivery or alter signed reservation evidence, and completion waits for lock depth', () => { + const f = fixture(), locked = apply(f.state, [f.lock]); + assert.throws(() => apply(locked, [f.complete]), /mature lock window/); + const mature = at(locked, 11); + assert.throws(() => apply(mature, [{ ...f.complete, reservationDigest: 'e'.repeat(64) }]), /host authorization/); + assert.throws(() => apply(mature, [{ ...f.complete, hostAuthorization: attacker.authorization(saleCompletionMessage(f.complete as Extract)) }]), /host authorization/); + assert.throws(() => apply(mature, [{ ...f.complete, to: attacker.owner }])); + const done = apply(mature, [f.complete]); + assert.equal(done.nfts[card].owner, buyer.owner); assert.equal(done.nfts[card].sequence, 2); + assert.equal(done.sales[saleId(f.terms)].reservationDigest, 'f'.repeat(64)); + assert.equal(saleDecision(done, f.terms, context).action, 'hold'); + assert.equal(saleDecision(at(done, 14), f.terms, context).action, 'settle'); + assert.equal(saleDecision(at(done, 14), f.terms, context, 'settled').action, 'none'); +}); + +test('locked cards cannot escape through free transfers or any leg of an atomic trade', () => { + const f = fixture(), locked = apply(f.state, [f.lock]); + assert.throws(() => apply(locked, [gift(1)]), /locked/); + assert.throws(() => apply(locked, [bundle(1)]), /locked/); + assert.equal(apply(f.state, [gift(0)]).nfts[card].owner, attacker.owner); + const trade = apply(f.state, [bundle(0)]); + assert.equal(trade.nfts[card].owner, buyer.owner); assert.equal(trade.nfts[second].owner, seller.owner); + // A gift before lock invalidates the seller's stale lock authorization. + assert.throws(() => apply(f.state, [gift(0), f.lock]), /stale/); +}); + +test('cancellation needs buyer and host, releases only after canonical depth and invalidates all old signatures', () => { + const f = fixture(), locked = apply(f.state, [f.lock]); + assert.throws(() => apply(locked, [{ ...f.cancel, buyerAuthorization: attacker.authorization(saleCancellationMessage(f.cancel as Extract)) }]), /host and buyer/); + const cancelled = apply(locked, [f.cancel]); + assert.equal(cancelled.nfts[card].owner, seller.owner); assert.equal(cancelled.nfts[card].sequence, 2); + assert.equal(saleDecision(cancelled, f.terms, context).action, 'hold'); + assert.equal(saleDecision(at(cancelled, 13), f.terms, context).action, 'refund'); + assert.throws(() => apply(cancelled, [f.complete]), /No active/); + assert.throws(() => apply(cancelled, [f.lock]), /stale/); + assert.throws(() => apply(cancelled, [gift(0)]), /stale/); + assert.throws(() => apply(cancelled, [gift(1)]), /stale/); + assert.equal(apply(cancelled, [gift(2)]).nfts[card].owner, attacker.owner); +}); + +test('expiry is permissionless exactly at expiry height, but never a website-clock-only release', () => { + const f = fixture(), locked = apply(f.state, [f.lock]); + assert.throws(() => apply(locked, [f.expire]), /not expired/); + const before = at(locked, 19); + assert.throws(() => apply(before, [f.complete]), /outside/); + assert.throws(() => apply(before, [f.cancel]), /permissionless expiry/); + assert.throws(() => apply(before, [gift(1)]), /locked/); // Explicit expiry transaction required, no implicit timer. + const expired = apply(before, [f.expire]); + assert.equal(expired.nfts[card].owner, seller.owner); assert.equal(expired.nfts[card].sequence, 2); + assert.equal(saleDecision(expired, f.terms, context).action, 'hold'); + assert.equal(saleDecision(at(expired, 22), f.terms, context).action, 'refund'); + const last = apply(at(locked, 18), [f.complete]); assert.equal(last.sales[saleId(f.terms)].status, 'completed'); +}); + +test('withheld lock has a strict admission deadline and missing lock refunds wait beyond that deadline', () => { + const f = fixture(); + assert.equal(saleDecision(at(f.state, 12), f.terms, context).action, 'hold'); + assert.equal(apply(at(f.state, 11), [f.lock]).sales[saleId(f.terms)].lockHeight, 12); + assert.throws(() => apply(at(f.state, 12), [f.lock]), /height window/); + assert.equal(saleDecision(at(f.state, 14), f.terms, context).action, 'hold'); + assert.equal(saleDecision(at(f.state, 15), f.terms, context).action, 'refund'); +}); + +test('one canonical outcome wins: cancel/complete race rejects the whole invalid block, never partially updates state', () => { + const f = fixture(), ready = at(apply(f.state, [f.lock]), 11), prior = structuredClone(ready); + assert.throws(() => apply(ready, [f.complete, f.cancel]), /No active/); + assert.throws(() => apply(ready, [f.cancel, f.complete]), /No active/); + assert.deepEqual(ready, prior); + assert.throws(() => apply(ready, [f.complete, f.complete]), /Duplicate/); + const completed = apply(ready, [f.complete]), cancelled = apply(ready, [f.cancel]); + assert.equal(completed.sales[saleId(f.terms)].status, 'completed'); + assert.equal(cancelled.sales[saleId(f.terms)].status, 'cancelled'); + // Reorg to a sibling outcome after a financial posting must quarantine rather than pay/refund again. + assert.equal(saleDecision(at(cancelled, 14), f.terms, context, 'settled').action, 'review'); + assert.equal(saleDecision(at(completed, 14), f.terms, context, 'refunded').action, 'review'); + assert.equal(saleDecision(at(completed, 14), f.terms, context, 'review').action, 'review'); +}); + +test('cross-market competing locks are exclusive and cancelled order IDs cannot be recycled under fresh terms', () => { + const f = fixture(), otherMarket = fixture({ custodianId: randomUUID() }); + const locked = apply(f.state, [f.lock]); + assert.throws(() => apply(locked, [otherMarket.lock]), /locked/); + const cancelled = apply(locked, [f.cancel]); + const replay = fixture({ ...f.terms, sequence: 2, priceAtomicXmr: '2' }); + assert.throws(() => apply(cancelled, [replay.lock]), /already used/); + const nextOrder = fixture({ sequence: 2 }); + assert.equal(apply(cancelled, [nextOrder.lock]).nfts[card].sequence, 3); +}); + +test('policy/height bounds, immature issuance, sequence exhaustion and transaction limits reject safely', () => { + const f = fixture(); + assert.throws(() => saleContext({ ...context.policy, maxLockBlocks: 1 }), /window/); + assert.throws(() => apply(f.state, Array(65).fill(f.lock)), /budget/); + assert.throws(() => applySaleBlock({ ...f.state, height: Number.MAX_SAFE_INTEGER }, [], context), /exhausted/); + const tooEarly = initialSaleState(f.state.nfts, 1); assert.throws(() => apply(tooEarly, [f.lock]), /Immature/); + for (const sequence of [Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER - 1]) { + const huge = fixture({ sequence }); huge.state.nfts[card].sequence = sequence; + assert.throws(() => apply(huge.state, [huge.lock]), /exhausted/); + } + assert.throws(() => apply(f.state, [fixture({ expiresHeight: 100 }).lock]), /window/); +}); + +test('candidate drafts from real sandbox reservations without signing, changing balances or activating NFT delivery', async t => { + const dir = await mkdtemp(join(tmpdir(), 'wikideck-sale-draft-')); + const market = await CustodyMarket.open(dir, { feeBps: 250, card: () => ({ title: 'Draft test' }) }); + t.after(async () => { market.close(); await rm(dir, { recursive: true, force: true }); }); + const execute = (who: typeof buyer, action: CustodyAction) => { + const unsigned: Omit = { version: 1, mode: 'sandbox', policy: CUSTODY_POLICY, network: NETWORK, + chainId: CHAIN_ID, custodianId: market.id, actor: who.owner, publicKey: '', nonce: randomUUID(), expiresAt: Date.now() + 60000, action }; + const publicKey = who.authorization('lookup').publicKey; unsigned.publicKey = publicKey; + return market.execute({ ...unsigned, signature: who.authorization(custodyMessage(unsigned)).signature }); + }; + execute(buyer, { type: 'mock-credit' }); + const listing = execute(seller, { type: 'list', cardHash: card, sequence: 0, priceAtomicXmr: '1', feeBps: 250 }) as CustodyListing; + const order = execute(buyer, { type: 'reserve', listingId: listing.id, priceAtomicXmr: '1', feeBps: 250 }) as CustodyOrder; + const before = market.audit(), options = { custodianId: market.id, host: host.owner, feeBps: 250, lockUntilHeight: 12, expiresHeight: 20 }; + const terms = termsForCustodyOrder(order, context, options); + assert.equal(terms.priceAtomicXmr, '1'); assert.equal(terms.orderId, order.id); + assert.equal(terms.custodianId, market.id); assert.deepEqual(market.audit(), before); + assert.equal(market.info().nftSettlement, false); + assert.throws(() => termsForCustodyOrder({ ...order, authorAtomicXmr: '1' }, context, options), /allocations/); + assert.throws(() => termsForCustodyOrder({ ...order, status: 'cancelled' }, context, options), /awaiting-lock/); +}); + +test('enumerated adversarial histories preserve exclusivity and deterministic replay across snapshot restores', () => { + const f = fixture(), start = apply(f.state, [f.lock]); + let explored = 0; + const explore = (state: SaleState, depth: number) => { + if (depth === 0) return; + const choices = [[], [f.complete], [f.cancel], [f.expire], [gift(state.nfts[card].sequence)], [bundle(state.nfts[card].sequence)]]; + for (const txs of choices) { + const before = structuredClone(state); + try { + const next = apply(state, txs); + assert.deepEqual(next, apply(JSON.parse(JSON.stringify(state)), txs)); + const sale = next.sales[saleId(f.terms)]; + if (sale.status === 'locked') assert.equal(next.nfts[card].owner, seller.owner); + assert.equal(!!next.locks[card], sale.status === 'locked'); + explored++; explore(next, depth - 1); + } catch (e) { + if (e instanceof assert.AssertionError) throw e; + } + assert.deepEqual(state, before); + } + }; + // Reach the exact expiry boundary too; short horizons mostly contain correctly rejected actions. + explore(start, 12); assert.ok(explored > 100, `Explored only ${explored} accepted transitions`); +}); + +test('candidate state commitment binds ownership, lock authority, terminal outcomes and order-use tombstones', () => { + const f = fixture(), state = apply(f.state, [f.lock]), root = saleStateRoot(state, context); + assert.equal(root, saleStateRoot(JSON.parse(JSON.stringify(state)), context)); + for (const mutate of [ + (s: SaleState) => { s.nfts[card].sequence++; }, + (s: SaleState) => { s.sales[saleId(f.terms)].terms.host = attacker.owner; }, + (s: SaleState) => { s.sales[saleId(f.terms)].status = 'cancelled'; }, + (s: SaleState) => { delete s.locks[card]; }, + (s: SaleState) => { s.orders = {}; }, + ]) { const copy = structuredClone(state); mutate(copy); assert.notEqual(saleStateRoot(copy, context), root); } +});