diff --git a/src/modules/detection/draining/draining.module.ts b/src/modules/detection/draining/draining.module.ts new file mode 100644 index 0000000..7f8cf9f --- /dev/null +++ b/src/modules/detection/draining/draining.module.ts @@ -0,0 +1,12 @@ +import { DrainingService } from './draining.service'; +import { DrainingDetectorOptions } from './interfaces/draining.interface'; + +/** + * Module for rapid asset draining detection. + * Provides balance monitoring, velocity analysis, and emergency alert generation. + */ +export class DrainingModule { + static create(options: DrainingDetectorOptions = {}): DrainingService { + return new DrainingService(options); + } +} diff --git a/src/modules/detection/draining/draining.service.spec.ts b/src/modules/detection/draining/draining.service.spec.ts new file mode 100644 index 0000000..5b82f79 --- /dev/null +++ b/src/modules/detection/draining/draining.service.spec.ts @@ -0,0 +1,143 @@ +import { DrainingService } from './draining.service'; + +describe('DrainingService', () => { + let clock: number; + let service: DrainingService; + + beforeEach(() => { + clock = 0; + service = new DrainingService({ + drainThreshold: 0.5, + windowMs: 300_000, + now: () => clock, + }); + }); + + const addr = 'GABC123XYZ'; + + describe('recordBalance — no drain', () => { + it('returns null for a single snapshot', () => { + expect(service.recordBalance(addr, 1000)).toBeNull(); + }); + + it('returns null when balance increases', () => { + service.recordBalance(addr, 500); + clock += 10_000; + expect(service.recordBalance(addr, 600)).toBeNull(); + }); + + it('returns null when drain is below threshold', () => { + service.recordBalance(addr, 1000); + clock += 60_000; + // only 40 % drained — below 50 % threshold + expect(service.recordBalance(addr, 600)).toBeNull(); + }); + }); + + describe('recordBalance — drain detected', () => { + it('detects a drain at exactly the threshold', () => { + service.recordBalance(addr, 1000); + clock += 60_000; + const event = service.recordBalance(addr, 500); // 50 % drain + expect(event).not.toBeNull(); + expect(event!.drainRatio).toBeCloseTo(0.5); + expect(event!.amountDrained).toBe(500); + expect(event!.address).toBe(addr); + }); + + it('detects a full drain (100 %)', () => { + service.recordBalance(addr, 1000); + clock += 30_000; + const event = service.recordBalance(addr, 0); + expect(event).not.toBeNull(); + expect(event!.drainRatio).toBe(1); + }); + + it('stores detected events', () => { + service.recordBalance(addr, 1000); + clock += 30_000; + service.recordBalance(addr, 0); + expect(service.getDetectedEvents()).toHaveLength(1); + }); + + it('includes an alert with indicators and recommended actions', () => { + service.recordBalance(addr, 1000); + clock += 30_000; + const event = service.recordBalance(addr, 0)!; + expect(event.alert.indicators.length).toBeGreaterThan(0); + expect(event.alert.recommendedActions.length).toBeGreaterThan(0); + expect(event.alert.title).toMatch(/Rapid Asset Draining/); + }); + }); + + describe('severity escalation', () => { + it('assigns critical for ≥90 % drain within 60 s', () => { + service.recordBalance(addr, 1000); + clock += 30_000; // 30 s — fast + const event = service.recordBalance(addr, 50)!; // 95 % drained + expect(event.severity).toBe('critical'); + }); + + it('assigns high for ≥70 % drain within 60 s', () => { + service.recordBalance(addr, 1000); + clock += 45_000; + const event = service.recordBalance(addr, 250)!; // 75 % drained + expect(event.severity).toBe('critical'); // fast drain ≥70% + }); + + it('assigns medium for 50–69 % drain at moderate speed', () => { + service.recordBalance(addr, 1000); + clock += 200_000; // > 180 s + const event = service.recordBalance(addr, 400)!; // 60 % drained + expect(event.severity).toBe('medium'); + }); + + it('assigns high for 50 % drain within 60 s', () => { + service.recordBalance(addr, 1000); + clock += 30_000; + const event = service.recordBalance(addr, 500)!; // 50 % drained fast + expect(event.severity).toBe('high'); + }); + }); + + describe('history management', () => { + it('prunes snapshots outside the observation window', () => { + service.recordBalance(addr, 1000); + clock += 400_000; // beyond 5-min window + service.recordBalance(addr, 800); // old snapshot pruned + // Only the new snapshot should remain; evaluate needs ≥ 2 + const event = service.recordBalance(addr, 300); + // No drain detected because oldest visible snapshot is 800 + // 800 → 300 = 62.5 % drain — above threshold + expect(event).not.toBeNull(); + }); + + it('resetAddress clears history for one address', () => { + service.recordBalance(addr, 1000); + service.resetAddress(addr); + expect(service.getHistory(addr)).toHaveLength(0); + }); + + it('resetAll clears all state', () => { + service.recordBalance(addr, 1000); + clock += 30_000; + service.recordBalance(addr, 0); + service.resetAll(); + expect(service.getHistory(addr)).toHaveLength(0); + expect(service.getDetectedEvents()).toHaveLength(0); + }); + }); + + describe('multiple addresses', () => { + it('tracks addresses independently', () => { + const addr2 = 'GXYZ789ABC'; + service.recordBalance(addr, 1000); + service.recordBalance(addr2, 2000); + clock += 30_000; + const e1 = service.recordBalance(addr, 0); // drained + const e2 = service.recordBalance(addr2, 1900); // not drained (<5%) + expect(e1).not.toBeNull(); + expect(e2).toBeNull(); + }); + }); +}); diff --git a/src/modules/detection/draining/draining.service.ts b/src/modules/detection/draining/draining.service.ts new file mode 100644 index 0000000..30c2c90 --- /dev/null +++ b/src/modules/detection/draining/draining.service.ts @@ -0,0 +1,199 @@ +import { Severity } from '../../ai/interfaces/threat-summary.interface'; +import { + BalanceSnapshot, + DrainingAlert, + DrainingDetectorOptions, + DrainingEvent, +} from './interfaces/draining.interface'; + +/** + * Detects rapid asset draining events by monitoring wallet balance velocity. + * + * A drain is triggered when the balance drops by at least `drainThreshold` + * fraction within a rolling `windowMs` window. Severity is escalated + * automatically based on drain ratio and speed. + */ +export class DrainingService { + private readonly drainThreshold: number; + private readonly windowMs: number; + private readonly now: () => number; + + /** Circular balance history per address (most recent last). */ + private readonly history = new Map(); + private readonly detectedEvents: DrainingEvent[] = []; + + constructor(options: DrainingDetectorOptions = {}) { + this.drainThreshold = options.drainThreshold ?? 0.5; + this.windowMs = options.windowMs ?? 300_000; + this.now = options.now ?? (() => Date.now()); + } + + // ─── Balance Recording ─────────────────────────────────────────────────── + + /** + * Record a new balance snapshot for a wallet address. + * Automatically prunes snapshots outside the observation window and checks + * for a draining event. + * + * @returns The detected `DrainingEvent` if one was triggered, otherwise `null`. + */ + recordBalance(address: string, balance: number): DrainingEvent | null { + const snapshot: BalanceSnapshot = { + address, + balance, + timestamp: new Date(this.now()).toISOString(), + }; + + if (!this.history.has(address)) { + this.history.set(address, []); + } + const snapshots = this.history.get(address)!; + snapshots.push(snapshot); + + this.pruneHistory(address); + return this.evaluate(address); + } + + // ─── Detection ─────────────────────────────────────────────────────────── + + /** + * Evaluate the balance history for a given address and return a draining + * event if the threshold is breached. + */ + evaluate(address: string): DrainingEvent | null { + const snapshots = this.history.get(address); + if (!snapshots || snapshots.length < 2) return null; + + const oldest = snapshots[0]; + const newest = snapshots[snapshots.length - 1]; + + if (oldest.balance <= 0) return null; + + const amountDrained = oldest.balance - newest.balance; + if (amountDrained <= 0) return null; + + const drainRatio = amountDrained / oldest.balance; + if (drainRatio < this.drainThreshold) return null; + + const durationMs = new Date(newest.timestamp).getTime() - new Date(oldest.timestamp).getTime(); + + const severity = this.classifySeverity(drainRatio, durationMs); + const alert = this.buildAlert(address, amountDrained, drainRatio, durationMs, severity); + + const event: DrainingEvent = { + id: `drain-${address}-${this.now()}`, + address, + openingBalance: oldest.balance, + closingBalance: newest.balance, + amountDrained, + drainRatio, + durationMs, + detectedAt: newest.timestamp, + severity, + alert, + }; + + this.detectedEvents.push(event); + return event; + } + + // ─── Inspection ────────────────────────────────────────────────────────── + + /** All draining events detected so far (most recent last). */ + getDetectedEvents(): DrainingEvent[] { + return [...this.detectedEvents]; + } + + /** Current balance snapshots for an address (within the observation window). */ + getHistory(address: string): BalanceSnapshot[] { + return [...(this.history.get(address) ?? [])]; + } + + /** Clear history and events for a single address. */ + resetAddress(address: string): void { + this.history.delete(address); + } + + /** Clear all state. */ + resetAll(): void { + this.history.clear(); + this.detectedEvents.length = 0; + } + + // ─── Private ───────────────────────────────────────────────────────────── + + private pruneHistory(address: string): void { + const cutoff = this.now() - this.windowMs; + const snapshots = this.history.get(address)!; + const keep = snapshots.filter(s => new Date(s.timestamp).getTime() >= cutoff); + this.history.set(address, keep); + } + + /** + * Escalate severity based on both the drain ratio and speed. + * + * | ratio | duration ≤ 60s | duration ≤ 180s | otherwise | + * |-----------|---------------|-----------------|-----------| + * | ≥ 0.90 | critical | critical | high | + * | ≥ 0.70 | critical | high | high | + * | ≥ 0.50 | high | medium | medium | + */ + private classifySeverity(drainRatio: number, durationMs: number): Severity { + const fast = durationMs <= 60_000; + const moderate = durationMs <= 180_000; + + if (drainRatio >= 0.9) return fast || moderate ? 'critical' : 'high'; + if (drainRatio >= 0.7) return fast ? 'critical' : 'high'; + return fast ? 'high' : 'medium'; + } + + private buildAlert( + address: string, + amountDrained: number, + drainRatio: number, + durationMs: number, + severity: Severity, + ): DrainingAlert { + const pct = (drainRatio * 100).toFixed(1); + const secs = (durationMs / 1000).toFixed(0); + + return { + title: `Rapid Asset Draining Detected — ${pct}% drained`, + description: + `Wallet ${address} lost ${amountDrained.toFixed(4)} units ` + + `(${pct}% of opening balance) in ${secs}s.`, + severity, + indicators: [ + `Address: ${address}`, + `Drain ratio: ${pct}%`, + `Amount drained: ${amountDrained.toFixed(4)}`, + `Duration: ${secs}s`, + ], + recommendedActions: this.recommendActions(severity), + }; + } + + private recommendActions(severity: Severity): string[] { + const base = [ + 'Investigate transaction history for the affected wallet', + 'Check for unauthorized key exposure', + ]; + if (severity === 'critical') { + return [ + 'Immediately freeze the affected wallet if possible', + 'Initiate emergency incident response', + 'Notify security team and asset owners', + ...base, + ]; + } + if (severity === 'high') { + return [ + 'Suspend outbound transactions pending review', + 'Alert wallet owner immediately', + ...base, + ]; + } + return [...base, 'Monitor for continued drain activity']; + } +} + diff --git a/src/modules/detection/draining/index.ts b/src/modules/detection/draining/index.ts new file mode 100644 index 0000000..d51be8f --- /dev/null +++ b/src/modules/detection/draining/index.ts @@ -0,0 +1,8 @@ +export { DrainingService } from './draining.service'; +export { DrainingModule } from './draining.module'; +export type { + BalanceSnapshot, + DrainingEvent, + DrainingAlert, + DrainingDetectorOptions, +} from './interfaces/draining.interface'; diff --git a/src/modules/detection/draining/interfaces/draining.interface.ts b/src/modules/detection/draining/interfaces/draining.interface.ts new file mode 100644 index 0000000..9804f2d --- /dev/null +++ b/src/modules/detection/draining/interfaces/draining.interface.ts @@ -0,0 +1,48 @@ +import { Severity } from '../../ai/interfaces/threat-summary.interface'; + +export interface BalanceSnapshot { + address: string; + balance: number; + timestamp: string; +} + +export interface DrainingEvent { + id: string; + address: string; + /** Balance at the start of the observation window. */ + openingBalance: number; + /** Balance at the time draining was detected. */ + closingBalance: number; + /** Absolute amount drained. */ + amountDrained: number; + /** Fraction drained: amountDrained / openingBalance */ + drainRatio: number; + /** Milliseconds the drain took. */ + durationMs: number; + detectedAt: string; + severity: Severity; + alert: DrainingAlert; +} + +export interface DrainingAlert { + title: string; + description: string; + severity: Severity; + indicators: string[]; + recommendedActions: string[]; +} + +export interface DrainingDetectorOptions { + /** + * Fraction of balance that must be drained within the window to trigger detection. + * Default 0.5 (50 %). + */ + drainThreshold?: number; + /** + * Maximum window (ms) within which a drain is evaluated. + * Default 300_000 (5 minutes). + */ + windowMs?: number; + /** Injectable clock for deterministic tests. Defaults to Date.now. */ + now?: () => number; +}