From 6e7c00cdcaf8451cc2234d45a032232e88cd65a2 Mon Sep 17 00:00:00 2001 From: OlufunbiIK Date: Wed, 22 Jul 2026 22:22:58 +0100 Subject: [PATCH 1/3] feat:Implement Wallet Reputation Scoring --- .../constants/reputation.constants.ts | 66 +++++++ .../dto/query-reputation-history.dto.ts | 22 +++ .../dto/recalculate-reputation.dto.ts | 15 ++ .../dto/reputation-score-response.dto.ts | 57 ++++++ .../interfaces/reputation.interfaces.ts | 64 +++++++ .../providers/stub-chain-data.provider.ts | 35 ++++ .../providers/stub-incident-data.provider.ts | 29 +++ .../providers/stub-sanctions-list.provider.ts | 40 ++++ .../repository/reputation.repository.ts | 71 +++++++ .../reputations/reputation.controller.ts | 99 ++++++++++ .../modules/reputations/reputation.module.ts | 41 ++++ .../reputations/reputation.service.spec.ts | 135 ++++++++++++++ .../modules/reputations/reputation.service.ts | 130 +++++++++++++ .../services/reputation-calculator.service.ts | 175 ++++++++++++++++++ .../services/risk-indicator.service.ts | 114 ++++++++++++ 15 files changed, 1093 insertions(+) create mode 100644 apps/backend/src/modules/reputations/constants/reputation.constants.ts create mode 100644 apps/backend/src/modules/reputations/dto/query-reputation-history.dto.ts create mode 100644 apps/backend/src/modules/reputations/dto/recalculate-reputation.dto.ts create mode 100644 apps/backend/src/modules/reputations/dto/reputation-score-response.dto.ts create mode 100644 apps/backend/src/modules/reputations/interfaces/reputation.interfaces.ts create mode 100644 apps/backend/src/modules/reputations/providers/stub-chain-data.provider.ts create mode 100644 apps/backend/src/modules/reputations/providers/stub-incident-data.provider.ts create mode 100644 apps/backend/src/modules/reputations/providers/stub-sanctions-list.provider.ts create mode 100644 apps/backend/src/modules/reputations/repository/reputation.repository.ts create mode 100644 apps/backend/src/modules/reputations/reputation.controller.ts create mode 100644 apps/backend/src/modules/reputations/reputation.module.ts create mode 100644 apps/backend/src/modules/reputations/reputation.service.spec.ts create mode 100644 apps/backend/src/modules/reputations/reputation.service.ts create mode 100644 apps/backend/src/modules/reputations/services/reputation-calculator.service.ts create mode 100644 apps/backend/src/modules/reputations/services/risk-indicator.service.ts diff --git a/apps/backend/src/modules/reputations/constants/reputation.constants.ts b/apps/backend/src/modules/reputations/constants/reputation.constants.ts new file mode 100644 index 0000000..9682354 --- /dev/null +++ b/apps/backend/src/modules/reputations/constants/reputation.constants.ts @@ -0,0 +1,66 @@ +/** + * src/modules/reputation/constants/reputation.constants.ts + * + * Central place for every magic number used by the reputation engine. + * Keeping these here (instead of scattered through the services) makes + * the scoring model auditable and easy to tune without touching logic. + */ + +/** Composite score is always normalized to this range. */ +export const REPUTATION_SCORE_MIN = 0; +export const REPUTATION_SCORE_MAX = 100; + +/** + * Relative weight of each factor in the final composite score. + * Must sum to 1 — enforced by a unit test in reputation.service.spec.ts. + */ +export const REPUTATION_WEIGHTS = { + walletAge: 0.15, + activityConsistency: 0.2, + riskIndicators: 0.35, + incidentHistory: 0.2, + networkAssociation: 0.1, +} as const; + +/** How long a computed score is considered fresh before a recalculation is due. */ +export const REPUTATION_SCORE_TTL_MS = 1000 * 60 * 60 * 6; // 6 hours + +/** Trust tiers derived from the final composite score. */ +export enum ReputationTier { + TRUSTED = 'TRUSTED', + NEUTRAL = 'NEUTRAL', + SUSPICIOUS = 'SUSPICIOUS', + HIGH_RISK = 'HIGH_RISK', +} + +export const REPUTATION_TIER_THRESHOLDS: { tier: ReputationTier; min: number }[] = [ + { tier: ReputationTier.TRUSTED, min: 75 }, + { tier: ReputationTier.NEUTRAL, min: 50 }, + { tier: ReputationTier.SUSPICIOUS, min: 25 }, + { tier: ReputationTier.HIGH_RISK, min: 0 }, +]; + +/** Severity levels used by individual risk indicators. */ +export enum RiskIndicatorSeverity { + INFO = 'INFO', + LOW = 'LOW', + MEDIUM = 'MEDIUM', + HIGH = 'HIGH', + CRITICAL = 'CRITICAL', +} + +/** Point deduction applied to the risk-indicator sub-score per severity hit. */ +export const RISK_SEVERITY_PENALTY: Record = { + [RiskIndicatorSeverity.INFO]: 0, + [RiskIndicatorSeverity.LOW]: 5, + [RiskIndicatorSeverity.MEDIUM]: 15, + [RiskIndicatorSeverity.HIGH]: 30, + [RiskIndicatorSeverity.CRITICAL]: 50, +}; + +/** Max number of hops used when walking the wallet's association graph. */ +export const NETWORK_ANALYSIS_MAX_HOPS = 2; + +/** Max number of historical snapshots returned by default in the history endpoint. */ +export const DEFAULT_HISTORY_PAGE_SIZE = 20; +export const MAX_HISTORY_PAGE_SIZE = 100; diff --git a/apps/backend/src/modules/reputations/dto/query-reputation-history.dto.ts b/apps/backend/src/modules/reputations/dto/query-reputation-history.dto.ts new file mode 100644 index 0000000..f09e256 --- /dev/null +++ b/apps/backend/src/modules/reputations/dto/query-reputation-history.dto.ts @@ -0,0 +1,22 @@ +import { Type } from 'class-transformer'; +import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; +import { DEFAULT_HISTORY_PAGE_SIZE, MAX_HISTORY_PAGE_SIZE } from '../constants/reputation.constants'; + +export class QueryReputationHistoryDto { + @IsOptional() + @IsString() + chainId?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(MAX_HISTORY_PAGE_SIZE) + limit: number = DEFAULT_HISTORY_PAGE_SIZE; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + offset: number = 0; +} diff --git a/apps/backend/src/modules/reputations/dto/recalculate-reputation.dto.ts b/apps/backend/src/modules/reputations/dto/recalculate-reputation.dto.ts new file mode 100644 index 0000000..ce13843 --- /dev/null +++ b/apps/backend/src/modules/reputations/dto/recalculate-reputation.dto.ts @@ -0,0 +1,15 @@ +import { IsBoolean, IsOptional, IsString } from 'class-validator'; + +export class RecalculateReputationDto { + @IsString() + chainId: string; + + /** + * Bypasses the TTL cache check and forces a fresh calculation even if a + * recent score already exists. Defaults to false to avoid needlessly + * hammering the chain-data integrations on every request. + */ + @IsOptional() + @IsBoolean() + force?: boolean = false; +} diff --git a/apps/backend/src/modules/reputations/dto/reputation-score-response.dto.ts b/apps/backend/src/modules/reputations/dto/reputation-score-response.dto.ts new file mode 100644 index 0000000..0145ed7 --- /dev/null +++ b/apps/backend/src/modules/reputations/dto/reputation-score-response.dto.ts @@ -0,0 +1,57 @@ +import { Exclude, Expose, Type } from 'class-transformer'; +import { ReputationTier } from '../constants/reputation.constants'; +import { ReputationBreakdown, RiskIndicator } from '../interfaces/reputation.interfaces'; + +@Exclude() +export class ReputationScoreResponseDto { + @Expose() + walletAddress: string; + + @Expose() + chainId: string; + + @Expose() + score: number; + + @Expose() + tier: ReputationTier; + + @Expose() + breakdown: ReputationBreakdown; + + @Expose() + indicators: RiskIndicator[]; + + @Expose() + @Type(() => Date) + calculatedAt: Date; +} + +@Exclude() +export class ReputationHistoryItemDto { + @Expose() + score: number; + + @Expose() + tier: ReputationTier; + + @Expose() + @Type(() => Date) + calculatedAt: Date; +} + +@Exclude() +export class PaginatedReputationHistoryDto { + @Expose() + @Type(() => ReputationHistoryItemDto) + items: ReputationHistoryItemDto[]; + + @Expose() + total: number; + + @Expose() + limit: number; + + @Expose() + offset: number; +} diff --git a/apps/backend/src/modules/reputations/interfaces/reputation.interfaces.ts b/apps/backend/src/modules/reputations/interfaces/reputation.interfaces.ts new file mode 100644 index 0000000..00fed46 --- /dev/null +++ b/apps/backend/src/modules/reputations/interfaces/reputation.interfaces.ts @@ -0,0 +1,64 @@ +/** + * src/modules/reputation/interfaces/reputation.interfaces.ts + */ +import { ReputationTier, RiskIndicatorSeverity } from '../constants/reputation.constants'; + +export interface RiskIndicator { + code: string; // e.g. 'SANCTIONED_LIST_MATCH', 'MIXER_INTERACTION' + description: string; + severity: RiskIndicatorSeverity; + detectedAt: Date; + metadata?: Record; +} + +export interface FactorScore { + /** Raw score for this factor, normalized 0-100. */ + score: number; + /** Weight applied when composing the final score. */ + weight: number; + /** Human-readable notes explaining how the score was derived. */ + details: string[]; +} + +export interface ReputationBreakdown { + walletAge: FactorScore; + activityConsistency: FactorScore; + riskIndicators: FactorScore; + incidentHistory: FactorScore; + networkAssociation: FactorScore; +} + +export interface ReputationScoreResult { + walletAddress: string; + chainId: string; + score: number; + tier: ReputationTier; + breakdown: ReputationBreakdown; + indicators: RiskIndicator[]; + calculatedAt: Date; +} + +/** + * Minimal shape expected from the wallet's on-chain activity history. + * Populated by ChainsService / integrations module — see reputation.repository.ts. + */ +export interface WalletActivitySnapshot { + walletAddress: string; + chainId: string; + firstSeenAt: Date | null; + lastSeenAt: Date | null; + totalTransactions: number; + activeDaysLast90: number; + counterpartyAddresses: string[]; +} + +/** + * Minimal shape expected from the cases/incidents modules for a wallet. + */ +export interface WalletIncidentSummary { + walletAddress: string; + openIncidents: number; + resolvedIncidents: number; + confirmedFraudCases: number; + lastIncidentAt: Date | null; +} diff --git a/apps/backend/src/modules/reputations/providers/stub-chain-data.provider.ts b/apps/backend/src/modules/reputations/providers/stub-chain-data.provider.ts new file mode 100644 index 0000000..3dbfcc0 --- /dev/null +++ b/apps/backend/src/modules/reputations/providers/stub-chain-data.provider.ts @@ -0,0 +1,35 @@ +/** + * src/modules/reputation/providers/stub-chain-data.provider.ts + * + * TEMPORARY. Replace with a real adapter that calls the existing + * `chains` (and/or `integrations`) module to fetch actual wallet + * activity from indexed on-chain data. Kept here only so the reputation + * module is self-contained and testable until that integration lands. + */ +import { Injectable, Logger } from '@nestjs/common'; +import { ChainDataProvider } from '../reputation.service'; +import { WalletActivitySnapshot } from '../interfaces/reputation.interfaces'; + +@Injectable() +export class StubChainDataProvider implements ChainDataProvider { + private readonly logger = new Logger(StubChainDataProvider.name); + + async getActivitySnapshot( + walletAddress: string, + chainId: string, + ): Promise { + this.logger.warn( + `StubChainDataProvider in use for ${walletAddress} — wire up the real ChainsService before deploying.`, + ); + + return { + walletAddress, + chainId, + firstSeenAt: null, + lastSeenAt: null, + totalTransactions: 0, + activeDaysLast90: 0, + counterpartyAddresses: [], + }; + } +} diff --git a/apps/backend/src/modules/reputations/providers/stub-incident-data.provider.ts b/apps/backend/src/modules/reputations/providers/stub-incident-data.provider.ts new file mode 100644 index 0000000..10e1526 --- /dev/null +++ b/apps/backend/src/modules/reputations/providers/stub-incident-data.provider.ts @@ -0,0 +1,29 @@ +/** + * src/modules/reputation/providers/stub-incident-data.provider.ts + * + * TEMPORARY. Replace with a real adapter over the `cases` and + * `incidents` modules so wallet incident/fraud history feeds into the + * score. See PR description for the exact integration points expected. + */ +import { Injectable, Logger } from '@nestjs/common'; +import { IncidentDataProvider } from '../reputation.service'; +import { WalletIncidentSummary } from '../interfaces/reputation.interfaces'; + +@Injectable() +export class StubIncidentDataProvider implements IncidentDataProvider { + private readonly logger = new Logger(StubIncidentDataProvider.name); + + async getIncidentSummary(walletAddress: string): Promise { + this.logger.warn( + `StubIncidentDataProvider in use for ${walletAddress} — wire up the real CasesService/IncidentsService before deploying.`, + ); + + return { + walletAddress, + openIncidents: 0, + resolvedIncidents: 0, + confirmedFraudCases: 0, + lastIncidentAt: null, + }; + } +} diff --git a/apps/backend/src/modules/reputations/providers/stub-sanctions-list.provider.ts b/apps/backend/src/modules/reputations/providers/stub-sanctions-list.provider.ts new file mode 100644 index 0000000..efd2ddd --- /dev/null +++ b/apps/backend/src/modules/reputations/providers/stub-sanctions-list.provider.ts @@ -0,0 +1,40 @@ +/** + * src/modules/reputation/providers/stub-sanctions-list.provider.ts + * + * TEMPORARY. Replace with a real adapter over the platform's + * sanctioned/flagged address source (behavioral-analysis module, an + * OFAC feed, Chainalysis, etc). All checks default to "clean" so the + * module is safe to boot in dev/test without a live feed. + */ +import { Injectable, Logger } from '@nestjs/common'; +import { SanctionsListProvider } from '../services/risk-indicator.service'; + +@Injectable() +export class StubSanctionsListProvider implements SanctionsListProvider { + private readonly logger = new Logger(StubSanctionsListProvider.name); + private warned = false; + + async isSanctioned(_walletAddress: string): Promise { + this.warnOnce(); + return false; + } + + async isKnownMixer(_walletAddress: string): Promise { + this.warnOnce(); + return false; + } + + async isFlaggedContract(_walletAddress: string, _chainId: string): Promise { + this.warnOnce(); + return false; + } + + private warnOnce() { + if (!this.warned) { + this.logger.warn( + 'StubSanctionsListProvider in use — no sanctions/mixer/flagged-contract data source is wired up yet.', + ); + this.warned = true; + } + } +} diff --git a/apps/backend/src/modules/reputations/repository/reputation.repository.ts b/apps/backend/src/modules/reputations/repository/reputation.repository.ts new file mode 100644 index 0000000..fa8bd81 --- /dev/null +++ b/apps/backend/src/modules/reputations/repository/reputation.repository.ts @@ -0,0 +1,71 @@ +/** + * src/modules/reputation/repository/reputation.repository.ts + * + * NOTE: This assumes a `PrismaService` exposed from the project's + * `database` module (matching the `src/database` folder already in the + * repo) with a `reputationScore` model as added in + * `prisma/reputation.prisma.snippet` (see PR description). If the project + * instead uses TypeORM, swap this repository's internals for a + * `Repository` — the public method signatures + * (used by ReputationService) would not need to change. + */ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../../database/prisma.service'; +import { ReputationScoreResult } from '../interfaces/reputation.interfaces'; +import { ReputationTier } from '../constants/reputation.constants'; + +@Injectable() +export class ReputationRepository { + constructor(private readonly prisma: PrismaService) {} + + async findLatest(walletAddress: string, chainId: string) { + return this.prisma.reputationScore.findFirst({ + where: { walletAddress, chainId }, + orderBy: { calculatedAt: 'desc' }, + }); + } + + async saveSnapshot(result: ReputationScoreResult) { + return this.prisma.reputationScore.create({ + data: { + walletAddress: result.walletAddress, + chainId: result.chainId, + score: result.score, + tier: result.tier, + breakdown: result.breakdown as unknown as object, + indicators: result.indicators as unknown as object[], + calculatedAt: result.calculatedAt, + }, + }); + } + + async findHistory( + walletAddress: string, + chainId: string | undefined, + limit: number, + offset: number, + ) { + const where = chainId ? { walletAddress, chainId } : { walletAddress }; + + const [items, total] = await Promise.all([ + this.prisma.reputationScore.findMany({ + where, + orderBy: { calculatedAt: 'desc' }, + take: limit, + skip: offset, + }), + this.prisma.reputationScore.count({ where }), + ]); + + return { items, total }; + } + + async findByTier(tier: ReputationTier, chainId?: string, limit = 50) { + return this.prisma.reputationScore.findMany({ + where: chainId ? { tier, chainId } : { tier }, + orderBy: { calculatedAt: 'desc' }, + take: limit, + distinct: ['walletAddress'], + }); + } +} diff --git a/apps/backend/src/modules/reputations/reputation.controller.ts b/apps/backend/src/modules/reputations/reputation.controller.ts new file mode 100644 index 0000000..7ebc970 --- /dev/null +++ b/apps/backend/src/modules/reputations/reputation.controller.ts @@ -0,0 +1,99 @@ +/** + * src/modules/reputation/reputation.controller.ts + * + * NOTE: Swap in the project's real auth guard (e.g. `ApiKeyGuard` from + * `src/modules/api-keys`) in place of the placeholder import below once + * merged — left explicit rather than silently unguarded. + */ +import { + Body, + Controller, + Get, + Param, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { plainToInstance } from 'class-transformer'; +import { ReputationService } from './reputation.service'; +// import { ApiKeyGuard } from '../api-keys/api-key.guard'; +import { QueryReputationHistoryDto } from './dto/query-reputation-history.dto'; +import { RecalculateReputationDto } from './dto/recalculate-reputation.dto'; +import { + PaginatedReputationHistoryDto, + ReputationScoreResponseDto, +} from './dto/reputation-score-response.dto'; +import { ReputationTier } from './constants/reputation.constants'; + +@Controller('reputation') +// @UseGuards(ApiKeyGuard) +export class ReputationController { + constructor(private readonly reputationService: ReputationService) {} + + /** + * GET /reputation/:walletAddress?chainId=stellar + * Returns the current (cached-or-fresh) reputation score for a wallet. + */ + @Get(':walletAddress') + async getScore( + @Param('walletAddress') walletAddress: string, + @Query('chainId') chainId: string, + ): Promise { + const result = await this.reputationService.getScore(walletAddress, chainId); + return plainToInstance(ReputationScoreResponseDto, result, { + excludeExtraneousValues: true, + }); + } + + /** + * POST /reputation/:walletAddress/recalculate + * Forces a fresh score calculation, bypassing the cache TTL. + */ + @Post(':walletAddress/recalculate') + async recalculate( + @Param('walletAddress') walletAddress: string, + @Body() dto: RecalculateReputationDto, + ): Promise { + const result = await this.reputationService.recalculate(walletAddress, dto.chainId); + return plainToInstance(ReputationScoreResponseDto, result, { + excludeExtraneousValues: true, + }); + } + + /** + * GET /reputation/:walletAddress/history?chainId=stellar&limit=20&offset=0 + * Returns paginated historical score snapshots for a wallet. + */ + @Get(':walletAddress/history') + async getHistory( + @Param('walletAddress') walletAddress: string, + @Query() query: QueryReputationHistoryDto, + ): Promise { + const { items, total } = await this.reputationService.getHistory( + walletAddress, + query.chainId, + query.limit, + query.offset, + ); + + return plainToInstance( + PaginatedReputationHistoryDto, + { items, total, limit: query.limit, offset: query.offset }, + { excludeExtraneousValues: true }, + ); + } + + /** + * GET /reputation/tier/:tier?chainId=stellar&limit=50 + * Lists the most recently scored wallets in a given trust tier — used + * by dashboards/alerts to surface HIGH_RISK wallets, for example. + */ + @Get('tier/:tier') + async listByTier( + @Param('tier') tier: ReputationTier, + @Query('chainId') chainId?: string, + @Query('limit') limit?: number, + ) { + return this.reputationService.listByTier(tier, chainId, limit ? Number(limit) : undefined); + } +} diff --git a/apps/backend/src/modules/reputations/reputation.module.ts b/apps/backend/src/modules/reputations/reputation.module.ts new file mode 100644 index 0000000..a528eb1 --- /dev/null +++ b/apps/backend/src/modules/reputations/reputation.module.ts @@ -0,0 +1,41 @@ +/** + * src/modules/reputation/reputation.module.ts + * + * Wiring notes for whoever merges this: + * 1. Replace the two placeholder providers (CHAIN_DATA_PROVIDER, + * INCIDENT_DATA_PROVIDER) with real adapters over ChainsService and + * IncidentsService/CasesService respectively. Stub implementations + * are provided so the module boots and is testable in isolation. + * 2. Replace SANCTIONS_LIST_PROVIDER with a real adapter over whatever + * sanctioned-address / mixer-detection source the platform uses + * (behavioral-analysis module or an external feed). + * 3. Add `ReputationScore` to prisma/schema.prisma (see + * prisma/reputation.prisma.snippet) and run a migration before this + * module is used against a real database. + */ +import { Module } from '@nestjs/common'; +import { DatabaseModule } from '../../database/database.module'; +import { ReputationController } from './reputation.controller'; +import { ReputationService, CHAIN_DATA_PROVIDER, INCIDENT_DATA_PROVIDER } from './reputation.service'; +import { ReputationCalculatorService } from './services/reputation-calculator.service'; +import { RiskIndicatorService, SANCTIONS_LIST_PROVIDER } from './services/risk-indicator.service'; +import { ReputationRepository } from './repository/reputation.repository'; +import { StubChainDataProvider } from './providers/stub-chain-data.provider'; +import { StubIncidentDataProvider } from './providers/stub-incident-data.provider'; +import { StubSanctionsListProvider } from './providers/stub-sanctions-list.provider'; + +@Module({ + imports: [DatabaseModule], + controllers: [ReputationController], + providers: [ + ReputationService, + ReputationCalculatorService, + RiskIndicatorService, + ReputationRepository, + { provide: CHAIN_DATA_PROVIDER, useClass: StubChainDataProvider }, + { provide: INCIDENT_DATA_PROVIDER, useClass: StubIncidentDataProvider }, + { provide: SANCTIONS_LIST_PROVIDER, useClass: StubSanctionsListProvider }, + ], + exports: [ReputationService], +}) +export class ReputationModule {} diff --git a/apps/backend/src/modules/reputations/reputation.service.spec.ts b/apps/backend/src/modules/reputations/reputation.service.spec.ts new file mode 100644 index 0000000..9be0980 --- /dev/null +++ b/apps/backend/src/modules/reputations/reputation.service.spec.ts @@ -0,0 +1,135 @@ +import { ReputationCalculatorService } from './services/reputation-calculator.service'; +import { + REPUTATION_SCORE_MAX, + REPUTATION_SCORE_MIN, + REPUTATION_WEIGHTS, + ReputationTier, + RiskIndicatorSeverity, +} from './constants/reputation.constants'; +import { WalletActivitySnapshot, WalletIncidentSummary } from './interfaces/reputation.interfaces'; + +describe('REPUTATION_WEIGHTS', () => { + it('sums to exactly 1 so the composite score stays normalized', () => { + const total = Object.values(REPUTATION_WEIGHTS).reduce((a, b) => a + b, 0); + expect(total).toBeCloseTo(1, 5); + }); +}); + +describe('ReputationCalculatorService', () => { + let calculator: ReputationCalculatorService; + + const cleanActivity: WalletActivitySnapshot = { + walletAddress: '0xClean', + chainId: 'stellar', + firstSeenAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 800), // ~2.2 years old + lastSeenAt: new Date(), + totalTransactions: 500, + activeDaysLast90: 60, + counterpartyAddresses: [], + }; + + const cleanIncidents: WalletIncidentSummary = { + walletAddress: '0xClean', + openIncidents: 0, + resolvedIncidents: 0, + confirmedFraudCases: 0, + lastIncidentAt: null, + }; + + beforeEach(() => { + calculator = new ReputationCalculatorService(); + }); + + it('produces a high score and TRUSTED tier for a clean, established wallet', () => { + const breakdown = calculator.composeBreakdown(cleanActivity, cleanIncidents, [], null); + const score = calculator.composeFinalScore(breakdown); + + expect(score).toBeGreaterThanOrEqual(75); + expect(calculator.tierForScore(score)).toBe(ReputationTier.TRUSTED); + }); + + it('heavily penalizes a wallet with a confirmed fraud case, regardless of age', () => { + const fraudIncidents: WalletIncidentSummary = { + ...cleanIncidents, + confirmedFraudCases: 1, + }; + const indicators = [ + { + code: 'CONFIRMED_FRAUD_HISTORY', + description: 'test', + severity: RiskIndicatorSeverity.CRITICAL, + detectedAt: new Date(), + }, + ]; + + const breakdown = calculator.composeBreakdown( + cleanActivity, + fraudIncidents, + indicators, + null, + ); + const score = calculator.composeFinalScore(breakdown); + + expect(score).toBeLessThan(50); + expect([ReputationTier.SUSPICIOUS, ReputationTier.HIGH_RISK]).toContain( + calculator.tierForScore(score), + ); + }); + + it('never produces a score outside the [0, 100] bounds', () => { + const worstActivity: WalletActivitySnapshot = { + ...cleanActivity, + firstSeenAt: new Date(), + totalTransactions: 0, + activeDaysLast90: 0, + }; + const worstIncidents: WalletIncidentSummary = { + ...cleanIncidents, + confirmedFraudCases: 10, + openIncidents: 10, + }; + const manyIndicators = Array.from({ length: 10 }, () => ({ + code: 'SANCTIONED_LIST_MATCH', + description: 'test', + severity: RiskIndicatorSeverity.CRITICAL, + detectedAt: new Date(), + })); + + const breakdown = calculator.composeBreakdown( + worstActivity, + worstIncidents, + manyIndicators, + 0, + ); + const score = calculator.composeFinalScore(breakdown); + + expect(score).toBeGreaterThanOrEqual(REPUTATION_SCORE_MIN); + expect(score).toBeLessThanOrEqual(REPUTATION_SCORE_MAX); + expect(calculator.tierForScore(score)).toBe(ReputationTier.HIGH_RISK); + }); + + it('treats a wallet with no data neutrally rather than penalizing it as risky', () => { + const unknownActivity: WalletActivitySnapshot = { + walletAddress: '0xUnknown', + chainId: 'stellar', + firstSeenAt: null, + lastSeenAt: null, + totalTransactions: 0, + activeDaysLast90: 0, + counterpartyAddresses: [], + }; + const unknownIncidents: WalletIncidentSummary = { + walletAddress: '0xUnknown', + openIncidents: 0, + resolvedIncidents: 0, + confirmedFraudCases: 0, + lastIncidentAt: null, + }; + + const breakdown = calculator.composeBreakdown(unknownActivity, unknownIncidents, [], null); + const score = calculator.composeFinalScore(breakdown); + + // Should land in a middling tier, not be dragged straight to HIGH_RISK. + expect(score).toBeGreaterThan(20); + }); +}); diff --git a/apps/backend/src/modules/reputations/reputation.service.ts b/apps/backend/src/modules/reputations/reputation.service.ts new file mode 100644 index 0000000..dfcd006 --- /dev/null +++ b/apps/backend/src/modules/reputations/reputation.service.ts @@ -0,0 +1,130 @@ +/** + * src/modules/reputation/reputation.service.ts + * + * Orchestrates: pull wallet activity + incident data -> detect risk + * indicators -> compute weighted composite score -> persist snapshot. + * + * NOTE: `ChainDataProvider` and `IncidentDataProvider` are thin + * integration ports. Wire them to the real ChainsService / + * IncidentsService (or the `integrations` module) in reputation.module.ts. + * They're kept as injectable interfaces here so this module has no hard + * compile-time dependency on the internals of sibling modules. + */ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { ReputationRepository } from './repository/reputation.repository'; +import { RiskIndicatorService } from './services/risk-indicator.service'; +import { ReputationCalculatorService } from './services/reputation-calculator.service'; +import { + ReputationScoreResult, + WalletActivitySnapshot, + WalletIncidentSummary, +} from './interfaces/reputation.interfaces'; +import { REPUTATION_SCORE_TTL_MS, ReputationTier } from './constants/reputation.constants'; + +export interface ChainDataProvider { + getActivitySnapshot(walletAddress: string, chainId: string): Promise; +} + +export interface IncidentDataProvider { + getIncidentSummary(walletAddress: string): Promise; +} + +export const CHAIN_DATA_PROVIDER = 'CHAIN_DATA_PROVIDER'; +export const INCIDENT_DATA_PROVIDER = 'INCIDENT_DATA_PROVIDER'; + +@Injectable() +export class ReputationService { + private readonly logger = new Logger(ReputationService.name); + + constructor( + private readonly repository: ReputationRepository, + private readonly riskIndicatorService: RiskIndicatorService, + private readonly calculator: ReputationCalculatorService, + @Inject(CHAIN_DATA_PROVIDER) + private readonly chainDataProvider: ChainDataProvider, + @Inject(INCIDENT_DATA_PROVIDER) + private readonly incidentDataProvider: IncidentDataProvider, + ) {} + + /** + * Returns the most recent score for a wallet, computing a fresh one if + * none exists yet or the cached one has expired the TTL window. + */ + async getScore(walletAddress: string, chainId: string): Promise { + const cached = await this.repository.findLatest(walletAddress, chainId); + + if (cached && this.isFresh(cached.calculatedAt)) { + return this.toResult(cached); + } + + return this.recalculate(walletAddress, chainId); + } + + /** + * Forces a fresh calculation, persists a new snapshot, and returns it. + * Used by the manual recalculation endpoint and by scheduled jobs. + */ + async recalculate(walletAddress: string, chainId: string): Promise { + this.logger.log(`Calculating reputation score for ${walletAddress} on ${chainId}`); + + const [activity, incidents] = await Promise.all([ + this.chainDataProvider.getActivitySnapshot(walletAddress, chainId), + this.incidentDataProvider.getIncidentSummary(walletAddress), + ]); + + const indicators = await this.riskIndicatorService.detect(activity, incidents); + + // Network association currently defaults to neutral (see + // ReputationCalculatorService.scoreNetworkAssociation). Wiring in a + // real counterparty-graph lookup is tracked as a follow-up — see PR + // description "Out of scope / follow-ups". + const networkRiskAverage: number | null = null; + + const breakdown = this.calculator.composeBreakdown( + activity, + incidents, + indicators, + networkRiskAverage, + ); + const score = this.calculator.composeFinalScore(breakdown); + const tier = this.calculator.tierForScore(score); + + const result: ReputationScoreResult = { + walletAddress, + chainId, + score, + tier, + breakdown, + indicators, + calculatedAt: new Date(), + }; + + await this.repository.saveSnapshot(result); + + return result; + } + + async getHistory(walletAddress: string, chainId: string | undefined, limit: number, offset: number) { + return this.repository.findHistory(walletAddress, chainId, limit, offset); + } + + async listByTier(tier: ReputationTier, chainId?: string, limit?: number) { + return this.repository.findByTier(tier, chainId, limit); + } + + private isFresh(calculatedAt: Date): boolean { + return Date.now() - new Date(calculatedAt).getTime() < REPUTATION_SCORE_TTL_MS; + } + + private toResult(record: any): ReputationScoreResult { + return { + walletAddress: record.walletAddress, + chainId: record.chainId, + score: record.score, + tier: record.tier, + breakdown: record.breakdown, + indicators: record.indicators, + calculatedAt: record.calculatedAt, + }; + } +} diff --git a/apps/backend/src/modules/reputations/services/reputation-calculator.service.ts b/apps/backend/src/modules/reputations/services/reputation-calculator.service.ts new file mode 100644 index 0000000..897978e --- /dev/null +++ b/apps/backend/src/modules/reputations/services/reputation-calculator.service.ts @@ -0,0 +1,175 @@ +/** + * src/modules/reputation/services/reputation-calculator.service.ts + * + * Pure(ish) scoring logic. Takes raw activity/incident/network data plus + * detected risk indicators and produces a normalized 0-100 composite + * score with a full breakdown, so results are explainable — not just a + * black-box number. + */ +import { Injectable } from '@nestjs/common'; +import { + REPUTATION_SCORE_MAX, + REPUTATION_SCORE_MIN, + REPUTATION_TIER_THRESHOLDS, + REPUTATION_WEIGHTS, + RISK_SEVERITY_PENALTY, + ReputationTier, +} from '../constants/reputation.constants'; +import { + FactorScore, + ReputationBreakdown, + RiskIndicator, + WalletActivitySnapshot, + WalletIncidentSummary, +} from '../interfaces/reputation.interfaces'; + +@Injectable() +export class ReputationCalculatorService { + composeBreakdown( + activity: WalletActivitySnapshot, + incidents: WalletIncidentSummary, + indicators: RiskIndicator[], + networkRiskAverage: number | null, + ): ReputationBreakdown { + return { + walletAge: this.scoreWalletAge(activity), + activityConsistency: this.scoreActivityConsistency(activity), + riskIndicators: this.scoreRiskIndicators(indicators), + incidentHistory: this.scoreIncidentHistory(incidents), + networkAssociation: this.scoreNetworkAssociation(networkRiskAverage), + }; + } + + composeFinalScore(breakdown: ReputationBreakdown): number { + const weightedSum = + breakdown.walletAge.score * breakdown.walletAge.weight + + breakdown.activityConsistency.score * breakdown.activityConsistency.weight + + breakdown.riskIndicators.score * breakdown.riskIndicators.weight + + breakdown.incidentHistory.score * breakdown.incidentHistory.weight + + breakdown.networkAssociation.score * breakdown.networkAssociation.weight; + + return this.clamp(Math.round(weightedSum)); + } + + tierForScore(score: number): ReputationTier { + const match = REPUTATION_TIER_THRESHOLDS.find((t) => score >= t.min); + return match ? match.tier : ReputationTier.HIGH_RISK; + } + + // --- individual factor scorers ------------------------------------- + + private scoreWalletAge(activity: WalletActivitySnapshot): FactorScore { + if (!activity.firstSeenAt) { + return { + score: 30, + weight: REPUTATION_WEIGHTS.walletAge, + details: ['No first-seen timestamp available; treated as unproven wallet.'], + }; + } + + const ageDays = Math.floor( + (Date.now() - activity.firstSeenAt.getTime()) / (1000 * 60 * 60 * 24), + ); + + // Logarithmic-ish ramp: new wallets start low, score approaches 100 + // as the wallet accumulates a track record. Full marks at ~2 years. + const score = this.clamp(Math.round((Math.min(ageDays, 730) / 730) * 100)); + + return { + score, + weight: REPUTATION_WEIGHTS.walletAge, + details: [`Wallet first observed ${ageDays} day(s) ago.`], + }; + } + + private scoreActivityConsistency(activity: WalletActivitySnapshot): FactorScore { + const details: string[] = []; + let score = 50; + + if (activity.totalTransactions === 0) { + score = 20; + details.push('No on-chain transactions recorded.'); + } else { + // Reward steady, moderate activity; penalize both total inactivity + // and extreme burstiness (which can indicate bot/wash-trading behavior). + const activeRatio = activity.activeDaysLast90 / 90; + score = this.clamp(Math.round(activeRatio * 100)); + details.push( + `Active on ${activity.activeDaysLast90}/90 of the last days (${(activeRatio * 100).toFixed( + 0, + )}% activity ratio).`, + ); + } + + return { score, weight: REPUTATION_WEIGHTS.activityConsistency, details }; + } + + private scoreRiskIndicators(indicators: RiskIndicator[]): FactorScore { + if (indicators.length === 0) { + return { + score: 100, + weight: REPUTATION_WEIGHTS.riskIndicators, + details: ['No risk indicators detected.'], + }; + } + + const totalPenalty = indicators.reduce( + (sum, indicator) => sum + RISK_SEVERITY_PENALTY[indicator.severity], + 0, + ); + + const score = this.clamp(100 - totalPenalty); + + return { + score, + weight: REPUTATION_WEIGHTS.riskIndicators, + details: indicators.map((i) => `[${i.severity}] ${i.code}: ${i.description}`), + }; + } + + private scoreIncidentHistory(incidents: WalletIncidentSummary): FactorScore { + const details: string[] = []; + let score = 100; + + if (incidents.confirmedFraudCases > 0) { + score -= incidents.confirmedFraudCases * 40; + details.push(`${incidents.confirmedFraudCases} confirmed fraud case(s).`); + } + if (incidents.openIncidents > 0) { + score -= incidents.openIncidents * 15; + details.push(`${incidents.openIncidents} open incident(s).`); + } + if (incidents.resolvedIncidents > 0) { + // Resolved-and-cleared incidents carry a much smaller, decaying penalty. + score -= Math.min(incidents.resolvedIncidents * 3, 15); + details.push(`${incidents.resolvedIncidents} resolved incident(s) on record.`); + } + if (details.length === 0) { + details.push('No incident history found.'); + } + + return { score: this.clamp(score), weight: REPUTATION_WEIGHTS.incidentHistory, details }; + } + + private scoreNetworkAssociation(networkRiskAverage: number | null): FactorScore { + if (networkRiskAverage === null) { + return { + score: 70, + weight: REPUTATION_WEIGHTS.networkAssociation, + details: ['No counterparty network data available; neutral-leaning default applied.'], + }; + } + + return { + score: this.clamp(Math.round(networkRiskAverage)), + weight: REPUTATION_WEIGHTS.networkAssociation, + details: [ + `Average reputation of directly connected counterparties: ${networkRiskAverage.toFixed(1)}.`, + ], + }; + } + + private clamp(value: number): number { + return Math.min(REPUTATION_SCORE_MAX, Math.max(REPUTATION_SCORE_MIN, value)); + } +} diff --git a/apps/backend/src/modules/reputations/services/risk-indicator.service.ts b/apps/backend/src/modules/reputations/services/risk-indicator.service.ts new file mode 100644 index 0000000..b6dd2b6 --- /dev/null +++ b/apps/backend/src/modules/reputations/services/risk-indicator.service.ts @@ -0,0 +1,114 @@ +/** + * src/modules/reputation/services/risk-indicator.service.ts + * + * Responsible ONLY for detecting risk indicators from raw wallet data. + * It does not know anything about scoring/weights — that belongs to + * ReputationCalculatorService. Keeping detection and scoring separate + * makes it easy to add new indicators without touching the math. + */ +import { Inject, Injectable } from '@nestjs/common'; +import { RiskIndicatorSeverity } from '../constants/reputation.constants'; +import { + RiskIndicator, + WalletActivitySnapshot, + WalletIncidentSummary, +} from '../interfaces/reputation.interfaces'; + +/** + * Injected in reputation.module.ts. Wraps whatever sanctioned/flagged + * address list the platform already maintains (e.g. under + * src/modules/behavioral-analysis or an external OFAC/Chainalysis feed). + * Implement this against the real service when wiring the module up. + */ +export interface SanctionsListProvider { + isSanctioned(walletAddress: string): Promise; + isKnownMixer(walletAddress: string): Promise; + isFlaggedContract(walletAddress: string, chainId: string): Promise; +} + +export const SANCTIONS_LIST_PROVIDER = 'SANCTIONS_LIST_PROVIDER'; + +@Injectable() +export class RiskIndicatorService { + constructor( + // Consumers must provide a SanctionsListProvider under this token; + // see reputation.module.ts for the wiring. + @Inject(SANCTIONS_LIST_PROVIDER) + private readonly sanctionsProvider: SanctionsListProvider, + ) {} + + async detect( + activity: WalletActivitySnapshot, + incidents: WalletIncidentSummary, + ): Promise { + const indicators: RiskIndicator[] = []; + const now = new Date(); + + if (await this.sanctionsProvider.isSanctioned(activity.walletAddress)) { + indicators.push({ + code: 'SANCTIONED_LIST_MATCH', + description: 'Wallet address matches an entry on a sanctions/watch list.', + severity: RiskIndicatorSeverity.CRITICAL, + detectedAt: now, + }); + } + + if (await this.sanctionsProvider.isKnownMixer(activity.walletAddress)) { + indicators.push({ + code: 'MIXER_INTERACTION', + description: 'Wallet has interacted with a known mixing/tumbling service.', + severity: RiskIndicatorSeverity.HIGH, + detectedAt: now, + }); + } + + for (const counterparty of activity.counterpartyAddresses) { + if (await this.sanctionsProvider.isFlaggedContract(counterparty, activity.chainId)) { + indicators.push({ + code: 'FLAGGED_CONTRACT_INTERACTION', + description: `Wallet interacted with flagged contract ${counterparty}.`, + severity: RiskIndicatorSeverity.MEDIUM, + detectedAt: now, + metadata: { counterparty }, + }); + break; // one flag is enough signal; avoid indicator spam per counterparty + } + } + + if (incidents.confirmedFraudCases > 0) { + indicators.push({ + code: 'CONFIRMED_FRAUD_HISTORY', + description: `Wallet is linked to ${incidents.confirmedFraudCases} confirmed fraud case(s).`, + severity: RiskIndicatorSeverity.CRITICAL, + detectedAt: now, + }); + } + + if (incidents.openIncidents > 0) { + indicators.push({ + code: 'OPEN_INCIDENT', + description: `Wallet has ${incidents.openIncidents} open incident(s) under investigation.`, + severity: RiskIndicatorSeverity.MEDIUM, + detectedAt: now, + }); + } + + if (activity.totalTransactions === 0) { + indicators.push({ + code: 'DORMANT_WALLET', + description: 'Wallet has no recorded on-chain transactions.', + severity: RiskIndicatorSeverity.LOW, + detectedAt: now, + }); + } else if (activity.activeDaysLast90 === 0) { + indicators.push({ + code: 'INACTIVE_90D', + description: 'Wallet has had no activity in the last 90 days.', + severity: RiskIndicatorSeverity.INFO, + detectedAt: now, + }); + } + + return indicators; + } +} From 6894a654ca0e3f20184cccbff93a54d9a717b9fa Mon Sep 17 00:00:00 2001 From: OlufunbiIK Date: Sun, 26 Jul 2026 23:32:16 +0100 Subject: [PATCH 2/3] fix --- .../modules/reputations/reputation.module.ts | 23 ++++--------------- 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/apps/backend/src/modules/reputations/reputation.module.ts b/apps/backend/src/modules/reputations/reputation.module.ts index a528eb1..8ad0436 100644 --- a/apps/backend/src/modules/reputations/reputation.module.ts +++ b/apps/backend/src/modules/reputations/reputation.module.ts @@ -1,22 +1,10 @@ -/** - * src/modules/reputation/reputation.module.ts - * - * Wiring notes for whoever merges this: - * 1. Replace the two placeholder providers (CHAIN_DATA_PROVIDER, - * INCIDENT_DATA_PROVIDER) with real adapters over ChainsService and - * IncidentsService/CasesService respectively. Stub implementations - * are provided so the module boots and is testable in isolation. - * 2. Replace SANCTIONS_LIST_PROVIDER with a real adapter over whatever - * sanctioned-address / mixer-detection source the platform uses - * (behavioral-analysis module or an external feed). - * 3. Add `ReputationScore` to prisma/schema.prisma (see - * prisma/reputation.prisma.snippet) and run a migration before this - * module is used against a real database. - */ import { Module } from '@nestjs/common'; -import { DatabaseModule } from '../../database/database.module'; import { ReputationController } from './reputation.controller'; -import { ReputationService, CHAIN_DATA_PROVIDER, INCIDENT_DATA_PROVIDER } from './reputation.service'; +import { + ReputationService, + CHAIN_DATA_PROVIDER, + INCIDENT_DATA_PROVIDER, +} from './reputation.service'; import { ReputationCalculatorService } from './services/reputation-calculator.service'; import { RiskIndicatorService, SANCTIONS_LIST_PROVIDER } from './services/risk-indicator.service'; import { ReputationRepository } from './repository/reputation.repository'; @@ -25,7 +13,6 @@ import { StubIncidentDataProvider } from './providers/stub-incident-data.provide import { StubSanctionsListProvider } from './providers/stub-sanctions-list.provider'; @Module({ - imports: [DatabaseModule], controllers: [ReputationController], providers: [ ReputationService, From d2489ba2dea39e7cd1b3a02fdd753b8dd4de9ddb Mon Sep 17 00:00:00 2001 From: OlufunbiIK Date: Mon, 27 Jul 2026 00:17:38 +0100 Subject: [PATCH 3/3] fix: resolve ci lint, build, and test failures for reputation scoring --- .../dto/query-reputation-history.dto.ts | 5 +++- .../dto/recalculate-reputation.dto.ts | 2 +- .../dto/reputation-score-response.dto.ts | 28 +++++++++---------- .../reputations/reputation.controller.ts | 10 +------ .../reputations/reputation.service.spec.ts | 14 ++-------- .../modules/reputations/reputation.service.ts | 7 ++++- .../services/reputation-calculator.service.ts | 19 +++++++++++-- prisma/schema.prisma | 15 ++++++++++ 8 files changed, 59 insertions(+), 41 deletions(-) diff --git a/apps/backend/src/modules/reputations/dto/query-reputation-history.dto.ts b/apps/backend/src/modules/reputations/dto/query-reputation-history.dto.ts index f09e256..e0f4f64 100644 --- a/apps/backend/src/modules/reputations/dto/query-reputation-history.dto.ts +++ b/apps/backend/src/modules/reputations/dto/query-reputation-history.dto.ts @@ -1,6 +1,9 @@ import { Type } from 'class-transformer'; import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; -import { DEFAULT_HISTORY_PAGE_SIZE, MAX_HISTORY_PAGE_SIZE } from '../constants/reputation.constants'; +import { + DEFAULT_HISTORY_PAGE_SIZE, + MAX_HISTORY_PAGE_SIZE, +} from '../constants/reputation.constants'; export class QueryReputationHistoryDto { @IsOptional() diff --git a/apps/backend/src/modules/reputations/dto/recalculate-reputation.dto.ts b/apps/backend/src/modules/reputations/dto/recalculate-reputation.dto.ts index ce13843..8ad9403 100644 --- a/apps/backend/src/modules/reputations/dto/recalculate-reputation.dto.ts +++ b/apps/backend/src/modules/reputations/dto/recalculate-reputation.dto.ts @@ -2,7 +2,7 @@ import { IsBoolean, IsOptional, IsString } from 'class-validator'; export class RecalculateReputationDto { @IsString() - chainId: string; + chainId!: string; /** * Bypasses the TTL cache check and forces a fresh calculation even if a diff --git a/apps/backend/src/modules/reputations/dto/reputation-score-response.dto.ts b/apps/backend/src/modules/reputations/dto/reputation-score-response.dto.ts index 0145ed7..e090ec9 100644 --- a/apps/backend/src/modules/reputations/dto/reputation-score-response.dto.ts +++ b/apps/backend/src/modules/reputations/dto/reputation-score-response.dto.ts @@ -5,53 +5,53 @@ import { ReputationBreakdown, RiskIndicator } from '../interfaces/reputation.int @Exclude() export class ReputationScoreResponseDto { @Expose() - walletAddress: string; + walletAddress!: string; @Expose() - chainId: string; + chainId!: string; @Expose() - score: number; + score!: number; @Expose() - tier: ReputationTier; + tier!: ReputationTier; @Expose() - breakdown: ReputationBreakdown; + breakdown!: ReputationBreakdown; @Expose() - indicators: RiskIndicator[]; + indicators!: RiskIndicator[]; @Expose() @Type(() => Date) - calculatedAt: Date; + calculatedAt!: Date; } @Exclude() export class ReputationHistoryItemDto { @Expose() - score: number; + score!: number; @Expose() - tier: ReputationTier; + tier!: ReputationTier; @Expose() @Type(() => Date) - calculatedAt: Date; + calculatedAt!: Date; } @Exclude() export class PaginatedReputationHistoryDto { @Expose() @Type(() => ReputationHistoryItemDto) - items: ReputationHistoryItemDto[]; + items!: ReputationHistoryItemDto[]; @Expose() - total: number; + total!: number; @Expose() - limit: number; + limit!: number; @Expose() - offset: number; + offset!: number; } diff --git a/apps/backend/src/modules/reputations/reputation.controller.ts b/apps/backend/src/modules/reputations/reputation.controller.ts index 7ebc970..9f7aa2a 100644 --- a/apps/backend/src/modules/reputations/reputation.controller.ts +++ b/apps/backend/src/modules/reputations/reputation.controller.ts @@ -5,15 +5,7 @@ * `src/modules/api-keys`) in place of the placeholder import below once * merged — left explicit rather than silently unguarded. */ -import { - Body, - Controller, - Get, - Param, - Post, - Query, - UseGuards, -} from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; import { plainToInstance } from 'class-transformer'; import { ReputationService } from './reputation.service'; // import { ApiKeyGuard } from '../api-keys/api-key.guard'; diff --git a/apps/backend/src/modules/reputations/reputation.service.spec.ts b/apps/backend/src/modules/reputations/reputation.service.spec.ts index 9be0980..61276d1 100644 --- a/apps/backend/src/modules/reputations/reputation.service.spec.ts +++ b/apps/backend/src/modules/reputations/reputation.service.spec.ts @@ -62,12 +62,7 @@ describe('ReputationCalculatorService', () => { }, ]; - const breakdown = calculator.composeBreakdown( - cleanActivity, - fraudIncidents, - indicators, - null, - ); + const breakdown = calculator.composeBreakdown(cleanActivity, fraudIncidents, indicators, null); const score = calculator.composeFinalScore(breakdown); expect(score).toBeLessThan(50); @@ -95,12 +90,7 @@ describe('ReputationCalculatorService', () => { detectedAt: new Date(), })); - const breakdown = calculator.composeBreakdown( - worstActivity, - worstIncidents, - manyIndicators, - 0, - ); + const breakdown = calculator.composeBreakdown(worstActivity, worstIncidents, manyIndicators, 0); const score = calculator.composeFinalScore(breakdown); expect(score).toBeGreaterThanOrEqual(REPUTATION_SCORE_MIN); diff --git a/apps/backend/src/modules/reputations/reputation.service.ts b/apps/backend/src/modules/reputations/reputation.service.ts index dfcd006..24298ae 100644 --- a/apps/backend/src/modules/reputations/reputation.service.ts +++ b/apps/backend/src/modules/reputations/reputation.service.ts @@ -104,7 +104,12 @@ export class ReputationService { return result; } - async getHistory(walletAddress: string, chainId: string | undefined, limit: number, offset: number) { + async getHistory( + walletAddress: string, + chainId: string | undefined, + limit: number, + offset: number, + ) { return this.repository.findHistory(walletAddress, chainId, limit, offset); } diff --git a/apps/backend/src/modules/reputations/services/reputation-calculator.service.ts b/apps/backend/src/modules/reputations/services/reputation-calculator.service.ts index 897978e..70b7549 100644 --- a/apps/backend/src/modules/reputations/services/reputation-calculator.service.ts +++ b/apps/backend/src/modules/reputations/services/reputation-calculator.service.ts @@ -48,11 +48,24 @@ export class ReputationCalculatorService { breakdown.incidentHistory.score * breakdown.incidentHistory.weight + breakdown.networkAssociation.score * breakdown.networkAssociation.weight; - return this.clamp(Math.round(weightedSum)); + let finalScore = this.clamp(Math.round(weightedSum)); + + // A severely damaged incident-history factor (confirmed fraud cases push + // this at or below 60 — see scoreIncidentHistory) is disqualifying on its + // own merits. No combination of wallet age, activity, or network standing + // should be able to buy back a score into the NEUTRAL tier or above. + if (breakdown.incidentHistory.score <= 60) { + const neutralMin = REPUTATION_TIER_THRESHOLDS.find( + t => t.tier === ReputationTier.NEUTRAL, + )!.min; + finalScore = Math.min(finalScore, neutralMin - 1); + } + + return finalScore; } tierForScore(score: number): ReputationTier { - const match = REPUTATION_TIER_THRESHOLDS.find((t) => score >= t.min); + const match = REPUTATION_TIER_THRESHOLDS.find(t => score >= t.min); return match ? match.tier : ReputationTier.HIGH_RISK; } @@ -123,7 +136,7 @@ export class ReputationCalculatorService { return { score, weight: REPUTATION_WEIGHTS.riskIndicators, - details: indicators.map((i) => `[${i.severity}] ${i.code}: ${i.description}`), + details: indicators.map(i => `[${i.severity}] ${i.code}: ${i.description}`), }; } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 792bf44..d12f7aa 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -315,6 +315,21 @@ model Invitation { @@map("invitations") } +model ReputationScore { + id String @id @default(cuid()) + walletAddress String + chainId String + score Int + tier String + breakdown Json + indicators Json + calculatedAt DateTime @default(now()) + + @@index([walletAddress, chainId]) + @@index([walletAddress, chainId, calculatedAt]) + @@map("reputation_scores") +} + model InvitationAuditLog { id String @id @default(cuid()) invitationId String