NFT sale-lock protocol

This commit is contained in:
2026-10-20 15:38:37 -04:00
parent c254c4c537
commit 84a505c4a5
5 changed files with 550 additions and 0 deletions
+2
View File
@@ -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.
+76
View File
@@ -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<E$, and bounded remaining window. Ownership unchanged; sequence advances once. |
| Locked → completed | Exact host signature, $h-L\ge d_L$ and $h<E$. Ownership moves only to the terms' buyer; sequence advances again. |
| Locked → cancelled | Host+buyer signatures and $h<E$. Ownership remains seller; sequence advances again. |
| Locked → expired | Explicit permissionless transaction with $h\ge E$. Ownership remains seller; sequence advances again. |
| Free transfer/trade | Every affected NFT must be unlocked, correctly owned/sequenced, mature and signed. A locked card in any trade leg rejects the entire transition. |
Only one terminal branch can succeed on a canonical history. An invalid transaction rejects the entire candidate block, not just that transaction; two conflicting outcomes in one block therefore reject the block. Block assembly/mempool policy will eventually need to filter these conflicts before proposing a block.
Lock and terminal transitions both consume sequence numbers, invalidating ordinary transfers signed before/during a lock. Admission requires room for **two** increments so terminal release cannot overflow. Expired/cancelled order-use tombstones prevent a fresh signature from recycling the same custodian/order ID. A new order can relist at the fresh sequence. A withheld lock authorization cannot be accepted after $A$, closing the never-submitted-lock refund hazard.
Candidate state commitment includes NFT entries, active locks, full signed-term messages, lock/terminal heights, reservation commitments and permanent order-use tombstones. Ownership alone would be an insufficient state commitment. Snapshot restore/replay is deterministic. This uses full cloned state and tombstone retention for an auditable model, **not a production-optimized overlay or storage design**.
## Financial observation and reorg discipline
`saleDecision()` is a pure non-posting recommendation based on a caller-supplied canonical candidate state:
- Keep the gross reserved while a lock could still be admitted, while it is active, or before a terminal outcome reaches $h-\text{outcomeHeight}+1\ge d_O$.
- Recommend seller/commission settlement only after mature canonical completion.
- Recommend buyer release only after mature canonical cancellation/expiry. If no lock was ever canonical, require $h-A\ge d_O$ before recommending release.
- Already posted outcomes never post twice. A conflicting or immature/missing previously posted outcome requires `review`, not another settlement/refund. Existing review remains review pending evidence-backed resolution.
This does not independently prove the supplied canonical state or observe intermediate forks. Integration must consume the existing durable connect/disconnect stream and retain a review hold even if an outcome reappears before polling. Deep reorgs can invalidate previously mature results and external XMR withdrawals cannot be undone. Loss/reserve/compensation procedures remain mandatory.
An already mature terminal record remains usable evidence if the NFT is later gifted/traded; current owner alone must not erase completed sale evidence. Conversely, a gift is not payment evidence. Cancel/expiry can be followed by a freshly signed free transfer at the new sequence; this is ordinary owner authority, not resurrection of the cancelled sale.
## Failure behavior and trust
| Failure | Candidate behavior / remaining risk |
| --- | --- |
| Seller lists elsewhere or withholds lock | Competing canonical transfer/lock wins by ordinary sequence rules; funds remain reserved until safe cancellation/non-admission boundary. No cross-host database claim is treated as a chain lock. |
| Buyer disappears after signing lock | Host may complete only to the signed buyer; otherwise eventual expiry restores seller control. Financial operator still owes customer funds. |
| Host disappears or loses key | Anyone can submit expiry after the height boundary. A stalled chain delays expiry; this does not recover custodial XMR keys. |
| Host lies about funding | Valid host signature can authorize delivery despite absent reserves. This is explicit custody/attestation trust; the protocol cannot enforce the honesty of an internal balance claim. |
| Cancel and complete race | Only canonical sequence/lock state chooses a winner; no refund before terminal evidence matures. Mempool order is not settlement. |
| Reorg after posting or withdrawal | Review/quarantine, never imaginary reversal of XMR. Operator loss handling and funded limits needed. |
| Host key compromise/rotation | Existing locks bind an exact key. Rotation/revocation and emergency authority are NOT implemented; do not silently redirect existing authority. |
## Evidence and next release gates
TypeScript/Vite Docker build passed. **162 offline tests passed, zero failures/skips**, including 13 candidate tests in [tests/sale-candidate.test.ts](../tests/sale-candidate.test.ts). Coverage includes current-consensus rejection, field/signature tampering, exact height boundaries, sequence exhaustion, native transfer/trade lock bypass, competing-market/reused-order locks, atomic conflicting-outcome rejection, reorg financial decisions, deterministic snapshot restore, state commitments, actual custody-order drafting and enumerated adversarial histories through expiry. One initial enumeration-count expectation was corrected by extending its horizon through the expiry boundary; no protocol guarantee was inferred from an arbitrary test count.
**This does not close M3.** Required next steps:
1. Independent protocol/consensus review and operator approval of authority, cancellation and numeric policy parameters; final specification and test vectors.
2. Versioned network/manifest and explicit migration/activation strategy. No enabling the candidate under the existing v6 chain identity.
3. Integrate new transaction encodings/state commitments with mint/transfer/trade validation, canonical SQL undo/indexes/events, actual RandomX blocks, peer sync, restart and multi-host fork/partition tests. The current model supplies no PoW, issuance or fork-choice implementation.
4. Durable PostgreSQL sale witness/terms storage, financially reserved host-signing workflow, outbox fencing, canonical observer/inbox, atomic terminal postings and reorg holds. Existing sandbox signatures do not authorize the new sale protocol. No signing before final exact order consent.
5. Publication/review UI, key management/rotation, privacy and public-price disclosure, window/griefing/storage limits and operational recovery. Public terms would reveal sale linkage and amounts; privacy acceptance is a product decision.
6. Monero confirmation/unlock observation, real treasury controls, coordinated financial/node recovery and independently reviewed production release gates. No live wallet, funds or credentials were used in this increment.
+185
View File
@@ -0,0 +1,185 @@
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, 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;
}
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<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]])]));
}
+41
View File
@@ -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<typeof saleTermsSchema>;
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<typeof saleTransactionSchema>;
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<Extract<SaleTransaction, { type: 'sale-complete' }>, '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]);
}
+246
View File
@@ -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<SaleTerms> = {}) {
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<SaleTransaction, { type: 'sale-complete' }>)) }]), /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<SaleTransaction, { type: 'sale-cancel' }>)) }]), /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<CustodyCommand, 'signature'> = { 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); }
});