Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { BehavioralAnalysisService } from './behavioral-analysis.service';

@Module({
providers: [BehavioralAnalysisService],
exports: [BehavioralAnalysisService],
})
export class BehavioralAnalysisModule {}

Check failure on line 8 in apps/backend/src/modules/behavioral-analysis/behavioral-analysis.module.ts

View workflow job for this annotation

GitHub Actions / Linting (22.x)

Insert `⏎`

Check failure on line 8 in apps/backend/src/modules/behavioral-analysis/behavioral-analysis.module.ts

View workflow job for this annotation

GitHub Actions / Linting (20.x)

Insert `⏎`
Original file line number Diff line number Diff line change
@@ -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> = {}): 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>(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);
});
});
});

Check failure on line 110 in apps/backend/src/modules/behavioral-analysis/behavioral-analysis.service.spec.ts

View workflow job for this annotation

GitHub Actions / Linting (22.x)

Insert `⏎`

Check failure on line 110 in apps/backend/src/modules/behavioral-analysis/behavioral-analysis.service.spec.ts

View workflow job for this annotation

GitHub Actions / Linting (20.x)

Insert `⏎`
Original file line number Diff line number Diff line change
@@ -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(

Check failure on line 58 in apps/backend/src/modules/behavioral-analysis/behavioral-analysis.service.ts

View workflow job for this annotation

GitHub Actions / Linting (22.x)

Replace `⏎····recentTxs:·WalletTransaction[],⏎····baseline:·BehaviorBaseline,⏎··` with `recentTxs:·WalletTransaction[],·baseline:·BehaviorBaseline`

Check failure on line 58 in apps/backend/src/modules/behavioral-analysis/behavioral-analysis.service.ts

View workflow job for this annotation

GitHub Actions / Linting (20.x)

Replace `⏎····recentTxs:·WalletTransaction[],⏎····baseline:·BehaviorBaseline,⏎··` with `recentTxs:·WalletTransaction[],·baseline:·BehaviorBaseline`
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;
}
}

Check failure on line 119 in apps/backend/src/modules/behavioral-analysis/behavioral-analysis.service.ts

View workflow job for this annotation

GitHub Actions / Linting (22.x)

Insert `⏎`

Check failure on line 119 in apps/backend/src/modules/behavioral-analysis/behavioral-analysis.service.ts

View workflow job for this annotation

GitHub Actions / Linting (20.x)

Insert `⏎`
Original file line number Diff line number Diff line change
@@ -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;
}

Check failure on line 41 in apps/backend/src/modules/behavioral-analysis/interfaces/behavioral-analysis.interface.ts

View workflow job for this annotation

GitHub Actions / Linting (22.x)

Insert `⏎`

Check failure on line 41 in apps/backend/src/modules/behavioral-analysis/interfaces/behavioral-analysis.interface.ts

View workflow job for this annotation

GitHub Actions / Linting (20.x)

Insert `⏎`
Loading