diff --git a/backend/src/__tests__/withdrawalDailyLimit.test.ts b/backend/src/__tests__/withdrawalDailyLimit.test.ts new file mode 100644 index 00000000..8074ece8 --- /dev/null +++ b/backend/src/__tests__/withdrawalDailyLimit.test.ts @@ -0,0 +1,88 @@ +import request from 'supertest'; +import app from '../index'; +import { prisma } from '../prisma'; +import { clearAdminAuditLogsForTests } from '../adminAudit'; +import { clearWithdrawalLimitStateForTests } from '../middleware/withdrawalDailyLimit'; +import { registerApiKey } from '../middleware/apiKeyAuth'; +import { normalizeWalletAddress } from '../walletUtils'; + +describe('Withdrawal daily limit guard', () => { + const superAdminApiKey = 'super-admin-withdrawal-key'; + const walletAddress = `G${'A'.repeat(55)}`; + + beforeEach(async () => { + clearAdminAuditLogsForTests(); + clearWithdrawalLimitStateForTests(); + process.env.ALLOWLIST_ENABLED = 'false'; + process.env.WITHDRAWAL_DAILY_LIMIT_USDC = '1000'; + registerApiKey(superAdminApiKey, { role: 'super-admin' }); + await prisma.transaction.deleteMany({ where: { user: walletAddress, type: 'withdrawal' } }); + }); + + async function seedWithdrawals(total: string) { + await prisma.transaction.create({ + data: { + id: `seed-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + user: walletAddress, + amount: total, + type: 'withdrawal', + timestamp: new Date(), + }, + }); + } + + it('blocks withdrawals that exceed the configured daily limit', async () => { + await seedWithdrawals('900'); + + const response = await request(app) + .post('/api/v1/vault/withdrawals') + .send({ amount: 200, asset: 'USDC', walletAddress }); + + expect(response.status).toBe(429); + expect(response.body.message).toMatch(/daily withdrawal limit/i); + expect(response.body.limit).toMatchObject({ + dailyLimit: '1000', + usedToday: '900', + remaining: '100', + requested: '200', + }); + expect(response.body.limit.resetsAt).toBeTruthy(); + }); + + it('allows admin override with reason for super-admins', async () => { + await seedWithdrawals('900'); + + const response = await request(app) + .post('/api/v1/vault/withdrawals') + .set('Authorization', `ApiKey ${superAdminApiKey}`) + .set('x-admin-override-withdrawal', 'true') + .set('x-admin-id', 'GADMIN000000000000000000000000000000000000000000000001') + .send({ + amount: 200, + asset: 'USDC', + walletAddress, + overrideReason: 'manual fraud review clearance', + }); + + expect(response.status).not.toBe(429); + expect(response.body.message || '').not.toMatch(/daily withdrawal limit/i); + }); + + it('creates a temporary override via admin endpoint', async () => { + const response = await request(app) + .post('/admin/withdrawal-limits/override') + .set('Authorization', `ApiKey ${superAdminApiKey}`) + .set('x-admin-id', 'GADMIN000000000000000000000000000000000000000000000001') + .send({ + walletAddress, + reason: 'support ticket #991', + ttlSeconds: 1800, + }); + + expect(response.status).toBe(201); + expect(response.body.override).toMatchObject({ + wallet: walletAddress, + reason: 'support ticket #991', + }); + }); +}); diff --git a/backend/src/index.ts b/backend/src/index.ts index ee706284..1242e1ff 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -29,6 +29,10 @@ import { corsMiddleware } from './middleware/cors'; import { geofencingMiddleware } from './middleware/geofencing'; import { cacheMiddleware, invalidateCache, getCacheStats } from './middleware/cache'; import { validate, LoginSchema, RefreshSchema } from './middleware/validate'; +import { + setWithdrawalLimitOverride, + listWithdrawalLimitAuditEntries, +} from './middleware/withdrawalDailyLimit'; import { validateApiKey, authenticateApiKeyValue, @@ -967,6 +971,61 @@ app.post('/admin/events/replay', validateApiKey, async (req: Request, res: Respo } }); +/** + * POST /admin/withdrawal-limits/override + * Grants a temporary admin override for a wallet's daily withdrawal limit. + * Requires super-admin API key. + */ +app.post('/admin/withdrawal-limits/override', validateApiKey, async (req: Request, res: Response) => { + const walletAddress = typeof req.body?.walletAddress === 'string' ? req.body.walletAddress.trim() : ''; + const reason = typeof req.body?.reason === 'string' ? req.body.reason.trim() : ''; + const ttlSeconds = + typeof req.body?.ttlSeconds === 'number' && req.body.ttlSeconds > 0 + ? req.body.ttlSeconds + : 3600; + + if (!walletAddress || !reason) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'walletAddress and reason are required', + }); + return; + } + + if (!hasRequiredApiKeyRole(req, 'super-admin')) { + res.status(403).json({ + error: 'Forbidden', + status: 403, + message: 'Super-admin role is required to override withdrawal limits', + }); + return; + } + + const actor = resolveActingAdminAddress(req); + const override = setWithdrawalLimitOverride(walletAddress, reason, actor, ttlSeconds); + + await recordAdminAuditLog(req, 'withdrawal.limit.override.grant', 201, { + walletAddress: override.wallet, + reason: override.reason, + expiresAt: override.expiresAt, + actor, + }); + + res.status(201).json({ override }); +}); + +/** + * GET /admin/withdrawal-limits/audit + * Lists recent blocked and overridden withdrawal attempts. + */ +app.get('/admin/withdrawal-limits/audit', validateApiKey, (req: Request, res: Response) => { + const limit = typeof req.query.limit === 'string' ? parseInt(req.query.limit, 10) : 50; + res.status(200).json({ + entries: listWithdrawalLimitAuditEntries(Number.isFinite(limit) ? limit : 50), + }); +}); + // ─── Allowlist Admin Endpoints (Issue #375) ────────────────────────────────── /** diff --git a/backend/src/middleware/withdrawalDailyLimit.ts b/backend/src/middleware/withdrawalDailyLimit.ts new file mode 100644 index 00000000..ef61249e --- /dev/null +++ b/backend/src/middleware/withdrawalDailyLimit.ts @@ -0,0 +1,219 @@ +import Decimal from 'decimal.js'; +import type { Request, Response, NextFunction } from 'express'; +import { prisma } from '../prisma'; +import { normalizeWalletAddress } from '../walletUtils'; +import { recordAdminAuditLog } from '../adminAudit'; +import { hasRequiredApiKeyRole, authenticateApiKeyValue } from './apiKeyAuth'; + +export interface WithdrawalLimitCheckResult { + allowed: boolean; + limit: string; + used: string; + remaining: string; + requested: string; + resetsAt: string; + overridden: boolean; +} + +interface WithdrawalLimitOverrideRecord { + wallet: string; + reason: string; + actor: string; + expiresAt: string; + createdAt: string; +} + +const inMemoryOverrides = new Map(); +const inMemoryAuditLog: Array> = []; + +function getDailyLimit(): Decimal { + const raw = process.env.WITHDRAWAL_DAILY_LIMIT_USDC || '10000'; + const parsed = new Decimal(raw); + if (!parsed.isFinite() || parsed.lte(0)) { + return new Decimal(10000); + } + return parsed; +} + +function getUtcDayBounds(now = new Date()): { start: Date; end: Date; resetsAt: string } { + const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); + const end = new Date(start); + end.setUTCDate(end.getUTCDate() + 1); + return { + start, + end, + resetsAt: end.toISOString(), + }; +} + +async function sumWithdrawalsForDay(wallet: string, start: Date, end: Date): Promise { + const rows = await prisma.transaction.findMany({ + where: { + user: wallet, + type: 'withdrawal', + timestamp: { + gte: start, + lt: end, + }, + }, + select: { amount: true }, + }); + + return rows.reduce((total: Decimal, row: { amount: string }) => total.plus(new Decimal(row.amount || '0')), new Decimal(0)); +} + +function getActiveOverride(wallet: string): WithdrawalLimitOverrideRecord | null { + const override = inMemoryOverrides.get(wallet); + if (!override) { + return null; + } + if (Date.parse(override.expiresAt) <= Date.now()) { + inMemoryOverrides.delete(wallet); + return null; + } + return override; +} + +export function setWithdrawalLimitOverride( + wallet: string, + reason: string, + actor: string, + ttlSeconds = 3600, +): WithdrawalLimitOverrideRecord { + const normalizedWallet = normalizeWalletAddress(wallet); + const record: WithdrawalLimitOverrideRecord = { + wallet: normalizedWallet, + reason: reason.trim(), + actor, + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + ttlSeconds * 1000).toISOString(), + }; + inMemoryOverrides.set(normalizedWallet, record); + return record; +} + +export async function evaluateWithdrawalLimit( + walletAddress: string, + amount: number | string, + options: { overridden?: boolean } = {}, +): Promise { + const wallet = normalizeWalletAddress(walletAddress); + const limit = getDailyLimit(); + const requested = new Decimal(amount); + const { start, end, resetsAt } = getUtcDayBounds(); + const used = await sumWithdrawalsForDay(wallet, start, end); + const remaining = Decimal.max(limit.minus(used), 0); + const overrideActive = Boolean(getActiveOverride(wallet)); + const overridden = options.overridden === true || overrideActive; + + return { + allowed: overridden || used.plus(requested).lte(limit), + limit: limit.toString(), + used: used.toString(), + remaining: remaining.toString(), + requested: requested.toString(), + resetsAt, + overridden, + }; +} + +export function withdrawalDailyLimitMiddleware() { + return async (req: Request, res: Response, next: NextFunction): Promise => { + const walletAddress = req.body?.walletAddress as string | undefined; + const amount = req.body?.amount; + + if (!walletAddress || amount === undefined) { + next(); + return; + } + + const adminOverrideRequested = + req.get('x-admin-override-withdrawal') === 'true' || + req.get('x-withdrawal-limit-override') === 'true'; + + if (adminOverrideRequested) { + const authHeader = req.get('authorization') || ''; + const apiKeyMatch = authHeader.match(/^ApiKey\s+(.+)$/i); + if (apiKeyMatch) { + const authenticated = authenticateApiKeyValue(apiKeyMatch[1]); + if (authenticated) { + req.authApiKeyHash = authenticated.hash; + req.authApiKeyRole = authenticated.role; + } + } + } + + const overridden = + adminOverrideRequested && + hasRequiredApiKeyRole(req, 'super-admin') && + typeof req.body?.overrideReason === 'string' && + req.body.overrideReason.trim().length > 0; + + if (adminOverrideRequested && !overridden) { + await recordAdminAuditLog(req, 'withdrawal.limit.override.denied', 403, { + walletAddress, + reason: 'Super-admin role and overrideReason are required for withdrawal limit override', + }); + res.status(403).json({ + error: 'Forbidden', + status: 403, + message: 'Super-admin role and overrideReason are required to override withdrawal limits', + }); + return; + } + + const evaluation = await evaluateWithdrawalLimit(walletAddress, amount, { overridden }); + + if (!evaluation.allowed) { + await recordAdminAuditLog(req, 'withdrawal.limit.blocked', 429, { + walletAddress: normalizeWalletAddress(walletAddress), + ...evaluation, + }); + inMemoryAuditLog.unshift({ + type: 'blocked', + walletAddress: normalizeWalletAddress(walletAddress), + ...evaluation, + at: new Date().toISOString(), + }); + res.status(429).json({ + error: 'Too Many Requests', + status: 429, + message: 'Daily withdrawal limit exceeded', + limit: { + dailyLimit: evaluation.limit, + usedToday: evaluation.used, + remaining: evaluation.remaining, + requested: evaluation.requested, + resetsAt: evaluation.resetsAt, + }, + }); + return; + } + + if (overridden) { + await recordAdminAuditLog(req, 'withdrawal.limit.override', 200, { + walletAddress: normalizeWalletAddress(walletAddress), + overrideReason: req.body.overrideReason, + ...evaluation, + }); + inMemoryAuditLog.unshift({ + type: 'override', + walletAddress: normalizeWalletAddress(walletAddress), + overrideReason: req.body.overrideReason, + ...evaluation, + at: new Date().toISOString(), + }); + } + + next(); + }; +} + +export function listWithdrawalLimitAuditEntries(limit = 50): Array> { + return inMemoryAuditLog.slice(0, limit); +} + +export function clearWithdrawalLimitStateForTests(): void { + inMemoryOverrides.clear(); + inMemoryAuditLog.length = 0; +}