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

443 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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, exchangePoolsDocId,
} from './schemas';
import type {
ExchangeIntentsDoc, ExchangeTradesDoc, ExchangeReputationDoc, ExchangePoolsDoc,
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);
// Track LP fee accrual if trade matched an LP position
updateLpPositionAfterTrade(trade, 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;
}
});
}
/**
* After a trade completes, check if either intent belongs to an LP position.
* If so, update fee earnings and remaining amounts on the position.
*/
function updateLpPositionAfterTrade(
trade: ExchangeTrade,
syncServer: SyncServer,
space: string,
): void {
const poolsDocId = exchangePoolsDocId(space);
const poolsDoc = syncServer.getDoc<ExchangePoolsDoc>(poolsDocId);
if (!poolsDoc) return;
// Find position that owns either the buy or sell intent
const position = Object.values(poolsDoc.positions).find(
p => p.buyIntentId === trade.buyIntentId || p.sellIntentId === trade.sellIntentId,
);
if (!position || position.status !== 'active') return;
syncServer.changeDoc<ExchangePoolsDoc>(poolsDocId, 'LP fee accrual after trade', (d) => {
const p = d.positions[position.id];
if (!p) return;
p.tradesMatched = (p.tradesMatched + 1) as any;
p.updatedAt = Date.now() as any;
// Determine which side was filled
if (position.sellIntentId === trade.sellIntentId) {
// LP sold tokens → decrement tokenRemaining, earn fiat fee
p.tokenRemaining = Math.max(0, p.tokenRemaining - trade.tokenAmount) as any;
// Fee ≈ spread portion of the fiat amount: (spreadBps/10000) × fiatAmount
const fiatFee = (p.spreadBps / 10000) * trade.fiatAmount;
p.feesEarnedFiat = (p.feesEarnedFiat + fiatFee) as any;
}
if (position.buyIntentId === trade.buyIntentId) {
// LP bought tokens → decrement fiatRemaining, earn token fee
p.fiatRemaining = Math.max(0, p.fiatRemaining - trade.fiatAmount) as any;
// Fee ≈ spread portion of the token amount: (spreadBps/10000) × tokenAmount
const tokenFee = Math.round((p.spreadBps / 10000) * trade.tokenAmount);
p.feesEarnedToken = (p.feesEarnedToken + tokenFee) as any;
}
// If either side depleted, pause the position
if (p.tokenRemaining <= 0 || p.fiatRemaining <= 0) {
p.status = 'paused' 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;
}
},
);
}