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

125 lines
3.5 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.

/**
* Exchange reputation scoring and badge calculation.
*
* Score formula (0-100):
* completionRate × 50 + (1 - disputeRate) × 25 + (1 - disputeLossRate) × 15 + confirmSpeed × 10
*
* Badges:
* verified_seller — 5+ completed trades, score ≥ 70
* liquidity_provider — has standing orders
* top_trader — ≥ $10k equivalent volume
*/
import type { ExchangeReputationRecord, ExchangeReputationDoc, ExchangeTradesDoc } from './schemas';
export const DEFAULT_REPUTATION: ExchangeReputationRecord = {
did: '',
tradesCompleted: 0,
tradesCancelled: 0,
disputesRaised: 0,
disputesLost: 0,
totalVolumeBase: 0,
avgConfirmTimeMs: 0,
score: 50,
badges: [],
};
/**
* Calculate reputation score from raw stats.
*/
export function calculateScore(rec: ExchangeReputationRecord): number {
const totalTrades = rec.tradesCompleted + rec.tradesCancelled;
if (totalTrades === 0) return 50; // neutral default
const completionRate = rec.tradesCompleted / totalTrades;
const totalDisputes = rec.disputesRaised;
const disputeRate = totalTrades > 0 ? totalDisputes / totalTrades : 0;
const disputeLossRate = totalDisputes > 0 ? rec.disputesLost / totalDisputes : 0;
// Confirm speed: normalize to 0-1 (faster = higher).
// Target: < 1hr = perfect, > 24hr = 0. avgConfirmTimeMs capped at 24h.
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));
const score =
completionRate * 50 +
(1 - disputeRate) * 25 +
(1 - disputeLossRate) * 15 +
speedScore * 10;
return Math.round(Math.max(0, Math.min(100, score)));
}
/**
* Compute badges based on reputation stats.
*/
export function computeBadges(
rec: ExchangeReputationRecord,
hasStandingOrders: boolean,
): string[] {
const badges: string[] = [];
if (rec.tradesCompleted >= 5 && rec.score >= 70) {
badges.push('verified_seller');
}
if (hasStandingOrders) {
badges.push('liquidity_provider');
}
// $10k volume threshold — base units with 6 decimals → 10_000 * 1_000_000
if (rec.totalVolumeBase >= 10_000_000_000) {
badges.push('top_trader');
}
return badges;
}
/**
* Update reputation for a DID after a completed trade.
*/
export function updateReputationAfterTrade(
rec: ExchangeReputationRecord,
tokenAmount: number,
confirmTimeMs: number,
): ExchangeReputationRecord {
const newCompleted = rec.tradesCompleted + 1;
const newVolume = rec.totalVolumeBase + tokenAmount;
// Running average of confirm time
const prevTotal = rec.avgConfirmTimeMs * rec.tradesCompleted;
const newAvg = newCompleted > 0 ? (prevTotal + confirmTimeMs) / newCompleted : 0;
const updated: ExchangeReputationRecord = {
...rec,
tradesCompleted: newCompleted,
totalVolumeBase: newVolume,
avgConfirmTimeMs: newAvg,
};
updated.score = calculateScore(updated);
return updated;
}
/**
* Get reputation for a DID from the doc, or return defaults.
*/
export function getReputation(did: string, doc: ExchangeReputationDoc): ExchangeReputationRecord {
return doc.records[did] || { ...DEFAULT_REPUTATION, did };
}
/**
* Check if a DID has standing orders in the intents doc.
*/
export function hasStandingOrders(
did: string,
intentsDoc: { intents: Record<string, { creatorDid: string; isStandingOrder: boolean; status: string }> },
): boolean {
return Object.values(intentsDoc.intents).some(
i => i.creatorDid === did && i.isStandingOrder && i.status === 'active',
);
}