391 lines
11 KiB
TypeScript
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;
|
|
}
|
|
},
|
|
);
|
|
}
|