diff --git a/server/src/controllers/payment.controller.ts b/server/src/controllers/payment.controller.ts index 797f02b..c14267b 100644 --- a/server/src/controllers/payment.controller.ts +++ b/server/src/controllers/payment.controller.ts @@ -10,12 +10,15 @@ import { ApiError } from '../middleware/error.middleware'; export const createPayment = async (req: Request, res: Response, next: NextFunction) => { try { const { merchantId, fromAddress, amount, assetCode, assetIssuer, memo, minReceive } = req.body; + const userId = (req as any).user?.userId; if (!merchantId || !fromAddress || !amount || !assetCode) { throw new ApiError(400, 'Missing required fields: merchantId, fromAddress, amount, assetCode', 'VALIDATION_ERROR'); } + if (!userId) throw new ApiError(401, 'Authentication required', 'AUTH_REQUIRED'); const result = await paymentService.createPayment( + userId, merchantId, fromAddress, amount, @@ -24,7 +27,6 @@ export const createPayment = async (req: Request, res: Response, next: NextFunct memo, minReceive, ); - 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 c64b98d..cf8c3c6 100644 --- a/server/src/services/payment.service.ts +++ b/server/src/services/payment.service.ts @@ -66,6 +66,7 @@ class PaymentService { * The server NEVER touches user private keys. */ async createPayment( + userId: string, merchantId: string, fromAddress: string, amount: string, @@ -80,9 +81,10 @@ class PaymentService { if (!merchant.active) throw new ApiError(400, 'Merchant is inactive', 'MERCHANT_INACTIVE'); // 2. Compliance - const sanctioned = await complianceService.checkSanctions(fromAddress); - if (sanctioned) throw new ApiError(403, 'Address is sanctioned', 'SANCTIONED'); - await complianceService.checkVelocity(fromAddress, BigInt(amount)); + if (await complianceService.checkSanctions(userId)) { + throw new ApiError(403, 'User is sanctioned', 'COMPLIANCE_SANCTIONS'); + } + await complianceService.checkVelocity(userId, amount); // 3. Build the Soroban PaymentRouter.pay() invocation const routerContract = config.stellar.paymentRouterContract; @@ -174,6 +176,8 @@ class PaymentService { // 2. Compliance const sanctioned = await complianceService.checkSanctions(fromUserId); if (sanctioned) throw new ApiError(403, 'Sender is sanctioned', 'SANCTIONED'); + const recipientSanctioned = await complianceService.checkSanctions(toUserId); + if (recipientSanctioned) throw new ApiError(403, 'Recipient is sanctioned', 'SANCTIONED'); await complianceService.checkVelocity(fromUserId, BigInt(amount)); // 3. Build classic Stellar payment XDR