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
3 changes: 2 additions & 1 deletion apps/backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@
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],

Check failure on line 10 in apps/backend/src/app.module.ts

View workflow job for this annotation

GitHub Actions / Linting (22.x)

Replace `DatabaseModule,·HealthModule,·NotificationsModule,·ReportingModule,·SpikeDetectionModule` with `⏎····DatabaseModule,⏎····HealthModule,⏎····NotificationsModule,⏎····ReportingModule,⏎····SpikeDetectionModule,⏎··`

Check failure on line 10 in apps/backend/src/app.module.ts

View workflow job for this annotation

GitHub Actions / Linting (20.x)

Replace `DatabaseModule,·HealthModule,·NotificationsModule,·ReportingModule,·SpikeDetectionModule` with `⏎····DatabaseModule,⏎····HealthModule,⏎····NotificationsModule,⏎····ReportingModule,⏎····SpikeDetectionModule,⏎··`
controllers: [AppController],
})
export class AppModule {}
Original file line number Diff line number Diff line change
@@ -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';
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { SpikeDetectionService } from './spike-detection.service';

@Module({
providers: [SpikeDetectionService],
exports: [SpikeDetectionService],
})
export class SpikeDetectionModule {}
Original file line number Diff line number Diff line change
@@ -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', () => {

Check failure on line 19 in apps/backend/src/modules/detection/contracts/spike-detection.service.spec.ts

View workflow job for this annotation

GitHub Actions / TypeScript Type Checking

Cannot find name 'describe'. Do you need to install type definitions for a test runner? Try `npm i --save-dev @types/jest` or `npm i --save-dev @types/mocha` and then add 'jest' or 'mocha' to the types field in your tsconfig.
let service: SpikeDetectionService;
const windowMs = 60_000;

beforeEach(async () => {

Check failure on line 23 in apps/backend/src/modules/detection/contracts/spike-detection.service.spec.ts

View workflow job for this annotation

GitHub Actions / TypeScript Type Checking

Cannot find name 'beforeEach'. Do you need to install type definitions for a test runner? Try `npm i --save-dev @types/jest` or `npm i --save-dev @types/mocha` and then add 'jest' or 'mocha' to the types field in your tsconfig.
const module: TestingModule = await Test.createTestingModule({
providers: [SpikeDetectionService],
}).compile();

service = module.get<SpikeDetectionService>(SpikeDetectionService);
});

it('should be defined', () => {

Check failure on line 31 in apps/backend/src/modules/detection/contracts/spike-detection.service.spec.ts

View workflow job for this annotation

GitHub Actions / TypeScript Type Checking

Cannot find name 'it'. Do you need to install type definitions for a test runner? Try `npm i --save-dev @types/jest` or `npm i --save-dev @types/mocha` and then add 'jest' or 'mocha' to the types field in your tsconfig.
expect(service).toBeDefined();

Check failure on line 32 in apps/backend/src/modules/detection/contracts/spike-detection.service.spec.ts

View workflow job for this annotation

GitHub Actions / TypeScript Type Checking

Cannot find name 'expect'.
});

describe('getConfig', () => {

Check failure on line 35 in apps/backend/src/modules/detection/contracts/spike-detection.service.spec.ts

View workflow job for this annotation

GitHub Actions / TypeScript Type Checking

Cannot find name 'describe'. Do you need to install type definitions for a test runner? Try `npm i --save-dev @types/jest` or `npm i --save-dev @types/mocha` and then add 'jest' or 'mocha' to the types field in your tsconfig.
it('returns default config values', () => {

Check failure on line 36 in apps/backend/src/modules/detection/contracts/spike-detection.service.spec.ts

View workflow job for this annotation

GitHub Actions / TypeScript Type Checking

Cannot find name 'it'. Do you need to install type definitions for a test runner? Try `npm i --save-dev @types/jest` or `npm i --save-dev @types/mocha` and then add 'jest' or 'mocha' to the types field in your tsconfig.
const cfg = service.getConfig();
expect(cfg.windowMs).toBe(60_000);

Check failure on line 38 in apps/backend/src/modules/detection/contracts/spike-detection.service.spec.ts

View workflow job for this annotation

GitHub Actions / TypeScript Type Checking

Cannot find name 'expect'.
expect(cfg.multiplierThreshold).toBe(3);

Check failure on line 39 in apps/backend/src/modules/detection/contracts/spike-detection.service.spec.ts

View workflow job for this annotation

GitHub Actions / TypeScript Type Checking

Cannot find name 'expect'.
expect(cfg.minBaselineCount).toBe(5);

Check failure on line 40 in apps/backend/src/modules/detection/contracts/spike-detection.service.spec.ts

View workflow job for this annotation

GitHub Actions / TypeScript Type Checking

Cannot find name 'expect'.
});

it('respects partial config override', () => {

Check failure on line 43 in apps/backend/src/modules/detection/contracts/spike-detection.service.spec.ts

View workflow job for this annotation

GitHub Actions / TypeScript Type Checking

Cannot find name 'it'. Do you need to install type definitions for a test runner? Try `npm i --save-dev @types/jest` or `npm i --save-dev @types/mocha` and then add 'jest' or 'mocha' to the types field in your tsconfig.
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);
});
});
});
Original file line number Diff line number Diff line change
@@ -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

Check failure on line 9 in apps/backend/src/modules/detection/contracts/spike-detection.service.ts

View workflow job for this annotation

GitHub Actions / Linting (22.x)

Delete `·····`

Check failure on line 9 in apps/backend/src/modules/detection/contracts/spike-detection.service.ts

View workflow job for this annotation

GitHub Actions / Linting (20.x)

Delete `·····`
multiplierThreshold: 3, // alert when count > 3× baseline
minBaselineCount: 5, // ignore noise below this floor

Check failure on line 11 in apps/backend/src/modules/detection/contracts/spike-detection.service.ts

View workflow job for this annotation

GitHub Actions / Linting (22.x)

Delete `···`

Check failure on line 11 in apps/backend/src/modules/detection/contracts/spike-detection.service.ts

View workflow job for this annotation

GitHub Actions / Linting (20.x)

Delete `···`
};

@Injectable()
export class SpikeDetectionService {
private readonly logger = new Logger(SpikeDetectionService.name);
private readonly activity = new Map<string, ContractActivityRecord>();
private readonly config: SpikeDetectionConfig;

constructor(config: Partial<SpikeDetectionConfig> = {}) {
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;
}
}
Loading