Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 18 additions & 0 deletions backend/src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),

Expand Down
60 changes: 60 additions & 0 deletions backend/src/modules/credit/credit.config.ts
Original file line number Diff line number Diff line change
@@ -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();
104 changes: 104 additions & 0 deletions backend/src/modules/credit/credit.factors.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {
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;
}
Loading
Loading