Skip to content
88 changes: 88 additions & 0 deletions backend/src/__tests__/withdrawalDailyLimit.test.ts
Original file line number Diff line number Diff line change
@@ -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',
});
});
});
59 changes: 59 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
});
Comment on lines +1023 to +1026
});

// ─── Allowlist Admin Endpoints (Issue #375) ──────────────────────────────────

/**
Expand Down
219 changes: 219 additions & 0 deletions backend/src/middleware/withdrawalDailyLimit.ts
Original file line number Diff line number Diff line change
@@ -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<string, WithdrawalLimitOverrideRecord>();
const inMemoryAuditLog: Array<Record<string, unknown>> = [];
Comment on lines +26 to +27

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<Decimal> {
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));
Comment on lines +49 to +62
}

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<WithdrawalLimitCheckResult> {
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<void> => {
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<Record<string, unknown>> {
return inMemoryAuditLog.slice(0, limit);
}

export function clearWithdrawalLimitStateForTests(): void {
inMemoryOverrides.clear();
inMemoryAuditLog.length = 0;
}
Loading