Skip to content

Commit 20f841a

Browse files
authored
feat: add sanctions and velocity compliance checks (#80)
1 parent f650b8a commit 20f841a

File tree

4 files changed

+92
-8
lines changed

4 files changed

+92
-8
lines changed

server/src/controllers/payment.controller.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,15 @@ import { ApiError } from '../middleware/error.middleware';
1010
export const createPayment = async (req: Request, res: Response, next: NextFunction) => {
1111
try {
1212
const { merchantId, fromAddress, amount, assetCode, assetIssuer, memo, minReceive } = req.body;
13+
const userId = (req as any).user?.userId;
1314

1415
if (!merchantId || !fromAddress || !amount || !assetCode) {
1516
throw new ApiError(400, 'Missing required fields: merchantId, fromAddress, amount, assetCode', 'VALIDATION_ERROR');
1617
}
18+
if (!userId) throw new ApiError(401, 'Authentication required', 'AUTH_REQUIRED');
1719

1820
const result = await paymentService.createPayment(
21+
userId,
1922
merchantId,
2023
fromAddress,
2124
amount,
@@ -24,7 +27,6 @@ export const createPayment = async (req: Request, res: Response, next: NextFunct
2427
memo,
2528
minReceive,
2629
);
27-
2830
res.status(201).json(result);
2931
} catch (error) {
3032
next(error);

server/src/services/anchor.service.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import prisma from '../utils/prisma';
22
import logger from '../utils/logger';
3+
import { ApiError } from '../middleware/error.middleware';
4+
import complianceService from './compliance.service';
35

46
/**
57
* Skeletal Blueprint for Anchor Integration (SEP-24/31).
@@ -12,6 +14,11 @@ class AnchorService {
1214
async createWithdrawal(userId: string, destinationAddress: string, amount: string, asset: string) {
1315
logger.info(`Skeletal Anchor: Initiating withdrawal for ${userId}`);
1416

17+
if (await complianceService.checkSanctions(userId)) {
18+
throw new ApiError(403, 'User is sanctioned', 'COMPLIANCE_SANCTIONS');
19+
}
20+
await complianceService.checkVelocity(userId, amount);
21+
1522
return prisma.withdrawal.create({
1623
data: {
1724
userId,

server/src/services/compliance.service.ts

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,96 @@
11
import connection from '../utils/redis';
22
import logger from '../utils/logger';
3+
import { ApiError } from '../middleware/error.middleware';
4+
5+
const DAY_SECONDS = 24 * 60 * 60;
6+
const STROOPS_PER_UNIT = 10_000_000n;
7+
8+
const parseAmountToMinorUnits = (amount: string | bigint): bigint => {
9+
if (typeof amount === 'bigint') return amount;
10+
if (typeof amount !== 'string') throw new ApiError(400, 'Invalid amount', 'VALIDATION_ERROR');
11+
12+
const normalized = amount.trim();
13+
if (!/^\d+(\.\d+)?$/.test(normalized)) throw new ApiError(400, 'Invalid amount format', 'VALIDATION_ERROR');
14+
15+
const [whole, fractional = ''] = normalized.split('.');
16+
if (fractional.length > 7) throw new ApiError(400, 'Amount has too many decimals', 'VALIDATION_ERROR');
17+
18+
const paddedFractional = (fractional + '0000000').slice(0, 7);
19+
return BigInt(whole) * STROOPS_PER_UNIT + BigInt(paddedFractional);
20+
};
321

422
/**
523
* Skeletal Blueprint for Risk & Compliance.
624
* Implements velocity limits and sanctions screening interfaces.
725
*/
826
class ComplianceService {
27+
private readonly dailyLimit: bigint;
28+
private readonly sanctionsBlacklist: Set<string>;
29+
30+
constructor() {
31+
const limitEnv = process.env.COMPLIANCE_DAILY_LIMIT_USD || '1000';
32+
this.dailyLimit = parseAmountToMinorUnits(limitEnv);
33+
34+
const blacklist = process.env.COMPLIANCE_SANCTIONS_BLACKLIST || '';
35+
this.sanctionsBlacklist = new Set(
36+
blacklist
37+
.split(',')
38+
.map((entry) => entry.trim())
39+
.filter((entry) => entry.length > 0)
40+
);
41+
}
42+
943
/**
1044
* Checks if a user is on a sanctions blacklist (e.g., OFAC).
1145
*/
1246
async checkSanctions(userId: string): Promise<boolean> {
1347
// Blueprint: Integrate with screening providers (Chainalysis, TRM, OFAC API).
14-
return false;
48+
const normalized = userId.trim();
49+
return this.sanctionsBlacklist.has(normalized);
1550
}
1651

1752
/**
1853
* Enforces rolling 24h volume limits using Redis.
1954
*/
20-
async checkVelocity(userId: string, amount: bigint): Promise<void> {
21-
// Blueprint: INCR volume key in Redis with 24h TTL -> Throw error if > limit.
22-
logger.info(`Skeletal Compliance: Checking velocity for user ${userId}`);
55+
async checkVelocity(userId: string, amount: string | bigint): Promise<void> {
56+
const amountUnits = parseAmountToMinorUnits(amount);
57+
if (amountUnits <= 0n) throw new ApiError(400, 'Amount must be positive', 'VALIDATION_ERROR');
58+
59+
const now = Date.now();
60+
const cutoff = now - DAY_SECONDS * 1000;
61+
const key = `compliance:velocity:${userId}`;
62+
const member = `${now}:${amountUnits.toString()}`;
63+
64+
try {
65+
const results = await connection
66+
.multi()
67+
.zremrangebyscore(key, 0, cutoff)
68+
.zadd(key, now, member)
69+
.zrangebyscore(key, cutoff, now)
70+
.expire(key, DAY_SECONDS + 3600)
71+
.exec();
72+
73+
const rangeResult = results?.[2]?.[1] as string[] | undefined;
74+
const entries = rangeResult ?? [];
75+
76+
let total = 0n;
77+
for (const entry of entries) {
78+
const [, amountStr] = entry.split(':');
79+
if (!amountStr) continue;
80+
total += BigInt(amountStr);
81+
}
82+
83+
logger.info(`Compliance velocity check for user ${userId}: total=${total.toString()}`);
84+
85+
if (total > this.dailyLimit) {
86+
throw new ApiError(403, 'Velocity limit exceeded', 'COMPLIANCE_VELOCITY');
87+
}
88+
} catch (error) {
89+
if (error instanceof ApiError) throw error;
90+
logger.error('Compliance velocity check failed:', { error });
91+
// Fail open to avoid blocking users on Redis failure
92+
return;
93+
}
2394
}
2495
}
2596

server/src/services/payment.service.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ class PaymentService {
6666
* The server NEVER touches user private keys.
6767
*/
6868
async createPayment(
69+
userId: string,
6970
merchantId: string,
7071
fromAddress: string,
7172
amount: string,
@@ -80,9 +81,10 @@ class PaymentService {
8081
if (!merchant.active) throw new ApiError(400, 'Merchant is inactive', 'MERCHANT_INACTIVE');
8182

8283
// 2. Compliance
83-
const sanctioned = await complianceService.checkSanctions(fromAddress);
84-
if (sanctioned) throw new ApiError(403, 'Address is sanctioned', 'SANCTIONED');
85-
await complianceService.checkVelocity(fromAddress, BigInt(amount));
84+
if (await complianceService.checkSanctions(userId)) {
85+
throw new ApiError(403, 'User is sanctioned', 'COMPLIANCE_SANCTIONS');
86+
}
87+
await complianceService.checkVelocity(userId, amount);
8688

8789
// 3. Build the Soroban PaymentRouter.pay() invocation
8890
const routerContract = config.stellar.paymentRouterContract;
@@ -174,6 +176,8 @@ class PaymentService {
174176
// 2. Compliance
175177
const sanctioned = await complianceService.checkSanctions(fromUserId);
176178
if (sanctioned) throw new ApiError(403, 'Sender is sanctioned', 'SANCTIONED');
179+
const recipientSanctioned = await complianceService.checkSanctions(toUserId);
180+
if (recipientSanctioned) throw new ApiError(403, 'Recipient is sanctioned', 'SANCTIONED');
177181
await complianceService.checkVelocity(fromUserId, BigInt(amount));
178182

179183
// 3. Build classic Stellar payment XDR

0 commit comments

Comments
 (0)