Skip to content

Commit 1c23e4b

Browse files
authored
Merge pull request #165 from portableDD/feat/rate-limit-and-suspicious-activity
feat: add rate limiting and suspicious activity detection (#103, #102)
2 parents f8a1296 + 6bc0ed2 commit 1c23e4b

File tree

8 files changed

+596
-8
lines changed

8 files changed

+596
-8
lines changed
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { HttpException, HttpStatus } from '@nestjs/common';
2+
import { RateLimitService } from './rate-limit.service';
3+
4+
describe('RateLimitService', () => {
5+
let service: RateLimitService;
6+
7+
beforeEach(() => {
8+
service = new RateLimitService({ maxPerMinute: 3, maxPerHour: 10 });
9+
});
10+
11+
describe('check()', () => {
12+
it('allows transactions below per-minute limit', () => {
13+
service.record('M1');
14+
service.record('M1');
15+
const status = service.check('M1');
16+
expect(status.allowed).toBe(true);
17+
expect(status.transactionsLastMinute).toBe(2);
18+
});
19+
20+
it('throws 429 when per-minute limit is reached', () => {
21+
service.record('M1');
22+
service.record('M1');
23+
service.record('M1');
24+
expect(() => service.check('M1')).toThrow(HttpException);
25+
try {
26+
service.check('M1');
27+
} catch (e: any) {
28+
expect(e.getStatus()).toBe(HttpStatus.TOO_MANY_REQUESTS);
29+
expect(e.getResponse().retryAfterSeconds).toBeGreaterThan(0);
30+
}
31+
});
32+
33+
it('throws 429 when per-hour limit is reached', () => {
34+
// Fill the hour bucket without triggering per-minute limit.
35+
// Use a fresh service with maxPerMinute=100, maxPerHour=5
36+
const svc = new RateLimitService({ maxPerMinute: 100, maxPerHour: 5 });
37+
for (let i = 0; i < 5; i++) svc.record('M2');
38+
expect(() => svc.check('M2')).toThrow(HttpException);
39+
});
40+
41+
it('does not bleed limits between merchants', () => {
42+
service.record('M1');
43+
service.record('M1');
44+
service.record('M1');
45+
// M2 should still be fine
46+
expect(() => service.check('M2')).not.toThrow();
47+
});
48+
});
49+
50+
describe('getStatus()', () => {
51+
it('returns current counts without throwing', () => {
52+
service.record('M3');
53+
service.record('M3');
54+
const status = service.getStatus('M3');
55+
expect(status.transactionsLastMinute).toBe(2);
56+
expect(status.transactionsLastHour).toBe(2);
57+
expect(status.limitPerMinute).toBe(3);
58+
expect(status.limitPerHour).toBe(10);
59+
});
60+
61+
it('returns allowed=false when limits are reached, without throwing', () => {
62+
for (let i = 0; i < 3; i++) service.record('M4');
63+
const status = service.getStatus('M4');
64+
expect(status.allowed).toBe(false);
65+
});
66+
});
67+
68+
describe('record()', () => {
69+
it('increments counts', () => {
70+
service.record('M5');
71+
service.record('M5');
72+
service.record('M5');
73+
const status = service.getStatus('M5');
74+
expect(status.transactionsLastMinute).toBe(3);
75+
});
76+
});
77+
});
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { Injectable, HttpException, HttpStatus, Logger } from '@nestjs/common';
2+
3+
export interface RateLimitConfig {
4+
maxPerMinute: number;
5+
maxPerHour: number;
6+
}
7+
8+
export interface RateLimitStatus {
9+
allowed: boolean;
10+
merchantId: string;
11+
transactionsLastMinute: number;
12+
transactionsLastHour: number;
13+
limitPerMinute: number;
14+
limitPerHour: number;
15+
retryAfterSeconds?: number;
16+
}
17+
18+
const DEFAULT_CONFIG: RateLimitConfig = {
19+
maxPerMinute: 10,
20+
maxPerHour: 100,
21+
};
22+
23+
@Injectable()
24+
export class RateLimitService {
25+
private readonly logger = new Logger(RateLimitService.name);
26+
private readonly timestamps = new Map<string, number[]>();
27+
private readonly config: RateLimitConfig;
28+
29+
constructor(config: Partial<RateLimitConfig> = {}) {
30+
this.config = { ...DEFAULT_CONFIG, ...config };
31+
}
32+
33+
/**
34+
* Check if a merchant is within rate limits.
35+
* Throws 429 if limits are exceeded.
36+
*/
37+
check(merchantId: string): RateLimitStatus {
38+
const now = Date.now();
39+
const oneMinuteAgo = now - 60_000;
40+
const oneHourAgo = now - 3_600_000;
41+
42+
const all = this.timestamps.get(merchantId) ?? [];
43+
// Prune timestamps older than 1 hour
44+
const recent = all.filter((t) => t > oneHourAgo);
45+
this.timestamps.set(merchantId, recent);
46+
47+
const lastMinute = recent.filter((t) => t > oneMinuteAgo).length;
48+
const lastHour = recent.length;
49+
50+
const status: RateLimitStatus = {
51+
allowed: true,
52+
merchantId,
53+
transactionsLastMinute: lastMinute,
54+
transactionsLastHour: lastHour,
55+
limitPerMinute: this.config.maxPerMinute,
56+
limitPerHour: this.config.maxPerHour,
57+
};
58+
59+
if (lastMinute >= this.config.maxPerMinute) {
60+
const oldestInWindow = recent.filter((t) => t > oneMinuteAgo)[0];
61+
status.allowed = false;
62+
status.retryAfterSeconds = Math.ceil((oldestInWindow + 60_000 - now) / 1000);
63+
this.logger.warn(
64+
`Rate limit (per-minute) exceeded for merchant ${merchantId}: ${lastMinute} tx in last 60s`,
65+
);
66+
throw new HttpException(
67+
{
68+
statusCode: HttpStatus.TOO_MANY_REQUESTS,
69+
error: 'Too Many Requests',
70+
message: `Rate limit exceeded: ${lastMinute}/${this.config.maxPerMinute} transactions in the last minute.`,
71+
retryAfterSeconds: status.retryAfterSeconds,
72+
},
73+
HttpStatus.TOO_MANY_REQUESTS,
74+
);
75+
}
76+
77+
if (lastHour >= this.config.maxPerHour) {
78+
const oldestInWindow = recent[0];
79+
status.allowed = false;
80+
status.retryAfterSeconds = Math.ceil((oldestInWindow + 3_600_000 - now) / 1000);
81+
this.logger.warn(
82+
`Rate limit (per-hour) exceeded for merchant ${merchantId}: ${lastHour} tx in last 60min`,
83+
);
84+
throw new HttpException(
85+
{
86+
statusCode: HttpStatus.TOO_MANY_REQUESTS,
87+
error: 'Too Many Requests',
88+
message: `Rate limit exceeded: ${lastHour}/${this.config.maxPerHour} transactions in the last hour.`,
89+
retryAfterSeconds: status.retryAfterSeconds,
90+
},
91+
HttpStatus.TOO_MANY_REQUESTS,
92+
);
93+
}
94+
95+
return status;
96+
}
97+
98+
/**
99+
* Record a transaction timestamp for a merchant after it has been saved.
100+
*/
101+
record(merchantId: string): void {
102+
const all = this.timestamps.get(merchantId) ?? [];
103+
all.push(Date.now());
104+
this.timestamps.set(merchantId, all);
105+
}
106+
107+
/**
108+
* Returns current rate-limit status without enforcing the limit.
109+
*/
110+
getStatus(merchantId: string): RateLimitStatus {
111+
const now = Date.now();
112+
const oneMinuteAgo = now - 60_000;
113+
const oneHourAgo = now - 3_600_000;
114+
115+
const all = this.timestamps.get(merchantId) ?? [];
116+
const recent = all.filter((t) => t > oneHourAgo);
117+
118+
const lastMinute = recent.filter((t) => t > oneMinuteAgo).length;
119+
const lastHour = recent.length;
120+
121+
return {
122+
allowed: lastMinute < this.config.maxPerMinute && lastHour < this.config.maxPerHour,
123+
merchantId,
124+
transactionsLastMinute: lastMinute,
125+
transactionsLastHour: lastHour,
126+
limitPerMinute: this.config.maxPerMinute,
127+
limitPerHour: this.config.maxPerHour,
128+
};
129+
}
130+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { SuspiciousActivityService, SuspiciousActivityType, AlertSeverity } from './suspicious-activity.service';
2+
import { Transaction, TxStatus, TxType } from './transaction.entity';
3+
4+
function makeTx(overrides: Partial<Transaction> = {}): Transaction {
5+
return Object.assign(new Transaction(), {
6+
id: crypto.randomUUID(),
7+
txHash: `0x${Math.random().toString(16).slice(2)}`,
8+
merchantId: 'merchant_1',
9+
chainId: 1,
10+
status: TxStatus.SUCCESS,
11+
type: TxType.TRANSFER,
12+
gasUsed: 21000,
13+
timestamp: new Date(),
14+
...overrides,
15+
});
16+
}
17+
18+
describe('SuspiciousActivityService', () => {
19+
let service: SuspiciousActivityService;
20+
21+
beforeEach(() => {
22+
service = new SuspiciousActivityService();
23+
});
24+
25+
describe('analyze() — no detection below threshold', () => {
26+
it('returns detected=false when fewer than 5 transactions', () => {
27+
for (let i = 0; i < 4; i++) {
28+
const result = service.analyze(makeTx());
29+
expect(result.detected).toBe(false);
30+
}
31+
});
32+
33+
it('returns detected=false for normal traffic', () => {
34+
for (let i = 0; i < 10; i++) {
35+
const tx = makeTx({ timestamp: new Date(Date.now() - i * 5000) });
36+
service.analyze(tx);
37+
}
38+
const result = service.analyze(makeTx());
39+
expect(result.detected).toBe(false);
40+
});
41+
});
42+
43+
describe('High failure rate detection', () => {
44+
it('detects HIGH_FAILURE_RATE when > 70% of last 10 transactions fail', () => {
45+
// Seed 10 transactions with 8 failures
46+
const txs = [
47+
...Array(8).fill(null).map(() => makeTx({ status: TxStatus.FAILURE })),
48+
...Array(2).fill(null).map(() => makeTx({ status: TxStatus.SUCCESS })),
49+
];
50+
let lastResult: any;
51+
for (const tx of txs) {
52+
lastResult = service.analyze(tx);
53+
}
54+
expect(lastResult.detected).toBe(true);
55+
expect(lastResult.type).toBe(SuspiciousActivityType.HIGH_FAILURE_RATE);
56+
expect([AlertSeverity.LOW, AlertSeverity.MEDIUM, AlertSeverity.HIGH]).toContain(lastResult.severity);
57+
expect(lastResult.detectedAt).toBeDefined();
58+
expect(lastResult.metadata.failureRate).toBeGreaterThanOrEqual(70);
59+
});
60+
61+
it('does not flag when failure rate is below 70%', () => {
62+
const txs = [
63+
...Array(6).fill(null).map(() => makeTx({ status: TxStatus.SUCCESS })),
64+
...Array(4).fill(null).map(() => makeTx({ status: TxStatus.FAILURE })),
65+
];
66+
let lastResult: any;
67+
for (const tx of txs) {
68+
lastResult = service.analyze(tx);
69+
}
70+
// 40% failure — should NOT trigger high failure rate
71+
if (lastResult.detected) {
72+
expect(lastResult.type).not.toBe(SuspiciousActivityType.HIGH_FAILURE_RATE);
73+
}
74+
});
75+
});
76+
77+
describe('Gas spike detection', () => {
78+
it('detects GAS_SPIKE when latest tx uses > 4x rolling average', () => {
79+
// 9 normal transactions
80+
for (let i = 0; i < 9; i++) {
81+
service.analyze(makeTx({ gasUsed: 21000 }));
82+
}
83+
// Spike transaction
84+
const result = service.analyze(makeTx({ gasUsed: 210000 })); // 10x
85+
expect(result.detected).toBe(true);
86+
expect(result.type).toBe(SuspiciousActivityType.GAS_SPIKE);
87+
expect(result.severity).toBe(AlertSeverity.HIGH); // 10x => HIGH
88+
expect(result.metadata?.spikeMultiplier).toBeGreaterThanOrEqual(4);
89+
});
90+
91+
it('does not flag a normal gas amount', () => {
92+
for (let i = 0; i < 9; i++) {
93+
service.analyze(makeTx({ gasUsed: 21000 }));
94+
}
95+
const result = service.analyze(makeTx({ gasUsed: 25000 })); // ~1.2x — normal
96+
if (result.detected) {
97+
expect(result.type).not.toBe(SuspiciousActivityType.GAS_SPIKE);
98+
}
99+
});
100+
});
101+
102+
describe('Burst detection', () => {
103+
it('detects BURST_TRANSACTIONS when >= 5 tx occur within 10 seconds', () => {
104+
const now = Date.now();
105+
const txs = Array(6).fill(null).map((_, i) =>
106+
makeTx({ timestamp: new Date(now - i * 1000) }), // 1s apart within 10s
107+
);
108+
let lastResult: any;
109+
for (const tx of txs) {
110+
lastResult = service.analyze(tx);
111+
}
112+
expect(lastResult.detected).toBe(true);
113+
expect(lastResult.type).toBe(SuspiciousActivityType.BURST_TRANSACTIONS);
114+
expect(lastResult.metadata?.burstCount).toBeGreaterThanOrEqual(5);
115+
});
116+
117+
it('does not flag transactions spread over time', () => {
118+
const now = Date.now();
119+
// Spread 10 transactions over 2 minutes — never > 5 in 10s
120+
const txs = Array(10).fill(null).map((_, i) =>
121+
makeTx({ timestamp: new Date(now - i * 15000) }),
122+
);
123+
let lastResult: any;
124+
for (const tx of txs) {
125+
lastResult = service.analyze(tx);
126+
}
127+
if (lastResult.detected) {
128+
expect(lastResult.type).not.toBe(SuspiciousActivityType.BURST_TRANSACTIONS);
129+
}
130+
});
131+
});
132+
});

0 commit comments

Comments
 (0)