Skip to content
Merged
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 {}
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);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
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 > 3x 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 3x 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;
}
}
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;
}
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"resolveJsonModule": true,
"outDir": "./dist",
"rootDir": "./",
"types": ["node", "jest"],
"paths": {
"@common/*": ["./apps/backend/src/common/*"],
"@modules/*": ["./apps/backend/src/modules/*"],
Expand Down
Loading