From 45d5ad3d851dd73a40fd8667af3be601519fede3 Mon Sep 17 00:00:00 2001 From: Chibuikem Michael Ilonze Date: Fri, 20 Feb 2026 18:48:50 +0100 Subject: [PATCH] feat: add sanctions and velocity compliance checks --- server/src/controllers/payment.controller.ts | 5 +- server/src/services/anchor.service.ts | 7 ++ server/src/services/compliance.service.ts | 79 +++++++++++++++++++- server/src/services/payment.service.ts | 15 +++- 4 files changed, 100 insertions(+), 6 deletions(-) diff --git a/server/src/controllers/payment.controller.ts b/server/src/controllers/payment.controller.ts index c52a0a0..c0783a9 100644 --- a/server/src/controllers/payment.controller.ts +++ b/server/src/controllers/payment.controller.ts @@ -11,7 +11,10 @@ export const createPayment = async (req: Request, res: Response, next: NextFunct const { merchantId, fromAddress, amount, assetCode, assetIssuer } = req.body; if (!merchantId || !fromAddress || !amount || !assetCode) throw new ApiError(400, 'Missing payment fields'); - const result = await paymentService.createPayment(merchantId, fromAddress, amount, assetCode, assetIssuer); + const userId = (req as any).user?.userId; + if (!userId) throw new ApiError(401, 'Unauthorized'); + + const result = await paymentService.createPayment(userId, merchantId, fromAddress, amount, assetCode, assetIssuer); res.status(201).json(result); } catch (error) { next(error); diff --git a/server/src/services/anchor.service.ts b/server/src/services/anchor.service.ts index 39f7f36..415eb99 100644 --- a/server/src/services/anchor.service.ts +++ b/server/src/services/anchor.service.ts @@ -1,5 +1,7 @@ import prisma from '../utils/prisma'; import logger from '../utils/logger'; +import { ApiError } from '../middleware/error.middleware'; +import complianceService from './compliance.service'; /** * Skeletal Blueprint for Anchor Integration (SEP-24/31). @@ -12,6 +14,11 @@ class AnchorService { async createWithdrawal(userId: string, destinationAddress: string, amount: string, asset: string) { logger.info(`Skeletal Anchor: Initiating withdrawal for ${userId}`); + if (await complianceService.checkSanctions(userId)) { + throw new ApiError(403, 'User is sanctioned', 'COMPLIANCE_SANCTIONS'); + } + await complianceService.checkVelocity(userId, amount); + return prisma.withdrawal.create({ data: { userId, diff --git a/server/src/services/compliance.service.ts b/server/src/services/compliance.service.ts index ece8557..331a588 100644 --- a/server/src/services/compliance.service.ts +++ b/server/src/services/compliance.service.ts @@ -1,25 +1,96 @@ import connection from '../utils/redis'; import logger from '../utils/logger'; +import { ApiError } from '../middleware/error.middleware'; + +const DAY_SECONDS = 24 * 60 * 60; +const STROOPS_PER_UNIT = 10_000_000n; + +const parseAmountToMinorUnits = (amount: string | bigint): bigint => { + if (typeof amount === 'bigint') return amount; + if (typeof amount !== 'string') throw new ApiError(400, 'Invalid amount', 'VALIDATION_ERROR'); + + const normalized = amount.trim(); + if (!/^\d+(\.\d+)?$/.test(normalized)) throw new ApiError(400, 'Invalid amount format', 'VALIDATION_ERROR'); + + const [whole, fractional = ''] = normalized.split('.'); + if (fractional.length > 7) throw new ApiError(400, 'Amount has too many decimals', 'VALIDATION_ERROR'); + + const paddedFractional = (fractional + '0000000').slice(0, 7); + return BigInt(whole) * STROOPS_PER_UNIT + BigInt(paddedFractional); +}; /** * Skeletal Blueprint for Risk & Compliance. * Implements velocity limits and sanctions screening interfaces. */ class ComplianceService { + private readonly dailyLimit: bigint; + private readonly sanctionsBlacklist: Set; + + constructor() { + const limitEnv = process.env.COMPLIANCE_DAILY_LIMIT_USD || '1000'; + this.dailyLimit = parseAmountToMinorUnits(limitEnv); + + const blacklist = process.env.COMPLIANCE_SANCTIONS_BLACKLIST || ''; + this.sanctionsBlacklist = new Set( + blacklist + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + ); + } + /** * Checks if a user is on a sanctions blacklist (e.g., OFAC). */ async checkSanctions(userId: string): Promise { // Blueprint: Integrate with screening providers (Chainalysis, TRM, OFAC API). - return false; + const normalized = userId.trim(); + return this.sanctionsBlacklist.has(normalized); } /** * Enforces rolling 24h volume limits using Redis. */ - async checkVelocity(userId: string, amount: bigint): Promise { - // Blueprint: INCR volume key in Redis with 24h TTL -> Throw error if > limit. - logger.info(`Skeletal Compliance: Checking velocity for user ${userId}`); + async checkVelocity(userId: string, amount: string | bigint): Promise { + const amountUnits = parseAmountToMinorUnits(amount); + if (amountUnits <= 0n) throw new ApiError(400, 'Amount must be positive', 'VALIDATION_ERROR'); + + const now = Date.now(); + const cutoff = now - DAY_SECONDS * 1000; + const key = `compliance:velocity:${userId}`; + const member = `${now}:${amountUnits.toString()}`; + + try { + const results = await connection + .multi() + .zremrangebyscore(key, 0, cutoff) + .zadd(key, now, member) + .zrangebyscore(key, cutoff, now) + .expire(key, DAY_SECONDS + 3600) + .exec(); + + const rangeResult = results?.[2]?.[1] as string[] | undefined; + const entries = rangeResult ?? []; + + let total = 0n; + for (const entry of entries) { + const [, amountStr] = entry.split(':'); + if (!amountStr) continue; + total += BigInt(amountStr); + } + + logger.info(`Compliance velocity check for user ${userId}: total=${total.toString()}`); + + if (total > this.dailyLimit) { + throw new ApiError(403, 'Velocity limit exceeded', 'COMPLIANCE_VELOCITY'); + } + } catch (error) { + if (error instanceof ApiError) throw error; + logger.error('Compliance velocity check failed:', { error }); + // Fail open to avoid blocking users on Redis failure + return; + } } } diff --git a/server/src/services/payment.service.ts b/server/src/services/payment.service.ts index a069f59..015d94a 100644 --- a/server/src/services/payment.service.ts +++ b/server/src/services/payment.service.ts @@ -15,7 +15,12 @@ class PaymentService { * Builds an unsigned XDR for a merchant payment. * Flow: Validate Merchant -> Check Compliance -> Build Stellar Payment OP -> Sponsor Fees. */ - async createPayment(merchantId: string, fromAddress: string, amount: string, assetCode: string, assetIssuer?: string) { + async createPayment(userId: string, merchantId: string, fromAddress: string, amount: string, assetCode: string, assetIssuer?: string) { + if (await complianceService.checkSanctions(userId)) { + throw new ApiError(403, 'User is sanctioned', 'COMPLIANCE_SANCTIONS'); + } + await complianceService.checkVelocity(userId, amount); + const merchant = await prisma.merchant.findUnique({ where: { merchantId } }); if (!merchant) throw new ApiError(404, 'Merchant not found'); @@ -41,6 +46,14 @@ class PaymentService { * Skeletal blueprint for User-to-User transfers. */ async transfer(fromUserId: string, toUserId: string, amount: string, assetCode: string, assetIssuer?: string) { + if (await complianceService.checkSanctions(fromUserId)) { + throw new ApiError(403, 'User is sanctioned', 'COMPLIANCE_SANCTIONS'); + } + if (await complianceService.checkSanctions(toUserId)) { + throw new ApiError(403, 'Recipient is sanctioned', 'COMPLIANCE_SANCTIONS'); + } + await complianceService.checkVelocity(fromUserId, amount); + // Implementation: Resolve addresses -> Build Payment XDR -> Return for signing. return { xdr: '...', status: 'PENDING' }; }