443 lines
13 KiB
TypeScript
443 lines
13 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, 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;
|
||
}
|
||
},
|
||
);
|
||
}
|