rspace-online/modules/rexchange/exchange-settlement.ts

391 lines
11 KiB
TypeScript

/**
* 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<ExchangeTradesDoc>(
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<ExchangeTradesDoc>(
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<ExchangeTradesDoc>(
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<ExchangeTradesDoc>(
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<ExchangeTradesDoc>(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<ExchangeIntentsDoc>(
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<ExchangeIntentsDoc>(
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<ExchangeReputationDoc>(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<ExchangeReputationDoc>(
exchangeReputationDocId(space),
'update dispute loser',
(d) => {
if (d.records[loserDid]) {
d.records[loserDid].disputesLost = (d.records[loserDid].disputesLost + 1) as any;
}
},
);
}