222 lines
7.2 KiB
TypeScript
222 lines
7.2 KiB
TypeScript
/**
|
||
* 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<Omit<ExchangeTrade, 'id' | 'createdAt' | 'chatMessages'>[]> {
|
||
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<string, { buys: ExchangeIntent[]; sells: ExchangeIntent[] }>();
|
||
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<string>();
|
||
const results: Omit<ExchangeTrade, 'id' | 'createdAt' | 'chatMessages'>[] = [];
|
||
|
||
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,
|
||
};
|
||
}
|