diff --git a/backend/.env.example b/backend/.env.example index 1d255ae0..6b85f08a 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -34,6 +34,30 @@ INDEXER_START_LEDGER= # Credit score recompute schedule (cron expression for BullMQ repeatable job) CREDIT_RECOMPUTE_CRON=0 */6 * * * + +# Credit score weights and configuration (optional — defaults provided) +# Weights as percentages (must sum to <= 100) +CREDIT_SCORE_WEIGHT_BASE=40 +CREDIT_SCORE_WEIGHT_TIP=20 +CREDIT_SCORE_WEIGHT_X=30 +CREDIT_SCORE_WEIGHT_AGE=10 + +# Divisors for converting raw signals to scores +CREDIT_SCORE_DIVISOR_TIP=10000000 +CREDIT_SCORE_DIVISOR_FOLLOWER=50 +CREDIT_SCORE_DIVISOR_ENGAGEMENT=10 +CREDIT_SCORE_DIVISOR_AGE=10 + +# Caps for sub-scores +CREDIT_SCORE_CAP_BASE=40 +CREDIT_SCORE_CAP_MAX=100 +CREDIT_SCORE_CAP_X_SUB=50 +CREDIT_SCORE_CAP_AGE_SUB=100 +CREDIT_SCORE_CAP_TIP_SUB=100 + +# Cache TTL for credit scores in seconds +CREDIT_SCORE_CACHE_TTL_SECONDS=300 + # Withdrawals WITHDRAWAL_MIN_AMOUNT_STROOPS=10000000 diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index 5c14e850..ccc89108 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -41,6 +41,24 @@ const envSchema = z.object({ INDEXER_START_LEDGER: z.coerce.number().optional(), CREDIT_RECOMPUTE_CRON: z.string().default('0 */6 * * *'), + /** Credit score weights (must sum to <= 100) */ + CREDIT_SCORE_WEIGHT_BASE: z.coerce.number().int().min(0).max(100).optional(), + CREDIT_SCORE_WEIGHT_TIP: z.coerce.number().int().min(0).max(100).optional(), + CREDIT_SCORE_WEIGHT_X: z.coerce.number().int().min(0).max(100).optional(), + CREDIT_SCORE_WEIGHT_AGE: z.coerce.number().int().min(0).max(100).optional(), + /** Credit score divisors */ + CREDIT_SCORE_DIVISOR_TIP: z.coerce.number().int().positive().optional(), + CREDIT_SCORE_DIVISOR_FOLLOWER: z.coerce.number().int().positive().optional(), + CREDIT_SCORE_DIVISOR_ENGAGEMENT: z.coerce.number().int().positive().optional(), + CREDIT_SCORE_DIVISOR_AGE: z.coerce.number().int().positive().optional(), + /** Credit score caps */ + CREDIT_SCORE_CAP_BASE: z.coerce.number().int().min(0).optional(), + CREDIT_SCORE_CAP_MAX: z.coerce.number().int().min(0).optional(), + CREDIT_SCORE_CAP_X_SUB: z.coerce.number().int().min(0).optional(), + CREDIT_SCORE_CAP_AGE_SUB: z.coerce.number().int().min(0).optional(), + CREDIT_SCORE_CAP_TIP_SUB: z.coerce.number().int().min(0).optional(), + /** Credit score cache TTL in seconds */ + CREDIT_SCORE_CACHE_TTL_SECONDS: z.coerce.number().int().positive().optional(), /** Minimum withdrawal amount, in stroops (1 XLM = 10,000,000 stroops). */ WITHDRAWAL_MIN_AMOUNT_STROOPS: z.coerce.number().int().positive().default(10_000_000), diff --git a/backend/src/modules/credit/credit.config.ts b/backend/src/modules/credit/credit.config.ts new file mode 100644 index 00000000..bdc0bfee --- /dev/null +++ b/backend/src/modules/credit/credit.config.ts @@ -0,0 +1,60 @@ +/** + * Credit score configuration and weights. + * All weights and divisors can be overridden via environment variables. + */ + +interface CreditScoreConfig { + weights: { + base: number; + tip: number; + x: number; + age: number; + }; + divisors: { + tip: number; + follower: number; + engagement: number; + age: number; + }; + caps: { + base: number; + max: number; + xSub: number; + ageSub: number; + tipSub: number; + }; + cacheTtlSeconds: number; +} + +function parseEnvNumber(value: string | undefined, defaultValue: number): number { + if (!value) return defaultValue; + const parsed = parseInt(value, 10); + return isNaN(parsed) ? defaultValue : parsed; +} + +export function loadCreditScoreConfig(): CreditScoreConfig { + return { + weights: { + base: parseEnvNumber(process.env.CREDIT_SCORE_WEIGHT_BASE, 40), + tip: parseEnvNumber(process.env.CREDIT_SCORE_WEIGHT_TIP, 20), + x: parseEnvNumber(process.env.CREDIT_SCORE_WEIGHT_X, 30), + age: parseEnvNumber(process.env.CREDIT_SCORE_WEIGHT_AGE, 10), + }, + divisors: { + tip: parseEnvNumber(process.env.CREDIT_SCORE_DIVISOR_TIP, 10_000_000), + follower: parseEnvNumber(process.env.CREDIT_SCORE_DIVISOR_FOLLOWER, 50), + engagement: parseEnvNumber(process.env.CREDIT_SCORE_DIVISOR_ENGAGEMENT, 10), + age: parseEnvNumber(process.env.CREDIT_SCORE_DIVISOR_AGE, 10), + }, + caps: { + base: parseEnvNumber(process.env.CREDIT_SCORE_CAP_BASE, 40), + max: parseEnvNumber(process.env.CREDIT_SCORE_CAP_MAX, 100), + xSub: parseEnvNumber(process.env.CREDIT_SCORE_CAP_X_SUB, 50), + ageSub: parseEnvNumber(process.env.CREDIT_SCORE_CAP_AGE_SUB, 100), + tipSub: parseEnvNumber(process.env.CREDIT_SCORE_CAP_TIP_SUB, 100), + }, + cacheTtlSeconds: parseEnvNumber(process.env.CREDIT_SCORE_CACHE_TTL_SECONDS, 5 * 60), + }; +} + +export const creditScoreConfig = loadCreditScoreConfig(); diff --git a/backend/src/modules/credit/credit.factors.ts b/backend/src/modules/credit/credit.factors.ts new file mode 100644 index 00000000..26006251 --- /dev/null +++ b/backend/src/modules/credit/credit.factors.ts @@ -0,0 +1,104 @@ +/** + * Credit score factors and weights breakdown. + * Exposes the contributing factors in a human-readable format. + */ + +import { creditScoreConfig } from './credit.config.js'; + +export interface CreditFactorBreakdown { + name: string; + weight: number; + maxContribution: number; + description: string; + divisor?: number; + cap?: number; +} + +export interface CreditScoreFactors { + factors: CreditFactorBreakdown[]; + totalWeight: number; + maxScore: number; + baseScore: number; +} + +/** + * Returns the complete credit score factors breakdown. + * This exposes all contributing factors and their weights for transparency. + */ +export function getCreditScoreFactors(): CreditScoreFactors { + const config = creditScoreConfig; + const maxScore = config.caps.max; + + const factors: CreditFactorBreakdown[] = [ + { + name: 'Base Score', + weight: 0, + maxContribution: config.weights.base, + description: 'Flat score given to all registered creators', + cap: config.weights.base, + }, + { + name: 'Tip Volume', + weight: config.weights.tip, + maxContribution: Math.floor((100 * config.weights.tip) / maxScore), + description: 'Total XLM received from tips', + divisor: config.divisors.tip, + cap: config.caps.tipSub, + }, + { + name: 'X Metrics', + weight: config.weights.x, + maxContribution: Math.floor((100 * config.weights.x) / maxScore), + description: 'X (Twitter) followers and engagement', + divisor: config.divisors.follower, + cap: config.caps.xSub, + }, + { + name: 'Account Age', + weight: config.weights.age, + maxContribution: Math.floor((100 * config.weights.age) / maxScore), + description: 'Days since account creation', + divisor: config.divisors.age, + cap: config.caps.ageSub, + }, + ]; + + const totalWeight = factors.reduce((sum, f) => sum + f.weight, 0); + + return { + factors, + totalWeight, + maxScore: config.caps.max, + baseScore: config.weights.base, + }; +} + +/** + * Returns the configuration for all credit score constants. + * This is used for transparency and API exposure. + */ +export function getCreditScoreConfig() { + return creditScoreConfig; +} + +/** + * Formats a factor breakdown for API response. + */ +export function formatFactorForResponse(factor: CreditFactorBreakdown) { + const result: Record = { + name: factor.name, + weight: factor.weight, + maxContribution: factor.maxContribution, + description: factor.description, + }; + + if (factor.divisor) { + result.divisor = factor.divisor; + } + + if (factor.cap) { + result.cap = factor.cap; + } + + return result; +} diff --git a/backend/src/modules/credit/credit.formula.test.ts b/backend/src/modules/credit/credit.formula.test.ts new file mode 100644 index 00000000..d53a90d0 --- /dev/null +++ b/backend/src/modules/credit/credit.formula.test.ts @@ -0,0 +1,381 @@ +import { describe, expect, it } from 'vitest'; +import { + clamp, + computeTipSubScore, + computeXSubScore, + computeAgeSubScore, + applyWeight, + computeCreditScore, + getTierForScore, + type CreditScoreFormula, +} from './credit.formula.js'; + +const defaultConfig: CreditScoreFormula = { + weights: { + base: 40, + tip: 20, + x: 30, + age: 10, + }, + divisors: { + tip: 10_000_000, + follower: 50, + engagement: 10, + age: 10, + }, + caps: { + base: 40, + max: 100, + xSub: 50, + ageSub: 100, + tipSub: 100, + }, +}; + +const tiers = [ + { min: 80, max: 100, label: 'Diamond' }, + { min: 60, max: 79, label: 'Gold' }, + { min: 40, max: 59, label: 'Silver' }, + { min: 20, max: 39, label: 'Bronze' }, + { min: 0, max: 19, label: 'New' }, +]; + +describe('clamp', () => { + it('returns the value when within bounds', () => { + expect(clamp(50, 0, 100)).toBe(50); + }); + + it('returns min when value is below min', () => { + expect(clamp(-10, 0, 100)).toBe(0); + }); + + it('returns max when value is above max', () => { + expect(clamp(150, 0, 100)).toBe(100); + }); +}); + +describe('computeTipSubScore', () => { + it('returns 0 for no tips', () => { + const result = computeTipSubScore(BigInt(0), defaultConfig); + expect(result).toBe(0); + }); + + it('returns 1 for 10 XLM (10,000,000 stroops)', () => { + const result = computeTipSubScore(BigInt(10_000_000), defaultConfig); + expect(result).toBe(1); + }); + + it('returns 10 for 100 XLM (100,000,000 stroops)', () => { + const result = computeTipSubScore(BigInt(100_000_000), defaultConfig); + expect(result).toBe(10); + }); + + it('caps at tipSub cap (100)', () => { + const result = computeTipSubScore(BigInt(10_000_000_000), defaultConfig); + expect(result).toBe(100); + }); + + it('respects custom divisor', () => { + const config: CreditScoreFormula = { + ...defaultConfig, + divisors: { ...defaultConfig.divisors, tip: 5_000_000 }, + }; + const result = computeTipSubScore(BigInt(10_000_000), config); + expect(result).toBe(2); + }); +}); + +describe('computeXSubScore', () => { + it('returns 0 when no X presence', () => { + const result = computeXSubScore(0, 0, defaultConfig); + expect(result).toBe(0); + }); + + it('computes follower component', () => { + const result = computeXSubScore(500, 0, defaultConfig); + expect(result).toBe(10); + }); + + it('computes engagement component', () => { + const result = computeXSubScore(0, 100, defaultConfig); + expect(result).toBe(10); + }); + + it('combines follower and engagement components', () => { + const result = computeXSubScore(500, 100, defaultConfig); + expect(result).toBe(20); + }); + + it('caps each component at xSub (50)', () => { + const result = computeXSubScore(10000, 1000, defaultConfig); + expect(result).toBe(100); + }); + + it('respects custom divisors', () => { + const config: CreditScoreFormula = { + ...defaultConfig, + divisors: { ...defaultConfig.divisors, follower: 100, engagement: 20 }, + }; + const result = computeXSubScore(500, 100, config); + expect(result).toBe(10); + }); +}); + +describe('computeAgeSubScore', () => { + it('returns 0 for accounts less than 1 day old', () => { + const result = computeAgeSubScore(0.5, defaultConfig); + expect(result).toBe(0); + }); + + it('returns 0 for accounts exactly 1 day old', () => { + const result = computeAgeSubScore(1, defaultConfig); + expect(result).toBe(0); + }); + + it('returns 1 for 10 days old', () => { + const result = computeAgeSubScore(10, defaultConfig); + expect(result).toBe(1); + }); + + it('returns 100 for 1000 days old', () => { + const result = computeAgeSubScore(1000, defaultConfig); + expect(result).toBe(100); + }); + + it('caps at ageSub cap (100)', () => { + const result = computeAgeSubScore(10000, defaultConfig); + expect(result).toBe(100); + }); + + it('respects custom divisor', () => { + const config: CreditScoreFormula = { + ...defaultConfig, + divisors: { ...defaultConfig.divisors, age: 20 }, + }; + const result = computeAgeSubScore(200, config); + expect(result).toBe(10); + }); +}); + +describe('applyWeight', () => { + it('applies weight correctly', () => { + const result = applyWeight(50, 20, 100); + expect(result).toBe(10); + }); + + it('floors the result', () => { + const result = applyWeight(33, 20, 100); + expect(result).toBe(6); + }); + + it('handles 0 weight', () => { + const result = applyWeight(100, 0, 100); + expect(result).toBe(0); + }); + + it('respects custom max score', () => { + const result = applyWeight(50, 30, 150); + expect(result).toBe(10); + }); +}); + +describe('computeCreditScore (full formula)', () => { + it('returns base score for new creator with no activity', () => { + const result = computeCreditScore( + { + totalTipsReceived: BigInt(0), + xFollowers: 0, + xEngagementAvg: 0, + accountAgeDays: 0, + streakBonus: 0, + }, + defaultConfig, + tiers, + ); + + expect(result.score).toBe(40); + expect(result.tier).toBe('Silver'); + expect(result.components.base).toBe(40); + expect(result.components.tipVolume).toBe(0); + expect(result.components.xMetrics).toBe(0); + expect(result.components.accountAge).toBe(0); + expect(result.components.streakBonus).toBe(0); + }); + + it('combines all components correctly', () => { + const result = computeCreditScore( + { + totalTipsReceived: BigInt(100_000_000), // 10 XLM + xFollowers: 500, + xEngagementAvg: 100, + accountAgeDays: 100, + streakBonus: 5, + }, + defaultConfig, + tiers, + ); + + expect(result.score).toBeGreaterThan(40); + expect(result.tier).toBe('Silver'); + expect(result.components.base).toBe(40); + expect(result.components.tipVolume).toBeGreaterThan(0); + expect(result.components.xMetrics).toBeGreaterThan(0); + expect(result.components.accountAge).toBeGreaterThan(0); + expect(result.components.streakBonus).toBe(5); + }); + + it('caps at max score (100)', () => { + const result = computeCreditScore( + { + totalTipsReceived: BigInt(10_000_000_000), + xFollowers: 10000, + xEngagementAvg: 1000, + accountAgeDays: 10000, + streakBonus: 100, + }, + defaultConfig, + tiers, + ); + + expect(result.score).toBe(100); + expect(result.tier).toBe('Diamond'); + }); + + it('assigns Diamond tier for high scores', () => { + const result = computeCreditScore( + { + totalTipsReceived: BigInt(1_000_000_000), + xFollowers: 2500, + xEngagementAvg: 200, + accountAgeDays: 365, + streakBonus: 10, + }, + defaultConfig, + tiers, + ); + + expect(result.score).toBeGreaterThanOrEqual(80); + expect(result.tier).toBe('Diamond'); + }); + + it('assigns Gold tier for mid-range scores', () => { + const result = computeCreditScore( + { + totalTipsReceived: BigInt(100_000_000), + xFollowers: 1000, + xEngagementAvg: 100, + accountAgeDays: 180, + streakBonus: 5, + }, + defaultConfig, + tiers, + ); + + expect(result.score).toBeGreaterThanOrEqual(60); + expect(result.score).toBeLessThan(80); + expect(result.tier).toBe('Gold'); + }); + + it('respects configurable weights', () => { + const customConfig: CreditScoreFormula = { + ...defaultConfig, + weights: { base: 50, tip: 30, x: 10, age: 10 }, + }; + + const result = computeCreditScore( + { + totalTipsReceived: BigInt(100_000_000), + xFollowers: 500, + xEngagementAvg: 0, + accountAgeDays: 100, + streakBonus: 0, + }, + customConfig, + tiers, + ); + + expect(result.components.base).toBe(50); + expect(result.score).toBeGreaterThan(50); + }); + + it('handles zero streak bonus correctly', () => { + const result = computeCreditScore( + { + totalTipsReceived: BigInt(0), + xFollowers: 0, + xEngagementAvg: 0, + accountAgeDays: 0, + streakBonus: 0, + }, + defaultConfig, + tiers, + ); + + expect(result.components.streakBonus).toBe(0); + expect(result.score).toBe(40); + }); + + it('clamps negative streak bonus to 0', () => { + const result = computeCreditScore( + { + totalTipsReceived: BigInt(0), + xFollowers: 0, + xEngagementAvg: 0, + accountAgeDays: 0, + streakBonus: -5, + }, + defaultConfig, + tiers, + ); + + expect(result.components.streakBonus).toBe(0); + }); + + it('clamps excessive streak bonus to max', () => { + const result = computeCreditScore( + { + totalTipsReceived: BigInt(0), + xFollowers: 0, + xEngagementAvg: 0, + accountAgeDays: 0, + streakBonus: 200, + }, + defaultConfig, + tiers, + ); + + expect(result.components.streakBonus).toBe(100); + expect(result.score).toBe(100); + }); +}); + +describe('getTierForScore', () => { + it('returns Diamond for 80-100', () => { + expect(getTierForScore(80, tiers)).toBe('Diamond'); + expect(getTierForScore(100, tiers)).toBe('Diamond'); + }); + + it('returns Gold for 60-79', () => { + expect(getTierForScore(60, tiers)).toBe('Gold'); + expect(getTierForScore(79, tiers)).toBe('Gold'); + }); + + it('returns Silver for 40-59', () => { + expect(getTierForScore(40, tiers)).toBe('Silver'); + expect(getTierForScore(59, tiers)).toBe('Silver'); + }); + + it('returns Bronze for 20-39', () => { + expect(getTierForScore(20, tiers)).toBe('Bronze'); + expect(getTierForScore(39, tiers)).toBe('Bronze'); + }); + + it('returns New for 0-19', () => { + expect(getTierForScore(0, tiers)).toBe('New'); + expect(getTierForScore(19, tiers)).toBe('New'); + }); + + it('returns New for unmatched scores', () => { + expect(getTierForScore(101, tiers)).toBe('New'); + }); +}); diff --git a/backend/src/modules/credit/credit.formula.ts b/backend/src/modules/credit/credit.formula.ts new file mode 100644 index 00000000..f9cc3167 --- /dev/null +++ b/backend/src/modules/credit/credit.formula.ts @@ -0,0 +1,160 @@ +/** + * Pure credit score formula functions. + * All functions are deterministic and have no side effects. + * Configuration is passed as parameters for testability. + */ + +export interface CreditScoreFormula { + weights: { + base: number; + tip: number; + x: number; + age: number; + }; + divisors: { + tip: number; + follower: number; + engagement: number; + age: number; + }; + caps: { + base: number; + max: number; + xSub: number; + ageSub: number; + tipSub: number; + }; +} + +/** + * Pure utility to clamp a value between min and max. + */ +export function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +/** + * Pure function: compute tip sub-score from total tips received. + * Converts stroops to a 0–cap value. + */ +export function computeTipSubScore(totalTipsReceived: bigint, config: CreditScoreFormula): number { + const clamped = clamp(Number(totalTipsReceived), 0, 1_000_000_000); + return Math.min(Math.floor(clamped / config.divisors.tip), config.caps.tipSub); +} + +/** + * Pure function: compute X sub-score from followers and engagement. + * Returns 0 if both followers and engagement are 0 (not registered on X). + * Otherwise, combines follower and engagement components, each capped at xSub/2. + */ +export function computeXSubScore( + xFollowers: number, + xEngagementAvg: number, + config: CreditScoreFormula, +): number { + if (xFollowers === 0 && xEngagementAvg === 0) return 0; + + const followerPart = Math.min( + Math.floor(xFollowers / config.divisors.follower), + config.caps.xSub, + ); + const engagementPart = Math.min( + Math.floor(xEngagementAvg / config.divisors.engagement), + config.caps.xSub, + ); + + return Math.min(followerPart + engagementPart, config.caps.max); +} + +/** + * Pure function: compute account age sub-score from days since account creation. + * Accounts younger than 1 day contribute 0. + * Result is capped at ageSub. + */ +export function computeAgeSubScore(accountAgeDays: number, config: CreditScoreFormula): number { + if (accountAgeDays < 1) return 0; + return Math.min(Math.floor(accountAgeDays / config.divisors.age), config.caps.ageSub); +} + +/** + * Pure function: compute the weighted score from a sub-score. + * Applies weight percentage and caps at max score. + */ +export function applyWeight(subScore: number, weight: number, maxScore: number): number { + return Math.floor((subScore * weight) / maxScore); +} + +/** + * Pure function: compute total credit score and breakdown components. + * This is the core formula: pure, deterministic, and fully testable. + */ +export interface CreditScoreComputeInput { + totalTipsReceived: bigint; + xFollowers: number; + xEngagementAvg: number; + accountAgeDays: number; + streakBonus: number; +} + +export interface CreditScoreComponents { + base: number; + tipVolume: number; + xMetrics: number; + accountAge: number; + streakBonus: number; +} + +export interface CreditScoreResult { + score: number; + components: CreditScoreComponents; + tier: string; +} + +export function computeCreditScore( + input: CreditScoreComputeInput, + config: CreditScoreFormula, + tiers: Array<{ min: number; max: number; label: string }>, +): CreditScoreResult { + // Compute sub-scores (capped at config.caps.max) + const tipSub = computeTipSubScore(input.totalTipsReceived, config); + const xSub = computeXSubScore(input.xFollowers, input.xEngagementAvg, config); + const ageSub = computeAgeSubScore(input.accountAgeDays, config); + + // Apply weights to sub-scores + const tipScore = applyWeight(tipSub, config.weights.tip, config.caps.max); + const xScore = applyWeight(xSub, config.weights.x, config.caps.max); + const ageScore = applyWeight(ageSub, config.weights.age, config.caps.max); + const streakBonus = clamp(input.streakBonus, 0, config.caps.max); + + // Combine components and cap at max + const total = clamp( + config.weights.base + tipScore + xScore + ageScore + streakBonus, + 0, + config.caps.max, + ); + + // Determine tier + const tier = tiers.find((t) => total >= t.min && total <= t.max)?.label ?? 'New'; + + return { + score: total, + components: { + base: config.weights.base, + tipVolume: tipScore, + xMetrics: xScore, + accountAge: ageScore, + streakBonus, + }, + tier, + }; +} + +/** + * Returns the tier definition based on score. + */ +export function getTierForScore( + score: number, + tiers: Array<{ min: number; max: number; label: string }>, +): string { + return tiers.find((t) => score >= t.min && score <= t.max)?.label ?? 'New'; +} diff --git a/backend/src/modules/credit/credit.service.ts b/backend/src/modules/credit/credit.service.ts index 08118cfe..4e5e2872 100644 --- a/backend/src/modules/credit/credit.service.ts +++ b/backend/src/modules/credit/credit.service.ts @@ -2,6 +2,11 @@ import { prisma } from '../../db/prisma.js'; import { redis } from '../../db/redis.js'; import { NotFoundError } from '../../common/errors/AppError.js'; import { logger } from '../../common/utils/logger.js'; +import { creditScoreConfig } from './credit.config.js'; +import { + computeCreditScore as computeCreditScoreFormula, + type CreditScoreComputeInput, +} from './credit.formula.js'; import type { CreditScoreResponse, CreditScoreComponents, @@ -9,20 +14,6 @@ import type { CreditScoreHistoryPoint, } from './credit.types.js'; -const BASE_SCORE = 40; -const MAX_SCORE = 100; -const TIP_WEIGHT = 20; -const X_WEIGHT = 30; -const AGE_WEIGHT = 10; -const TIP_DIVISOR = 10_000_000; -const FOLLOWER_DIVISOR = 50; -const ENGAGEMENT_DIVISOR = 10; -const AGE_DIVISOR = 10; -const X_SUB_CAP = 50; -const AGE_CAP = 100; -const TIP_CAP = 100; -const CREDIT_SCORE_CACHE_TTL_SECONDS = 5 * 60; - const TIERS: { min: number; max: number; label: string }[] = [ { min: 80, max: 100, label: 'Diamond' }, { min: 60, max: 79, label: 'Gold' }, @@ -31,9 +22,9 @@ const TIERS: { min: number; max: number; label: string }[] = [ { min: 0, max: 19, label: 'New' }, ]; -function clamp(value: number, min: number, max: number): number { - return Math.min(Math.max(value, min), max); -} +// Debounce map for recomputation requests per user +const recomputeDebounceMap = new Map(); +const RECOMPUTE_DEBOUNCE_MS = 5000; // 5 seconds function cacheKeyForUser(userId: string): string { return `credit:score:user:${userId}`; @@ -43,23 +34,6 @@ function cacheKeyForUsername(username: string): string { return `credit:score:username:${username.toLowerCase()}`; } -function computeTipSubScore(totalTipsReceived: bigint): number { - const clamped = clamp(Number(totalTipsReceived), 0, 1_000_000_000); - return Math.min(Math.floor(clamped / TIP_DIVISOR), TIP_CAP); -} - -function computeXSubScore(xFollowers: number, xEngagementAvg: number): number { - if (xFollowers === 0 && xEngagementAvg === 0) return 0; - const followerPart = Math.min(Math.floor(xFollowers / FOLLOWER_DIVISOR), X_SUB_CAP); - const engagementPart = Math.min(Math.floor(xEngagementAvg / ENGAGEMENT_DIVISOR), X_SUB_CAP); - return followerPart + engagementPart; -} - -function computeAgeSubScore(accountAgeDays: number): number { - if (accountAgeDays < 1) return 0; - return Math.min(Math.floor(accountAgeDays / AGE_DIVISOR), AGE_CAP); -} - async function readCachedScore(key: string): Promise { try { const cached = await redis.get(key); @@ -74,7 +48,9 @@ async function writeCachedScore(keys: string[], score: CreditScoreResponse): Pro try { const payload = JSON.stringify(score); await Promise.all( - keys.map((key) => redis.set(key, payload, 'EX', CREDIT_SCORE_CACHE_TTL_SECONDS)), + keys.map((key) => + redis.set(key, payload, 'EX', creditScoreConfig.cacheTtlSeconds), + ), ); } catch (err) { logger.warn({ err, keys }, 'Credit score cache write failed'); @@ -87,27 +63,20 @@ export function computeCreditScore(input: ComputeCreditScoreInput): { components: CreditScoreComponents; tier: string; } { - const tipSub = computeTipSubScore(input.totalTipsReceived); - const xSub = computeXSubScore(input.xFollowers, input.xEngagementAvg); - const ageSub = computeAgeSubScore(input.accountAgeDays); + const formulaInput: CreditScoreComputeInput = { + totalTipsReceived: input.totalTipsReceived, + xFollowers: input.xFollowers, + xEngagementAvg: input.xEngagementAvg, + accountAgeDays: input.accountAgeDays, + streakBonus: input.streakBonus, + }; - const tipScore = Math.floor((tipSub * TIP_WEIGHT) / MAX_SCORE); - const xScore = Math.floor((xSub * X_WEIGHT) / MAX_SCORE); - const ageScore = Math.floor((ageSub * AGE_WEIGHT) / MAX_SCORE); - const streakBonus = clamp(input.streakBonus, 0, MAX_SCORE); - const total = clamp(BASE_SCORE + tipScore + xScore + ageScore + streakBonus, 0, MAX_SCORE); - const tier = TIERS.find((item) => total >= item.min && total <= item.max)?.label ?? 'New'; + const result = computeCreditScoreFormula(formulaInput, creditScoreConfig, TIERS); return { - score: total, - components: { - base: BASE_SCORE, - tipVolume: tipScore, - xMetrics: xScore, - accountAge: ageScore, - streakBonus, - }, - tier, + score: result.score, + components: result.components, + tier: result.tier, }; } @@ -271,3 +240,30 @@ export async function recalculateCreditScore(userId: string): Promise { + // Clear any pending timeout for this user + const pendingTimeout = recomputeDebounceMap.get(userId); + if (pendingTimeout) { + clearTimeout(pendingTimeout); + } + + // Schedule a new recomputation after the debounce delay + const newTimeout = setTimeout(async () => { + try { + await recalculateCreditScore(userId); + logger.info({ userId }, 'Credit score recomputed after tip'); + } catch (err) { + logger.error({ err, userId }, 'Failed to recompute credit score'); + } finally { + recomputeDebounceMap.delete(userId); + } + }, RECOMPUTE_DEBOUNCE_MS); + + recomputeDebounceMap.set(userId, newTimeout); +} diff --git a/backend/src/modules/credit/credit.test.ts b/backend/src/modules/credit/credit.test.ts index 4153e554..73f43522 100644 --- a/backend/src/modules/credit/credit.test.ts +++ b/backend/src/modules/credit/credit.test.ts @@ -1,10 +1,11 @@ import request from 'supertest'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi, afterEach } from 'vitest'; import { createApp } from '../../app.js'; import { computeCreditScore, getCreditScoreByUsername, recalculateCreditScore, + scheduleRecomputeCreditScore, } from './credit.service.js'; const { @@ -79,6 +80,23 @@ describe('computeCreditScore (pure formula)', () => { expect(result.score).toBe(100); expect(result.tier).toBe('Diamond'); }); + + it('combines tip volume, X metrics, age, and streak correctly', () => { + const result = computeCreditScore({ + totalTipsReceived: BigInt(100_000_000), + xFollowers: 500, + xEngagementAvg: 100, + accountAgeDays: 100, + streakBonus: 5, + }); + + expect(result.score).toBeGreaterThan(40); + expect(result.score).toBeLessThanOrEqual(100); + expect(result.components.tipVolume).toBeGreaterThan(0); + expect(result.components.xMetrics).toBeGreaterThan(0); + expect(result.components.accountAge).toBeGreaterThan(0); + expect(result.components.streakBonus).toBe(5); + }); }); describe('credit score cache', () => { @@ -159,9 +177,55 @@ describe('credit score cache', () => { }); }); +describe('debounced recomputation on tips', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + mockRedisGet.mockResolvedValue(null); + mockRedisSet.mockResolvedValue('OK'); + }); + + afterEach(() => { + vi.runAllTimers(); + vi.useRealTimers(); + }); + + it('debounces rapid recomputation requests', async () => { + const createdAt = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000); + const computedAt = new Date(); + + mockFindUnique.mockResolvedValue({ + id: 'user-1', + username: 'alice', + stellarAddress: 'GA1', + createdAt, + deletedAt: null, + streak: { currentStreak: 0 }, + }); + mockAggregate.mockResolvedValue({ _sum: { amountStroops: BigInt(0) } }); + mockUpsert.mockResolvedValue({ value: 40, computedAt }); + mockCreate.mockResolvedValue({}); + + // Trigger 3 rapid recomputation requests + await scheduleRecomputeCreditScore('user-1'); + await scheduleRecomputeCreditScore('user-1'); + await scheduleRecomputeCreditScore('user-1'); + + // Should not have called recalculateCreditScore yet (debounced) + expect(mockUpsert).not.toHaveBeenCalled(); + + // Fast-forward time to trigger the debounced call + vi.advanceTimersByTime(5000); + + // Now it should have been called exactly once + expect(mockUpsert).toHaveBeenCalledTimes(1); + }); +}); + describe('GET /api/v1/credit/:identifier', () => { beforeEach(() => { vi.clearAllMocks(); + vi.useRealTimers(); mockRedisGet.mockResolvedValue(null); mockRedisSet.mockResolvedValue('OK'); });