/** * P2P Exchange Matching Engine — bipartite intent matching. * * Matches buy intents (want crypto, have fiat) with sell intents (have crypto, want fiat). * * Edge criteria (buy B ↔ sell S): * 1. Same tokenId + fiatCurrency * 2. Rate overlap (for market_plus_bps, evaluate against cached CoinGecko rate) * 3. Amount overlap: min(B.max, S.max) >= max(B.min, S.min) * 4. Reputation VPs satisfied both ways * * Scoring: 0.4×rateMutualness + 0.3×amountBalance + 0.2×avgReputation + 0.1×lpPriority */ import type { ExchangeIntent, ExchangeIntentsDoc, ExchangeReputationDoc, ExchangeTrade, TokenId, FiatCurrency, } from './schemas'; import { getReputation } from './exchange-reputation'; import { getExchangeRate } from './exchange-rates'; // ── Config ── const TOP_K = 20; const W_RATE = 0.4; const W_AMOUNT = 0.3; const W_REPUTATION = 0.2; const W_LP = 0.1; // ── Types ── interface Match { buyIntent: ExchangeIntent; sellIntent: ExchangeIntent; agreedAmount: number; // token base units agreedRate: number; // fiat per token fiatAmount: number; paymentMethod: string; score: number; } // ── Rate resolution ── /** Resolve the effective rate (fiat per token) for an intent, given the market rate. */ function resolveRate(intent: ExchangeIntent, marketRate: number): number { if (intent.rateType === 'fixed' && intent.rateFixed != null) { return intent.rateFixed; } if (intent.rateType === 'market_plus_bps' && intent.rateMarketBps != null) { const bps = intent.rateMarketBps; const direction = intent.side === 'sell' ? 1 : -1; // seller adds spread, buyer subtracts return marketRate * (1 + direction * bps / 10000); } return marketRate; } // ── Solver ── /** * Run the matching engine on active intents. * Returns proposed matches sorted by score. */ export async function solveExchange( intentsDoc: ExchangeIntentsDoc, reputationDoc: ExchangeReputationDoc, ): Promise[]> { const intents = Object.values(intentsDoc.intents).filter(i => i.status === 'active'); const buys = intents.filter(i => i.side === 'buy'); const sells = intents.filter(i => i.side === 'sell'); if (buys.length === 0 || sells.length === 0) return []; // Group by tokenId+fiatCurrency for efficiency const pairGroups = new Map(); for (const b of buys) { const key = `${b.tokenId}:${b.fiatCurrency}`; if (!pairGroups.has(key)) pairGroups.set(key, { buys: [], sells: [] }); pairGroups.get(key)!.buys.push(b); } for (const s of sells) { const key = `${s.tokenId}:${s.fiatCurrency}`; if (!pairGroups.has(key)) pairGroups.set(key, { buys: [], sells: [] }); pairGroups.get(key)!.sells.push(s); } const allMatches: Match[] = []; for (const [pairKey, group] of pairGroups) { if (group.buys.length === 0 || group.sells.length === 0) continue; const [tokenId, fiatCurrency] = pairKey.split(':') as [TokenId, FiatCurrency]; const marketRate = await getExchangeRate(tokenId, fiatCurrency); for (const buy of group.buys) { for (const sell of group.sells) { // Don't match same user if (buy.creatorDid === sell.creatorDid) continue; const match = evaluateMatch(buy, sell, marketRate, reputationDoc); if (match) allMatches.push(match); } } } // Sort by score descending, take top K allMatches.sort((a, b) => b.score - a.score); // Deduplicate: each intent can appear in at most one match (greedy) const usedIntents = new Set(); const results: Omit[] = []; for (const match of allMatches) { if (results.length >= TOP_K) break; if (usedIntents.has(match.buyIntent.id) || usedIntents.has(match.sellIntent.id)) continue; usedIntents.add(match.buyIntent.id); usedIntents.add(match.sellIntent.id); results.push({ buyIntentId: match.buyIntent.id, sellIntentId: match.sellIntent.id, buyerDid: match.buyIntent.creatorDid, buyerName: match.buyIntent.creatorName, sellerDid: match.sellIntent.creatorDid, sellerName: match.sellIntent.creatorName, tokenId: match.buyIntent.tokenId, tokenAmount: match.agreedAmount, fiatCurrency: match.buyIntent.fiatCurrency, fiatAmount: match.fiatAmount, agreedRate: match.agreedRate, paymentMethod: match.paymentMethod, status: 'proposed', acceptances: { [match.buyIntent.creatorDid]: match.buyIntent.autoAccept, [match.sellIntent.creatorDid]: match.sellIntent.autoAccept, }, }); } return results; } /** * Evaluate a single buy/sell pair. Returns a Match if compatible, null otherwise. */ function evaluateMatch( buy: ExchangeIntent, sell: ExchangeIntent, marketRate: number, reputationDoc: ExchangeReputationDoc, ): Match | null { // 1. Rate overlap const buyRate = resolveRate(buy, marketRate); // max rate buyer will pay const sellRate = resolveRate(sell, marketRate); // min rate seller will accept // Buyer's rate is the ceiling, seller's rate is the floor // For a match, buyer must be willing to pay >= seller's ask if (buyRate < sellRate) return null; const agreedRate = (buyRate + sellRate) / 2; // midpoint // 2. Amount overlap const overlapMin = Math.max(buy.tokenAmountMin, sell.tokenAmountMin); const overlapMax = Math.min(buy.tokenAmountMax, sell.tokenAmountMax); if (overlapMax < overlapMin) return null; const agreedAmount = overlapMax; // fill as much as possible // 3. Reputation VPs const buyerRep = getReputation(buy.creatorDid, reputationDoc); const sellerRep = getReputation(sell.creatorDid, reputationDoc); if (sell.minCounterpartyReputation != null && buyerRep.score < sell.minCounterpartyReputation) return null; if (buy.minCounterpartyReputation != null && sellerRep.score < buy.minCounterpartyReputation) return null; // Preferred counterparties (if set, counterparty must be in list) if (buy.preferredCounterparties?.length && !buy.preferredCounterparties.includes(sell.creatorDid)) return null; if (sell.preferredCounterparties?.length && !sell.preferredCounterparties.includes(buy.creatorDid)) return null; // 4. Payment method overlap const commonMethods = buy.paymentMethods.filter(m => sell.paymentMethods.includes(m)); if (commonMethods.length === 0) return null; // 5. Scoring // Rate mutualness: how much slack between buyer's max and seller's min (0 = barely, 1 = generous) const rateSlack = marketRate > 0 ? Math.min(1, (buyRate - sellRate) / (marketRate * 0.05)) // normalize to 5% of market : 0.5; // Amount balance: how well the agreed amount fills both sides const buyFill = agreedAmount / buy.tokenAmountMax; const sellFill = agreedAmount / sell.tokenAmountMax; const amountBalance = (buyFill + sellFill) / 2; // Reputation average (0-1) const avgRep = ((buyerRep.score + sellerRep.score) / 2) / 100; // LP priority: standing orders get a boost const lpBoost = (buy.isStandingOrder || sell.isStandingOrder) ? 1.0 : 0.0; const score = Number(( W_RATE * rateSlack + W_AMOUNT * amountBalance + W_REPUTATION * avgRep + W_LP * lpBoost ).toFixed(4)); const fiatAmount = (agreedAmount / 1_000_000) * agreedRate; return { buyIntent: buy, sellIntent: sell, agreedAmount, agreedRate, fiatAmount, paymentMethod: commonMethods[0], score, }; }