/** * P2P Exchange Settlement — escrow lifecycle with saga rollback. * * Escrow mechanism (reuses token-service): * 1. Lock: burnTokensEscrow(tokenId, sellerDid, amount, 'p2p-'+tradeId) — seller's tokens escrowed * 2. Release: confirmBurn(tokenId, 'p2p-'+tradeId) + mintTokens(tokenId, buyerDid, amount) — net supply neutral * 3. Reverse: reverseBurn(tokenId, 'p2p-'+tradeId) — seller gets tokens back * * Timeout sweep: trades with status=fiat_sent past fiatConfirmDeadline → auto reverseBurn. * Disputes: admin calls resolve with resolution direction. */ import type { SyncServer } from '../../server/local-first/sync-server'; import { burnTokensEscrow, confirmBurn, reverseBurn, mintTokens, getTokenDoc, getBalance, } from '../../server/token-service'; import { exchangeIntentsDocId, exchangeTradesDocId, exchangeReputationDocId, } from './schemas'; import type { ExchangeIntentsDoc, ExchangeTradesDoc, ExchangeReputationDoc, ExchangeTrade, TradeStatus, } from './schemas'; import { updateReputationAfterTrade, calculateScore, computeBadges, hasStandingOrders } from './exchange-reputation'; // ── Constants ── const FIAT_CONFIRM_TIMEOUT_MS = 24 * 60 * 60 * 1000; // 24 hours // ── Escrow Lock ── export interface LockResult { success: boolean; error?: string; } /** * Lock seller's tokens in escrow for a trade. */ export function lockEscrow( trade: ExchangeTrade, syncServer: SyncServer, space: string, ): LockResult { const doc = getTokenDoc(trade.tokenId); if (!doc) return { success: false, error: `Token ${trade.tokenId} not found` }; // Check seller balance const balance = getBalance(doc, trade.sellerDid); if (balance < trade.tokenAmount) { return { success: false, error: `Insufficient balance: ${balance} < ${trade.tokenAmount}` }; } const offRampId = `p2p-${trade.id}`; const success = burnTokensEscrow( trade.tokenId, trade.sellerDid, trade.sellerName, trade.tokenAmount, offRampId, `P2P escrow: ${trade.tokenAmount} ${trade.tokenId} for trade ${trade.id}`, ); if (!success) { return { success: false, error: 'Failed to create escrow entry' }; } // Update trade with escrow reference syncServer.changeDoc( exchangeTradesDocId(space), 'lock escrow', (d) => { if (d.trades[trade.id]) { d.trades[trade.id].escrowTxId = offRampId; d.trades[trade.id].status = 'escrow_locked' as any; d.trades[trade.id].fiatConfirmDeadline = (Date.now() + FIAT_CONFIRM_TIMEOUT_MS) as any; } }, ); return { success: true }; } // ── Release (buyer confirmed fiat receipt by seller) ── export interface ReleaseResult { success: boolean; error?: string; } /** * Release escrowed tokens to buyer after seller confirms fiat receipt. */ export function releaseEscrow( trade: ExchangeTrade, syncServer: SyncServer, space: string, ): ReleaseResult { if (!trade.escrowTxId) { return { success: false, error: 'No escrow reference on trade' }; } // Confirm the burn (marks original burn as confirmed) const burnOk = confirmBurn(trade.tokenId, trade.escrowTxId); if (!burnOk) { return { success: false, error: 'Failed to confirm escrow burn' }; } // Mint equivalent tokens to buyer (net supply neutral) const mintOk = mintTokens( trade.tokenId, trade.buyerDid, trade.buyerName, trade.tokenAmount, `P2P exchange: received from ${trade.sellerName} (trade ${trade.id})`, 'rexchange', ); if (!mintOk) { // Rollback: reverse the confirmed burn reverseBurn(trade.tokenId, trade.escrowTxId); return { success: false, error: 'Failed to mint tokens to buyer' }; } const now = Date.now(); // Update trade status syncServer.changeDoc( exchangeTradesDocId(space), 'release escrow — trade completed', (d) => { if (d.trades[trade.id]) { d.trades[trade.id].status = 'completed' as any; d.trades[trade.id].completedAt = now as any; } }, ); // Update intent statuses updateIntentsAfterTrade(trade, syncServer, space); // Update reputation for both parties const confirmTime = trade.fiatConfirmDeadline ? FIAT_CONFIRM_TIMEOUT_MS - (trade.fiatConfirmDeadline - now) : 0; updateReputationForTrade(trade, confirmTime, syncServer, space); return { success: true }; } // ── Reverse (timeout or dispute resolution) ── /** * Reverse escrow — return tokens to seller. */ export function reverseEscrow( trade: ExchangeTrade, reason: TradeStatus, syncServer: SyncServer, space: string, ): boolean { if (!trade.escrowTxId) return false; const ok = reverseBurn(trade.tokenId, trade.escrowTxId); if (!ok) return false; syncServer.changeDoc( exchangeTradesDocId(space), `reverse escrow — ${reason}`, (d) => { if (d.trades[trade.id]) { d.trades[trade.id].status = reason as any; } }, ); // Re-activate intents if not standing orders reactivateIntents(trade, syncServer, space); return true; } // ── Dispute resolution ── /** * Resolve a disputed trade. Admin decides: release to buyer or return to seller. */ export function resolveDispute( trade: ExchangeTrade, resolution: 'released_to_buyer' | 'returned_to_seller', syncServer: SyncServer, space: string, ): boolean { if (trade.status !== 'disputed') return false; syncServer.changeDoc( exchangeTradesDocId(space), `resolve dispute — ${resolution}`, (d) => { if (d.trades[trade.id]) { d.trades[trade.id].resolution = resolution as any; } }, ); if (resolution === 'released_to_buyer') { const result = releaseEscrow(trade, syncServer, space); if (!result.success) return false; // Loser of dispute = seller updateDisputeLoser(trade.sellerDid, syncServer, space); } else { const ok = reverseEscrow(trade, 'resolved', syncServer, space); if (!ok) return false; // Loser of dispute = buyer updateDisputeLoser(trade.buyerDid, syncServer, space); } return true; } // ── Timeout sweep ── /** * Check for timed-out trades and reverse their escrows. * Called periodically by the solver cron. */ export function sweepTimeouts(syncServer: SyncServer, space: string): number { const tradesDoc = syncServer.getDoc(exchangeTradesDocId(space)); if (!tradesDoc) return 0; const now = Date.now(); let reversed = 0; for (const trade of Object.values(tradesDoc.trades)) { if ( trade.status === 'fiat_sent' && trade.fiatConfirmDeadline && now > trade.fiatConfirmDeadline ) { const ok = reverseEscrow(trade, 'timed_out', syncServer, space); if (ok) { reversed++; console.log(`[rExchange] Trade ${trade.id} timed out — escrow reversed`); } } } return reversed; } // ── Helpers ── function updateIntentsAfterTrade( trade: ExchangeTrade, syncServer: SyncServer, space: string, ): void { syncServer.changeDoc( exchangeIntentsDocId(space), 'update intents after trade completion', (d) => { const buyIntent = d.intents[trade.buyIntentId]; const sellIntent = d.intents[trade.sellIntentId]; if (buyIntent) { if (buyIntent.isStandingOrder) { // Standing order: reduce range and re-activate const newMin = Math.max(0, buyIntent.tokenAmountMin - trade.tokenAmount); const newMax = Math.max(0, buyIntent.tokenAmountMax - trade.tokenAmount); if (newMax > 0) { buyIntent.tokenAmountMin = newMin as any; buyIntent.tokenAmountMax = newMax as any; buyIntent.status = 'active' as any; } else { buyIntent.status = 'completed' as any; } } else { buyIntent.status = 'completed' as any; } } if (sellIntent) { if (sellIntent.isStandingOrder) { const newMin = Math.max(0, sellIntent.tokenAmountMin - trade.tokenAmount); const newMax = Math.max(0, sellIntent.tokenAmountMax - trade.tokenAmount); if (newMax > 0) { sellIntent.tokenAmountMin = newMin as any; sellIntent.tokenAmountMax = newMax as any; sellIntent.status = 'active' as any; } else { sellIntent.status = 'completed' as any; } } else { sellIntent.status = 'completed' as any; } } }, ); } function reactivateIntents( trade: ExchangeTrade, syncServer: SyncServer, space: string, ): void { syncServer.changeDoc( exchangeIntentsDocId(space), 'reactivate intents after trade reversal', (d) => { const buyIntent = d.intents[trade.buyIntentId]; const sellIntent = d.intents[trade.sellIntentId]; if (buyIntent && buyIntent.status === 'matched') buyIntent.status = 'active' as any; if (sellIntent && sellIntent.status === 'matched') sellIntent.status = 'active' as any; }, ); } function updateReputationForTrade( trade: ExchangeTrade, confirmTimeMs: number, syncServer: SyncServer, space: string, ): void { const repDocId = exchangeReputationDocId(space); syncServer.changeDoc(repDocId, 'update reputation after trade', (d) => { for (const did of [trade.buyerDid, trade.sellerDid]) { if (!d.records[did]) { d.records[did] = { did, tradesCompleted: 0, tradesCancelled: 0, disputesRaised: 0, disputesLost: 0, totalVolumeBase: 0, avgConfirmTimeMs: 0, score: 50, badges: [], } as any; } const rec = d.records[did]; const newCompleted = rec.tradesCompleted + 1; const prevTotal = rec.avgConfirmTimeMs * rec.tradesCompleted; const newAvg = newCompleted > 0 ? (prevTotal + confirmTimeMs) / newCompleted : 0; rec.tradesCompleted = newCompleted as any; rec.totalVolumeBase = (rec.totalVolumeBase + trade.tokenAmount) as any; rec.avgConfirmTimeMs = newAvg as any; // Recalculate score inline (can't call external fn inside Automerge mutator with complex logic) const totalTrades = rec.tradesCompleted + rec.tradesCancelled; const completionRate = totalTrades > 0 ? rec.tradesCompleted / totalTrades : 0.5; const disputeRate = totalTrades > 0 ? rec.disputesRaised / totalTrades : 0; const disputeLossRate = rec.disputesRaised > 0 ? rec.disputesLost / rec.disputesRaised : 0; const oneHour = 3600_000; const twentyFourHours = 86400_000; const speedScore = rec.avgConfirmTimeMs <= 0 ? 0.5 : rec.avgConfirmTimeMs <= oneHour ? 1.0 : Math.max(0, 1 - (rec.avgConfirmTimeMs - oneHour) / (twentyFourHours - oneHour)); rec.score = Math.round(Math.max(0, Math.min(100, completionRate * 50 + (1 - disputeRate) * 25 + (1 - disputeLossRate) * 15 + speedScore * 10, ))) as any; // Badges const badges: string[] = []; if (rec.tradesCompleted >= 5 && rec.score >= 70) badges.push('verified_seller'); if (rec.totalVolumeBase >= 10_000_000_000) badges.push('top_trader'); rec.badges = badges as any; } }); } function updateDisputeLoser( loserDid: string, syncServer: SyncServer, space: string, ): void { syncServer.changeDoc( exchangeReputationDocId(space), 'update dispute loser', (d) => { if (d.records[loserDid]) { d.records[loserDid].disputesLost = (d.records[loserDid].disputesLost + 1) as any; } }, ); }