NFT sale-lock protocol
This commit is contained in:
Executable
+185
@@ -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]])]));
|
||||
}
|
||||
Reference in New Issue
Block a user