From 4576e5a3025f3db4f28bea23fcdff7dcbcd5b1a1 Mon Sep 17 00:00:00 2001 From: Chongai-Cli Date: Wed, 17 Jun 2026 08:23:46 +0100 Subject: [PATCH] Feat: Implemented High-Frequency Contract Calls detection --- apps/backend/src/app.module.ts | 3 +- .../interfaces/spike-detection.interface.ts | 22 +++ .../contracts/spike-detection.module.ts | 8 + .../contracts/spike-detection.service.spec.ts | 160 ++++++++++++++++++ .../contracts/spike-detection.service.ts | 85 ++++++++++ 5 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 apps/backend/src/modules/detection/contracts/interfaces/spike-detection.interface.ts create mode 100644 apps/backend/src/modules/detection/contracts/spike-detection.module.ts create mode 100644 apps/backend/src/modules/detection/contracts/spike-detection.service.spec.ts create mode 100644 apps/backend/src/modules/detection/contracts/spike-detection.service.ts diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 161d550..3833ed3 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -4,9 +4,10 @@ import { DatabaseModule } from '../../../database/database.module'; import { HealthModule } from './modules/health/health.module'; import { NotificationsModule } from './modules/notifications/notifications.module'; import { ReportingModule } from './modules/reporting/reporting.module'; +import { SpikeDetectionModule } from './modules/detection/contracts/spike-detection.module'; @Module({ - imports: [DatabaseModule, HealthModule, NotificationsModule, ReportingModule], + imports: [DatabaseModule, HealthModule, NotificationsModule, ReportingModule, SpikeDetectionModule], controllers: [AppController], }) export class AppModule {} diff --git a/apps/backend/src/modules/detection/contracts/interfaces/spike-detection.interface.ts b/apps/backend/src/modules/detection/contracts/interfaces/spike-detection.interface.ts new file mode 100644 index 0000000..8de2240 --- /dev/null +++ b/apps/backend/src/modules/detection/contracts/interfaces/spike-detection.interface.ts @@ -0,0 +1,22 @@ +export interface SpikeDetectionConfig { + /** Rolling window length in milliseconds used to count transactions. */ + windowMs: number; + /** How many multiples of the baseline triggers a spike alert (e.g. 3 = 3×). */ + multiplierThreshold: number; + /** Absolute minimum transaction count before spike logic activates. */ + minBaselineCount: number; +} + +export interface ContractActivityRecord { + contractAddress: string; + timestamps: number[]; // epoch ms of each observed transaction +} + +export interface SpikeAlert { + contractAddress: string; + detectedAt: string; // ISO-8601 + currentCount: number; + baselineCount: number; + multiplier: number; + severity: 'medium' | 'high' | 'critical'; +} diff --git a/apps/backend/src/modules/detection/contracts/spike-detection.module.ts b/apps/backend/src/modules/detection/contracts/spike-detection.module.ts new file mode 100644 index 0000000..43bcaf4 --- /dev/null +++ b/apps/backend/src/modules/detection/contracts/spike-detection.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { SpikeDetectionService } from './spike-detection.service'; + +@Module({ + providers: [SpikeDetectionService], + exports: [SpikeDetectionService], +}) +export class SpikeDetectionModule {} diff --git a/apps/backend/src/modules/detection/contracts/spike-detection.service.spec.ts b/apps/backend/src/modules/detection/contracts/spike-detection.service.spec.ts new file mode 100644 index 0000000..3a968c0 --- /dev/null +++ b/apps/backend/src/modules/detection/contracts/spike-detection.service.spec.ts @@ -0,0 +1,160 @@ +import 'reflect-metadata'; +import { Test, TestingModule } from '@nestjs/testing'; +import { SpikeDetectionService } from './spike-detection.service'; + +const CONTRACT = '0xDeAdBeEf'; + +/** Inject N transactions into the baseline window (previous windowMs). */ +function seedBaseline( + service: SpikeDetectionService, + count: number, + now: number, + windowMs: number, +) { + for (let i = 0; i < count; i++) { + service.recordTransaction(CONTRACT, now - windowMs - (i + 1) * 100); + } +} + +describe('SpikeDetectionService', () => { + let service: SpikeDetectionService; + const windowMs = 60_000; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [SpikeDetectionService], + }).compile(); + + service = module.get(SpikeDetectionService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('getConfig', () => { + it('returns default config values', () => { + const cfg = service.getConfig(); + expect(cfg.windowMs).toBe(60_000); + expect(cfg.multiplierThreshold).toBe(3); + expect(cfg.minBaselineCount).toBe(5); + }); + + it('respects partial config override', () => { + const custom = new SpikeDetectionService({ multiplierThreshold: 5 }); + expect(custom.getConfig().multiplierThreshold).toBe(5); + expect(custom.getConfig().windowMs).toBe(60_000); // default preserved + }); + }); + + describe('recordTransaction', () => { + it('returns null when baseline is below minBaselineCount', () => { + const now = Date.now(); + seedBaseline(service, 3, now, windowMs); // below min of 5 + const alert = service.recordTransaction(CONTRACT, now); + expect(alert).toBeNull(); + }); + + it('returns null when activity is within threshold', () => { + const now = Date.now(); + seedBaseline(service, 10, now, windowMs); // baseline = 10 + // current window: 5 transactions (0.5×, below threshold of 3×) + for (let i = 0; i < 5; i++) { + service.recordTransaction(CONTRACT, now - i * 100); + } + const alert = service.recordTransaction(CONTRACT, now); + expect(alert).toBeNull(); + }); + + it('returns a SpikeAlert when activity exceeds threshold', () => { + const now = Date.now(); + seedBaseline(service, 5, now, windowMs); // baseline = 5 + // current window: 20 transactions → 4× (> 3× threshold) + for (let i = 0; i < 19; i++) { + service.recordTransaction(CONTRACT, now - i * 100); + } + const alert = service.recordTransaction(CONTRACT, now); + expect(alert).not.toBeNull(); + expect(alert!.contractAddress).toBe(CONTRACT); + expect(alert!.multiplier).toBeGreaterThanOrEqual(3); + }); + + it('alert has a valid ISO detectedAt timestamp', () => { + const now = Date.now(); + seedBaseline(service, 5, now, windowMs); + for (let i = 0; i < 19; i++) { + service.recordTransaction(CONTRACT, now - i * 100); + } + const alert = service.recordTransaction(CONTRACT, now); + expect(alert).not.toBeNull(); + expect(new Date(alert!.detectedAt).toISOString()).toBe(alert!.detectedAt); + }); + + it('returns "medium" severity for 3–4.99× multiplier', () => { + const now = Date.now(); + seedBaseline(service, 5, now, windowMs); + for (let i = 0; i < 19; i++) { + service.recordTransaction(CONTRACT, now - i * 100); + } + const alert = service.recordTransaction(CONTRACT, now); + expect(alert).not.toBeNull(); + if (alert!.multiplier < 5) expect(alert!.severity).toBe('medium'); + }); + + it('returns "high" severity for 5–9.99× multiplier', () => { + const now = Date.now(); + seedBaseline(service, 5, now, windowMs); // baseline = 5 + // 30 txs in current window → 6× + for (let i = 0; i < 29; i++) { + service.recordTransaction(CONTRACT, now - i * 100); + } + const alert = service.recordTransaction(CONTRACT, now); + expect(alert).not.toBeNull(); + if (alert!.multiplier >= 5 && alert!.multiplier < 10) { + expect(alert!.severity).toBe('high'); + } + }); + + it('returns "critical" severity for ≥10× multiplier', () => { + const now = Date.now(); + seedBaseline(service, 5, now, windowMs); // baseline = 5 + // 55 txs in current window → 11× + for (let i = 0; i < 54; i++) { + service.recordTransaction(CONTRACT, now - i * 100); + } + const alert = service.recordTransaction(CONTRACT, now); + expect(alert).not.toBeNull(); + if (alert!.multiplier >= 10) expect(alert!.severity).toBe('critical'); + }); + + it('tracks multiple contracts independently', () => { + const other = '0xOtherContract'; + const now = Date.now(); + seedBaseline(service, 5, now, windowMs); + + // Spike CONTRACT, not OTHER + for (let i = 0; i < 19; i++) { + service.recordTransaction(CONTRACT, now - i * 100); + } + const spikeAlert = service.recordTransaction(CONTRACT, now); + const quietAlert = service.recordTransaction(other, now); + + expect(spikeAlert).not.toBeNull(); + expect(quietAlert).toBeNull(); // other has no baseline + }); + }); + + describe('getActivity', () => { + it('returns undefined for an unseen contract', () => { + expect(service.getActivity('0xUnknown')).toBeUndefined(); + }); + + it('stores the transaction timestamps', () => { + const now = Date.now(); + service.recordTransaction(CONTRACT, now); + const record = service.getActivity(CONTRACT); + expect(record).toBeDefined(); + expect(record!.timestamps).toContain(now); + }); + }); +}); diff --git a/apps/backend/src/modules/detection/contracts/spike-detection.service.ts b/apps/backend/src/modules/detection/contracts/spike-detection.service.ts new file mode 100644 index 0000000..aeea95a --- /dev/null +++ b/apps/backend/src/modules/detection/contracts/spike-detection.service.ts @@ -0,0 +1,85 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { + ContractActivityRecord, + SpikeAlert, + SpikeDetectionConfig, +} from './interfaces/spike-detection.interface'; + +const DEFAULT_CONFIG: SpikeDetectionConfig = { + windowMs: 60_000, // 1-minute rolling window + multiplierThreshold: 3, // alert when count > 3× baseline + minBaselineCount: 5, // ignore noise below this floor +}; + +@Injectable() +export class SpikeDetectionService { + private readonly logger = new Logger(SpikeDetectionService.name); + private readonly activity = new Map(); + private readonly config: SpikeDetectionConfig; + + constructor(config: Partial = {}) { + this.config = { ...DEFAULT_CONFIG, ...config }; + } + + /** + * Record a transaction for a contract. + * Returns a SpikeAlert when a spike is detected, otherwise null. + */ + recordTransaction(contractAddress: string, timestampMs = Date.now()): SpikeAlert | null { + const record = this.activity.get(contractAddress) ?? { + contractAddress, + timestamps: [], + }; + + record.timestamps.push(timestampMs); + this.activity.set(contractAddress, record); + + return this.evaluate(record, timestampMs); + } + + /** + * Returns all current activity records (for inspection / testing). + */ + getActivity(contractAddress: string): ContractActivityRecord | undefined { + return this.activity.get(contractAddress); + } + + /** Expose resolved config so callers can verify thresholds. */ + getConfig(): SpikeDetectionConfig { + return { ...this.config }; + } + + // ─── private ────────────────────────────────────────────────────────────── + + private evaluate(record: ContractActivityRecord, now: number): SpikeAlert | null { + const { windowMs, multiplierThreshold, minBaselineCount } = this.config; + + // Purge stale timestamps older than 2× the window (keeps memory bounded) + record.timestamps = record.timestamps.filter(t => t > now - windowMs * 2); + + const currentCount = record.timestamps.filter(t => t > now - windowMs).length; + const baselineCount = record.timestamps.filter( + t => t > now - windowMs * 2 && t <= now - windowMs, + ).length; + + if (baselineCount < minBaselineCount) return null; + + const multiplier = currentCount / baselineCount; + if (multiplier < multiplierThreshold) return null; + + const alert: SpikeAlert = { + contractAddress: record.contractAddress, + detectedAt: new Date(now).toISOString(), + currentCount, + baselineCount, + multiplier: Math.round(multiplier * 100) / 100, + severity: multiplier >= 10 ? 'critical' : multiplier >= 5 ? 'high' : 'medium', + }; + + this.logger.warn( + `Spike detected on ${record.contractAddress}: ${currentCount} txs vs baseline ${baselineCount} (${alert.multiplier}×) — ${alert.severity}`, + ); + + return alert; + } +}