Skip to content

Commit b6c55f1

Browse files
authored
Merge pull request #443 from Chibey-max/blackboxai/354-claim-rate-limiting
feat(rate-limit): #354 per-wallet and global claim submission limits
2 parents 2668899 + b470bfc commit b6c55f1

6 files changed

Lines changed: 328 additions & 15 deletions

File tree

backend/docs/rate-limiting.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,47 @@ This means authenticated users behind a shared corporate NAT are not penalised c
1919
> Limits for claim submission (`POST /api/claims/submit`) are additionally governed by
2020
> a per-policy ledger-window counter (see claim rate limiting docs).
2121
22+
## Claim Submission Rate Limits
23+
24+
Claim submission (`POST /api/claims/submit`) is protected by **three layers** of rate limiting:
25+
26+
1. **Global circuit breaker** — prevents system overload from any source.
27+
2. **Per-wallet sliding window** — prevents a single wallet from flooding claims.
28+
3. **Per-policy ledger window** — prevents spam against a single policy.
29+
30+
### Layer 1: Global Circuit Breaker
31+
32+
| Limit | Window | Rationale |
33+
|---|---|---|
34+
| 100 claims | 5 minutes | Protects governance capacity and DAO voter attention across all policies. If triggered, all claim submissions are rejected until the window slides. |
35+
36+
### Layer 2: Per-Wallet Sliding Window
37+
38+
| Limit | Window | Rationale |
39+
|---|---|---|
40+
| 3 claims | 1 hour | Prevents a single wallet from exhausting governance for a specific tenant or policy type. Aligns with typical legitimate catastrophic-event filing patterns (1–2 claims per hour). |
41+
42+
### Layer 3: Per-Policy Ledger Window
43+
44+
| Limit | Window | Rationale |
45+
|---|---|---|
46+
| 5 claims | 17,280 ledgers (~24h) | Prevents spam against an individual policy. Uses ledger-based windows to avoid clock-skew issues. |
47+
48+
### Claim Rate Limit Configuration
49+
50+
Environment variables:
51+
52+
| Variable | Default | Description |
53+
|---|---|---|
54+
| `GLOBAL_RATE_LIMIT` | 100 | Global claim submissions per window |
55+
| `GLOBAL_RATE_LIMIT_WINDOW_SECONDS` | 300 | Global window in seconds |
56+
| `WALLET_RATE_LIMIT` | 3 | Claims per wallet per window |
57+
| `WALLET_RATE_LIMIT_WINDOW_SECONDS` | 3600 | Wallet window in seconds |
58+
| `RATE_LIMIT_DEFAULTS.DEFAULT_LIMIT` | 5 | Per-policy claim limit |
59+
| `RATE_LIMIT_DEFAULTS.WINDOW_SIZE_LEDGERS` | 17280 | Per-policy window in ledgers |
60+
61+
All values are stored in Redis and survive service restarts.
62+
2263
## Response Headers
2364

2465
Every response includes:
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/**
2+
* Rate Limit Service Tests
3+
*
4+
* Tests per-wallet sliding window, global circuit breaker, and limit reset.
5+
*/
6+
import { RateLimitService } from '../rate-limit.service';
7+
import { RedisService } from '../../cache/redis.service';
8+
import { ConfigService } from '@nestjs/config';
9+
10+
describe('RateLimitService — wallet & global limits', () => {
11+
let service: RateLimitService;
12+
let redisMock: { getClient: jest.Mock; get: jest.Mock; set: jest.Mock };
13+
let redisClient: Record<string, jest.Mock>;
14+
15+
beforeEach(() => {
16+
redisClient = {
17+
zremrangebyscore: jest.fn().mockResolvedValue(0),
18+
zcard: jest.fn().mockResolvedValue(0),
19+
zadd: jest.fn().mockResolvedValue(1),
20+
pexpire: jest.fn().mockResolvedValue(1),
21+
zrange: jest.fn().mockResolvedValue([]),
22+
hset: jest.fn().mockResolvedValue(1),
23+
hgetall: jest.fn().mockResolvedValue({}),
24+
hget: jest.fn().mockResolvedValue(null),
25+
hincrby: jest.fn().mockResolvedValue(1),
26+
exists: jest.fn().mockResolvedValue(0),
27+
};
28+
29+
redisMock = {
30+
getClient: jest.fn().mockReturnValue(redisClient),
31+
get: jest.fn(),
32+
set: jest.fn(),
33+
};
34+
35+
const configMock = {
36+
get: jest.fn((key: string, fallback: unknown) => fallback),
37+
};
38+
39+
service = new RateLimitService(redisMock as unknown as RedisService, configMock as unknown as ConfigService);
40+
});
41+
42+
describe('checkWalletLimit', () => {
43+
it('allows request when under limit', async () => {
44+
redisClient.zcard.mockResolvedValue(0);
45+
const result = await service.checkWalletLimit('GABC123');
46+
expect(result.allowed).toBe(true);
47+
expect(result.retryAfterSeconds).toBe(0);
48+
expect(redisClient.zadd).toHaveBeenCalled();
49+
});
50+
51+
it('blocks request when limit exceeded', async () => {
52+
redisClient.zcard.mockResolvedValue(3);
53+
redisClient.zrange.mockResolvedValue(['entry', String(Date.now() - 1800000)]); // 30 min ago
54+
const result = await service.checkWalletLimit('GABC123');
55+
expect(result.allowed).toBe(false);
56+
expect(result.retryAfterSeconds).toBeGreaterThan(0);
57+
});
58+
59+
it('fails open on Redis error', async () => {
60+
redisClient.zcard.mockRejectedValue(new Error('Redis down'));
61+
const result = await service.checkWalletLimit('GABC123');
62+
expect(result.allowed).toBe(true);
63+
});
64+
});
65+
66+
describe('checkGlobalLimit', () => {
67+
it('allows request when under global limit', async () => {
68+
redisClient.zcard.mockResolvedValue(50);
69+
const result = await service.checkGlobalLimit();
70+
expect(result.allowed).toBe(true);
71+
expect(result.retryAfterSeconds).toBe(0);
72+
});
73+
74+
it('blocks request when global circuit breaker triggered', async () => {
75+
redisClient.zcard.mockResolvedValue(100);
76+
redisClient.zrange.mockResolvedValue(['entry', String(Date.now() - 120000)]); // 2 min ago
77+
const result = await service.checkGlobalLimit();
78+
expect(result.allowed).toBe(false);
79+
expect(result.retryAfterSeconds).toBeGreaterThan(0);
80+
});
81+
82+
it('fails open on Redis error', async () => {
83+
redisClient.zcard.mockRejectedValue(new Error('Redis down'));
84+
const result = await service.checkGlobalLimit();
85+
expect(result.allowed).toBe(true);
86+
});
87+
});
88+
});
89+

backend/src/rate-limit/rate-limit.constants.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,19 @@
1+
/**
2+
* Per-wallet rate limit: number of claims allowed per wallet per time window.
3+
*/
4+
export const WALLET_RATE_LIMIT_DEFAULTS = {
5+
LIMIT: 3, // claims per wallet per window
6+
WINDOW_SECONDS: 3600, // 1 hour sliding window
7+
};
8+
9+
/**
10+
* Global rate limit circuit breaker: total claims across all wallets per window.
11+
*/
12+
export const GLOBAL_RATE_LIMIT_DEFAULTS = {
13+
LIMIT: 100, // total claims per window across all wallets
14+
WINDOW_SECONDS: 300, // 5 minute sliding window
15+
};
16+
117
export const RATE_LIMIT_DEFAULTS = {
218
DEFAULT_LIMIT: 5, // claims per window
319
WINDOW_SIZE_LEDGERS: 17_280, // ~24 hours at 5s/ledger
@@ -9,4 +25,6 @@ export const REDIS_KEYS = {
925
COUNTER: (policyId: string) => `rate_limit:counter:${policyId}`,
1026
CONFIG: (policyId: string) => `rate_limit:config:${policyId}`,
1127
DEFAULTS: 'rate_limit:defaults',
28+
WALLET_WINDOW: (wallet: string) => `rate_limit:wallet:${wallet}`,
29+
GLOBAL_WINDOW: 'rate_limit:global',
1230
};

backend/src/rate-limit/rate-limit.exception.ts

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,23 +6,31 @@ export interface RateLimitErrorDetails {
66
limit: number;
77
windowResetLedger: number;
88
remainingLedgers: number;
9+
/** Seconds until the client should retry */
10+
retryAfterSeconds?: number;
11+
/** Type of limit that was exceeded */
12+
limitType?: 'policy' | 'wallet' | 'global';
913
}
1014

1115
export class RateLimitException extends HttpException {
1216
constructor(details: RateLimitErrorDetails) {
17+
const typeLabel = details.limitType ?? 'policy';
1318
const message =
14-
`Rate limit exceeded for policy ${details.policyId}. ` +
19+
`Rate limit exceeded for ${typeLabel} ${details.policyId}. ` +
1520
`Current: ${details.currentCount}/${details.limit}. ` +
1621
`Window resets in ${details.remainingLedgers} ledgers (ledger ${details.windowResetLedger}).`;
1722

18-
super(
19-
{
20-
statusCode: HttpStatus.TOO_MANY_REQUESTS,
21-
error: 'Too Many Requests',
22-
message,
23-
details,
24-
},
25-
HttpStatus.TOO_MANY_REQUESTS,
26-
);
23+
const responseBody: Record<string, unknown> = {
24+
statusCode: HttpStatus.TOO_MANY_REQUESTS,
25+
error: 'Too Many Requests',
26+
message,
27+
details,
28+
};
29+
30+
if (details.retryAfterSeconds && details.retryAfterSeconds > 0) {
31+
responseBody.retryAfter = details.retryAfterSeconds;
32+
}
33+
34+
super(responseBody, HttpStatus.TOO_MANY_REQUESTS);
2735
}
2836
}

backend/src/rate-limit/rate-limit.guard.ts

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Injectable, CanActivate, ExecutionContext, Logger } from '@nestjs/common';
2+
import { Response } from 'express';
23
import { RateLimitService } from './rate-limit.service';
34
import { RateLimitException } from './rate-limit.exception';
45
import { SorobanService } from '../rpc/soroban.service';
@@ -14,6 +15,7 @@ export class RateLimitGuard implements CanActivate {
1415

1516
async canActivate(context: ExecutionContext): Promise<boolean> {
1617
const request = context.switchToHttp().getRequest();
18+
const response = context.switchToHttp().getResponse<Response>();
1719
const { policyId } = request.body;
1820

1921
// If policyId is missing, let validation handle it
@@ -22,22 +24,58 @@ export class RateLimitGuard implements CanActivate {
2224
}
2325

2426
try {
25-
// Get current ledger from Soroban
26-
const currentLedger = await this.soroban.getLatestLedger();
27+
// ── 1. Global circuit breaker ───────────────────────────────────────
28+
const globalCheck = await this.rateLimitService.checkGlobalLimit();
29+
if (!globalCheck.allowed) {
30+
response.setHeader('Retry-After', String(globalCheck.retryAfterSeconds));
31+
throw new RateLimitException({
32+
policyId: 'global',
33+
currentCount: globalCheck.retryAfterSeconds,
34+
limit: 0,
35+
windowResetLedger: 0,
36+
remainingLedgers: 0,
37+
retryAfterSeconds: globalCheck.retryAfterSeconds,
38+
limitType: 'global',
39+
});
40+
}
2741

28-
// Check rate limit and increment counter
42+
// ── 2. Per-wallet sliding window ────────────────────────────────────
43+
const walletAddress = this.extractWalletAddress(request, policyId);
44+
if (walletAddress) {
45+
const walletCheck = await this.rateLimitService.checkWalletLimit(walletAddress);
46+
if (!walletCheck.allowed) {
47+
response.setHeader('Retry-After', String(walletCheck.retryAfterSeconds));
48+
throw new RateLimitException({
49+
policyId: walletAddress,
50+
currentCount: walletCheck.retryAfterSeconds,
51+
limit: 0,
52+
windowResetLedger: 0,
53+
remainingLedgers: 0,
54+
retryAfterSeconds: walletCheck.retryAfterSeconds,
55+
limitType: 'wallet',
56+
});
57+
}
58+
}
59+
60+
// ── 3. Per-policy ledger-based limit ────────────────────────────────
61+
const currentLedger = await this.soroban.getLatestLedger();
2962
const result = await this.rateLimitService.checkAndIncrement(
3063
policyId,
3164
currentLedger,
3265
);
3366

3467
if (!result.allowed) {
68+
const retryAfterLedgers = result.windowResetLedger - currentLedger;
69+
const retryAfterSeconds = Math.max(1, retryAfterLedgers * 5); // ~5s per ledger
70+
response.setHeader('Retry-After', String(retryAfterSeconds));
3571
throw new RateLimitException({
3672
policyId,
3773
currentCount: result.currentCount,
3874
limit: result.limit,
3975
windowResetLedger: result.windowResetLedger,
40-
remainingLedgers: result.windowResetLedger - currentLedger,
76+
remainingLedgers: retryAfterLedgers,
77+
retryAfterSeconds,
78+
limitType: 'policy',
4179
});
4280
}
4381

@@ -53,4 +91,31 @@ export class RateLimitGuard implements CanActivate {
5391
return true;
5492
}
5593
}
94+
95+
/**
96+
* Extract wallet address from request (JWT user, body, or policyId).
97+
*/
98+
private extractWalletAddress(request: { user?: { walletAddress?: string }; body?: { holder?: string; policyId?: string } }, policyId?: string): string | undefined {
99+
// 1. Authenticated user
100+
if (request.user?.walletAddress) {
101+
return request.user.walletAddress;
102+
}
103+
104+
// 2. Explicit holder field in body
105+
if (request.body?.holder) {
106+
return request.body.holder;
107+
}
108+
109+
// 3. Parse from policyId (format: holderAddress:policyId)
110+
if (policyId && policyId.includes(':')) {
111+
const parts = policyId.split(':');
112+
// Stellar addresses start with G and are 56 chars
113+
const candidate = parts[0];
114+
if (candidate.length >= 56 && candidate.startsWith('G')) {
115+
return candidate;
116+
}
117+
}
118+
119+
return undefined;
120+
}
56121
}

0 commit comments

Comments
 (0)