From aa2c889663ee62902bc5db56cbb2febfb6121cf1 Mon Sep 17 00:00:00 2001 From: mftee Date: Fri, 19 Jun 2026 16:30:00 +0000 Subject: [PATCH] feat(analytics): implement behavioral wallet analysis module Add apps/backend/src/modules/behavioral-analysis/ with: - buildProfile(): historical wallet profiling from transactions - buildBaseline(): compute avg amounts, counterparties, assets - detectAnomalies(): detect volume spikes, new counterparties, unusual assets - analyze(): full pipeline returning profile + baseline + anomalies Includes interfaces and unit tests covering all acceptance criteria. --- .../behavioral-analysis.module.ts | 8 ++ .../behavioral-analysis.service.spec.ts | 110 ++++++++++++++++ .../behavioral-analysis.service.ts | 119 ++++++++++++++++++ .../behavioral-analysis.interface.ts | 41 ++++++ 4 files changed, 278 insertions(+) create mode 100644 apps/backend/src/modules/behavioral-analysis/behavioral-analysis.module.ts create mode 100644 apps/backend/src/modules/behavioral-analysis/behavioral-analysis.service.spec.ts create mode 100644 apps/backend/src/modules/behavioral-analysis/behavioral-analysis.service.ts create mode 100644 apps/backend/src/modules/behavioral-analysis/interfaces/behavioral-analysis.interface.ts diff --git a/apps/backend/src/modules/behavioral-analysis/behavioral-analysis.module.ts b/apps/backend/src/modules/behavioral-analysis/behavioral-analysis.module.ts new file mode 100644 index 0000000..c2d1845 --- /dev/null +++ b/apps/backend/src/modules/behavioral-analysis/behavioral-analysis.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { BehavioralAnalysisService } from './behavioral-analysis.service'; + +@Module({ + providers: [BehavioralAnalysisService], + exports: [BehavioralAnalysisService], +}) +export class BehavioralAnalysisModule {} \ No newline at end of file diff --git a/apps/backend/src/modules/behavioral-analysis/behavioral-analysis.service.spec.ts b/apps/backend/src/modules/behavioral-analysis/behavioral-analysis.service.spec.ts new file mode 100644 index 0000000..0921d16 --- /dev/null +++ b/apps/backend/src/modules/behavioral-analysis/behavioral-analysis.service.spec.ts @@ -0,0 +1,110 @@ +import 'reflect-metadata'; +import { Test, TestingModule } from '@nestjs/testing'; +import { BehavioralAnalysisService } from './behavioral-analysis.service'; +import { WalletTransaction } from './interfaces/behavioral-analysis.interface'; + +const TX = (overrides: Partial = {}): WalletTransaction => ({ + txHash: 'tx1', + walletAddress: 'WALLET_A', + counterparty: 'WALLET_B', + asset: 'XLM', + amount: 100, + timestamp: '2026-01-01T00:00:00Z', + chain: 'Stellar', + ...overrides, +}); + +const HISTORY: WalletTransaction[] = [ + TX({ txHash: 'h1', timestamp: '2026-01-01T00:00:00Z', amount: 100 }), + TX({ txHash: 'h2', timestamp: '2026-01-02T00:00:00Z', amount: 120, counterparty: 'WALLET_C' }), + TX({ txHash: 'h3', timestamp: '2026-01-03T00:00:00Z', amount: 80 }), +]; + +describe('BehavioralAnalysisService', () => { + let service: BehavioralAnalysisService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [BehavioralAnalysisService], + }).compile(); + service = module.get(BehavioralAnalysisService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('buildProfile', () => { + it('builds correct profile from transactions', () => { + const profile = service.buildProfile(HISTORY); + expect(profile.walletAddress).toBe('WALLET_A'); + expect(profile.totalTransactions).toBe(3); + expect(profile.totalVolume).toBe(300); + expect(profile.uniqueCounterparties).toBe(2); + }); + + it('sets firstSeen and lastSeen correctly', () => { + const profile = service.buildProfile(HISTORY); + expect(profile.firstSeen).toBe('2026-01-01T00:00:00Z'); + expect(profile.lastSeen).toBe('2026-01-03T00:00:00Z'); + }); + + it('throws when no transactions provided', () => { + expect(() => service.buildProfile([])).toThrow(); + }); + }); + + describe('buildBaseline', () => { + it('computes avgTransactionAmount correctly', () => { + const baseline = service.buildBaseline(HISTORY); + expect(baseline.avgTransactionAmount).toBeCloseTo(100, 1); + }); + + it('includes all unique assets and counterparties', () => { + const baseline = service.buildBaseline(HISTORY); + expect(baseline.typicalAssets).toContain('XLM'); + expect(baseline.typicalCounterparties).toContain('WALLET_B'); + expect(baseline.typicalCounterparties).toContain('WALLET_C'); + }); + }); + + describe('detectAnomalies', () => { + it('detects volume spike when amount exceeds 3x baseline average', () => { + const baseline = service.buildBaseline(HISTORY); + const recent = [TX({ txHash: 'r1', amount: 1000 })]; + const anomalies = service.detectAnomalies(recent, baseline); + expect(anomalies.some(a => a.type === 'volume_spike')).toBe(true); + }); + + it('detects new counterparty not in baseline', () => { + const baseline = service.buildBaseline(HISTORY); + const recent = [TX({ txHash: 'r2', counterparty: 'WALLET_UNKNOWN' })]; + const anomalies = service.detectAnomalies(recent, baseline); + expect(anomalies.some(a => a.type === 'new_counterparty')).toBe(true); + }); + + it('detects unusual asset not in baseline', () => { + const baseline = service.buildBaseline(HISTORY); + const recent = [TX({ txHash: 'r3', asset: 'USDC' })]; + const anomalies = service.detectAnomalies(recent, baseline); + expect(anomalies.some(a => a.type === 'unusual_asset')).toBe(true); + }); + + it('returns no anomalies for normal behaviour', () => { + const baseline = service.buildBaseline(HISTORY); + const recent = [TX({ txHash: 'r4', amount: 110 })]; + const anomalies = service.detectAnomalies(recent, baseline); + expect(anomalies.every(a => a.type !== 'volume_spike')).toBe(true); + }); + }); + + describe('analyze', () => { + it('returns profile, baseline and anomalies together', () => { + const result = service.analyze(HISTORY, [TX({ txHash: 'r5', amount: 5000 })]); + expect(result.profile).toBeDefined(); + expect(result.baseline).toBeDefined(); + expect(result.anomalies.length).toBeGreaterThan(0); + expect(new Date(result.generatedAt).toISOString()).toBe(result.generatedAt); + }); + }); +}); \ No newline at end of file diff --git a/apps/backend/src/modules/behavioral-analysis/behavioral-analysis.service.ts b/apps/backend/src/modules/behavioral-analysis/behavioral-analysis.service.ts new file mode 100644 index 0000000..b20faad --- /dev/null +++ b/apps/backend/src/modules/behavioral-analysis/behavioral-analysis.service.ts @@ -0,0 +1,119 @@ +import { Injectable } from '@nestjs/common'; +import { + BehavioralAnalysisResult, + BehaviorBaseline, + WalletAnomaly, + WalletProfile, + WalletTransaction, +} from './interfaces/behavioral-analysis.interface'; + +@Injectable() +export class BehavioralAnalysisService { + /** Build a historical profile from a wallet's transactions. */ + buildProfile(transactions: WalletTransaction[]): WalletProfile { + if (transactions.length === 0) { + throw new Error('No transactions provided for profiling'); + } + + const sorted = [...transactions].sort((a, b) => + a.timestamp < b.timestamp ? -1 : a.timestamp > b.timestamp ? 1 : 0, + ); + + const totalVolume = transactions.reduce((sum, tx) => sum + tx.amount, 0); + const counterparties = new Set(transactions.map(tx => tx.counterparty)); + + return { + walletAddress: transactions[0].walletAddress, + chain: transactions[0].chain, + totalTransactions: transactions.length, + totalVolume, + uniqueCounterparties: counterparties.size, + firstSeen: sorted[0].timestamp, + lastSeen: sorted[sorted.length - 1].timestamp, + }; + } + + /** Establish a behavior baseline from historical transactions. */ + buildBaseline(transactions: WalletTransaction[]): BehaviorBaseline { + const days = this.uniqueDays(transactions); + const avgDailyTransactions = days > 0 ? transactions.length / days : transactions.length; + const avgTransactionAmount = + transactions.length > 0 + ? transactions.reduce((s, tx) => s + tx.amount, 0) / transactions.length + : 0; + + const counterparties = [...new Set(transactions.map(tx => tx.counterparty))]; + const assets = [...new Set(transactions.map(tx => tx.asset))]; + + return { + walletAddress: transactions[0]?.walletAddress ?? '', + avgDailyTransactions, + avgTransactionAmount, + typicalCounterparties: counterparties, + typicalAssets: assets, + }; + } + + /** Detect anomalies in recent transactions compared to the baseline. */ + detectAnomalies( + recentTxs: WalletTransaction[], + baseline: BehaviorBaseline, + ): WalletAnomaly[] { + const anomalies: WalletAnomaly[] = []; + const now = new Date().toISOString(); + + // Volume spike: any single tx > 3× average + for (const tx of recentTxs) { + if (baseline.avgTransactionAmount > 0 && tx.amount > baseline.avgTransactionAmount * 3) { + anomalies.push({ + walletAddress: tx.walletAddress, + type: 'volume_spike', + description: `Transaction ${tx.txHash} amount ${tx.amount} exceeds 3× baseline average`, + detectedAt: now, + }); + } + } + + // New counterparty not seen in baseline + for (const tx of recentTxs) { + if (!baseline.typicalCounterparties.includes(tx.counterparty)) { + anomalies.push({ + walletAddress: tx.walletAddress, + type: 'new_counterparty', + description: `Interaction with new counterparty ${tx.counterparty}`, + detectedAt: now, + }); + } + } + + // Unusual asset not seen in baseline + for (const tx of recentTxs) { + if (!baseline.typicalAssets.includes(tx.asset)) { + anomalies.push({ + walletAddress: tx.walletAddress, + type: 'unusual_asset', + description: `Unusual asset ${tx.asset} not present in baseline`, + detectedAt: now, + }); + } + } + + return anomalies; + } + + /** Run full behavioral analysis: profile + baseline + anomaly detection. */ + analyze( + historicalTxs: WalletTransaction[], + recentTxs: WalletTransaction[], + ): BehavioralAnalysisResult { + const profile = this.buildProfile(historicalTxs); + const baseline = this.buildBaseline(historicalTxs); + const anomalies = this.detectAnomalies(recentTxs, baseline); + + return { profile, baseline, anomalies, generatedAt: new Date().toISOString() }; + } + + private uniqueDays(txs: WalletTransaction[]): number { + return new Set(txs.map(tx => tx.timestamp.slice(0, 10))).size; + } +} \ No newline at end of file diff --git a/apps/backend/src/modules/behavioral-analysis/interfaces/behavioral-analysis.interface.ts b/apps/backend/src/modules/behavioral-analysis/interfaces/behavioral-analysis.interface.ts new file mode 100644 index 0000000..9adc710 --- /dev/null +++ b/apps/backend/src/modules/behavioral-analysis/interfaces/behavioral-analysis.interface.ts @@ -0,0 +1,41 @@ +export interface WalletTransaction { + txHash: string; + walletAddress: string; + counterparty: string; + asset: string; + amount: number; + timestamp: string; + chain: string; +} + +export interface WalletProfile { + walletAddress: string; + chain: string; + totalTransactions: number; + totalVolume: number; + uniqueCounterparties: number; + firstSeen: string; + lastSeen: string; +} + +export interface BehaviorBaseline { + walletAddress: string; + avgDailyTransactions: number; + avgTransactionAmount: number; + typicalCounterparties: string[]; + typicalAssets: string[]; +} + +export interface WalletAnomaly { + walletAddress: string; + type: 'volume_spike' | 'new_counterparty' | 'unusual_asset' | 'frequency_spike'; + description: string; + detectedAt: string; +} + +export interface BehavioralAnalysisResult { + profile: WalletProfile; + baseline: BehaviorBaseline; + anomalies: WalletAnomaly[]; + generatedAt: string; +} \ No newline at end of file