Skip to content
Closed
12 changes: 12 additions & 0 deletions src/modules/detection/draining/draining.module.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
143 changes: 143 additions & 0 deletions src/modules/detection/draining/draining.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
199 changes: 199 additions & 0 deletions src/modules/detection/draining/draining.service.ts
Original file line number Diff line number Diff line change
@@ -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<string, BalanceSnapshot[]>();
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'];
}
}

Check failure on line 199 in src/modules/detection/draining/draining.service.ts

View workflow job for this annotation

GitHub Actions / Linting (22.x)

Delete `⏎`

Check failure on line 199 in src/modules/detection/draining/draining.service.ts

View workflow job for this annotation

GitHub Actions / Linting (20.x)

Delete `⏎`
8 changes: 8 additions & 0 deletions src/modules/detection/draining/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export { DrainingService } from './draining.service';
export { DrainingModule } from './draining.module';
export type {
BalanceSnapshot,
DrainingEvent,
DrainingAlert,
DrainingDetectorOptions,
} from './interfaces/draining.interface';
Loading
Loading