diff --git a/backend/src/common/filters/http-exception.filter.ts b/backend/src/common/filters/http-exception.filter.ts new file mode 100644 index 000000000..80983580a --- /dev/null +++ b/backend/src/common/filters/http-exception.filter.ts @@ -0,0 +1,63 @@ +import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, Logger } from '@nestjs/common'; +import { HttpAdapterHost } from '@nestjs/core'; + +@Catch() +export class HttpExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger(HttpExceptionFilter.name); + + constructor(private readonly httpAdapterHost: HttpAdapterHost) {} + + catch(exception: unknown, host: ArgumentsHost) { + const { httpAdapter } = this.httpAdapterHost; + const ctx = host.switchToHttp(); + const request = ctx.getRequest(); + const response = ctx.getResponse(); + + // Generate or extract request ID for correlation + const requestId = + request.headers['x-request-id'] || + request.id || + `req-${Date.now()}-${Math.floor(Math.random() * 10000)}`; + + // Determine HTTP status code + const httpStatus = + exception instanceof HttpException + ? exception.getStatus() + : HttpStatus.INTERNAL_SERVER_ERROR; + + // Extract message from exception + const message = + exception instanceof HttpException + ? (exception.response as any)?.message || exception.message + : exception.message || + 'Internal server error'; + + // Use status code as error code (can be customized further) + const errorCode = httpStatus.toString(); + + // Build response envelope + const responseBody = { + statusCode: httpStatus, + message, + errorCode, + timestamp: new Date().toISOString(), + path: httpAdapter.getRequestUrl(request), + requestId, + }; + + // Log error with request ID for correlation + const logMessage = `[Request ID: ${requestId}] ${message}`; + if ( + process.env.NODE_ENV !== 'production' && + exception instanceof Error && + exception.stack + ) { + this.logger.error(logMessage, exception.stack); + } else { + this.logger.error(logMessage); + } + + // Send response + httpAdapter.reply(response, responseBody, httpStatus); + } +} \ No newline at end of file diff --git a/harvest-finance/backend/src/analytics/analytics.module.ts b/harvest-finance/backend/src/analytics/analytics.module.ts index 372373772..11ab2df3e 100644 --- a/harvest-finance/backend/src/analytics/analytics.module.ts +++ b/harvest-finance/backend/src/analytics/analytics.module.ts @@ -7,14 +7,16 @@ import { Withdrawal } from '../database/entities/withdrawal.entity'; import { AnalyticsService } from './analytics.service'; import { AnalyticsController } from './analytics.controller'; import { AnalyticsInterceptor } from './analytics.interceptor'; +import { RiskService } from './risk.service'; @Module({ imports: [TypeOrmModule.forFeature([Vault, Deposit, Withdrawal])], controllers: [AnalyticsController], providers: [ AnalyticsService, + RiskService, { provide: APP_INTERCEPTOR, useClass: AnalyticsInterceptor }, ], - exports: [AnalyticsService], + exports: [AnalyticsService, RiskService], }) export class AnalyticsModule {} diff --git a/harvest-finance/backend/src/analytics/risk.service.ts b/harvest-finance/backend/src/analytics/risk.service.ts new file mode 100644 index 000000000..542c1d5d8 --- /dev/null +++ b/harvest-finance/backend/src/analytics/risk.service.ts @@ -0,0 +1,134 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { Vault } from '../database/entities/vault.entity'; +import { Deposit } from '../database/entities/deposit.entity'; +import { NotificationsService } from '../notifications/notifications.service'; +import { NotificationType } from '../database/entities/notification.entity'; + +@Injectable() +export class RiskService { + private readonly logger = new Logger(RiskService.name); + + constructor( + @InjectRepository(Vault) private vaultRepo: Repository, + @InjectRepository(Deposit) private depositRepo: Repository, + private readonly notificationService: NotificationsService, + ) {} + + /** + * Calculate depositor concentration for a given vault. + * Returns an array of objects containing userId and their concentration percentage. + */ + async calculateDepositorConcentration(vaultId: string): Promise> { + const query = this.depositRepo + .createQueryBuilder('deposit') + .select('deposit.userId', 'userId') + .addSelect('SUM(deposit.amount)', 'totalAmount') + .where('deposit.vaultId = :vaultId', { vaultId }) + .andWhere('deposit.status = :status', { status: 'CONFIRMED' }) + .groupBy('deposit.userId'); + + const results = await query.getRawMany<{ userId: string; totalAmount: string }>(); + + // Get total vault TVL (sum of all confirmed deposits) + const totalResult = await this.depositRepo + .createQueryBuilder('deposit') + .select('COALESCE(SUM(deposit.amount), 0)', 'total') + .where('deposit.vaultId = :vaultId', { vaultId }) + .andWhere('deposit.status = :status', { status: 'CONFIRMED' }) + .getRawOne<{ total: string }>(); + + const vaultTvl = parseFloat(totalResult?.total ?? '0'); + + if (vaultTvl === 0) { + return []; + } + + return results.map(result => ({ + userId: result.userId, + concentration: parseFloat(result.totalAmount) / vaultTvl, + })); + } + + /** + * Check all vaults for depositor concentration risk and send alerts if thresholds are exceeded. + */ + @Cron(CronExpression.EVERY_HOUR) + async checkVaultConcentrationRisks() { + this.logger.log('Starting hourly depositor concentration risk check'); + + const vaults = await this.vaultRepo.find(); + + for (const vault of vaults) { + try { + const concentrations = await this.calculateDepositorConcentration(vault.id); + const maxConcentration = Math.max(...concentrations.map(c => c.concentration), 0); + + // If any depositor exceeds the threshold, send an alert + if (maxConcentration > vault.depositorConcentrationThreshold) { + // Find the depositor(s) exceeding the threshold + const offendingDepositors = concentrations.filter(c => c.concentration > vault.depositorConcentrationThreshold); + + for (const depositor of offendingDepositors) { + await this.notificationService.create({ + userId: vault.ownerId, // Send alert to vault owner + title: `Depositor Concentration Risk Alert for Vault ${vault.vaultName}`, + message: `Depositor ${depositor.userId} controls ${(depositor.concentration * 100).toFixed(2)}% of vault.depositorConcentrationThreshold * 100)}%`, + type: NotificationType.DEPOSITOR_CONCENTRATION, + adminOnly: false, + }); + } + + this.logger.warn(`Vault ${vault.id} (${vault.vaultName}) has depositor concentration risk: max concentration ${(maxConcentration * 100).toFixed(2)}% exceeds threshold ${(vault.depositorConcentrationThreshold * 100).toFixed(2)}%`); + } + } catch (error) { + this.logger.error(`Error checking concentration risk for vault ${vault.id}:`, error); + } + } + + this.logger.log('Completed hourly depositor concentration risk check'); + } + + /** + * Get depositor concentration data for a specific vault. + * Used for the risk-metrics endpoint. + */ + async getVaultDepositorConcentration(vaultId: string): Promise<{ + vaultId: string; + totalVaultTvl: number; + depositorConcentrations: Array<{ userId: string; concentration: number; percentage: string }>; + maxConcentration: number; + threshold: number; + }> { + const concentrations = await this.calculateDepositorConcentration(vaultId); + const vault = await this.vaultRepo.findOne({ where: { id: vaultId } }); + + if (!vault) { + throw new Error(`Vault not found: ${vaultId}`); + } + + // Get total vault TVL again for consistency + const totalResult = await this.depositRepo + .createQueryBuilder('deposit') + .select('COALESCE(SUM(deposit.amount), 0)', 'total') + .where('deposit.vaultId = :vaultId', { vaultId }) + .andWhere('deposit.status = :status', { status: 'CONFIRMED' }) + .getRawOne<{ total: string }>(); + + const totalVaultTvl = parseFloat(totalResult?.total ?? '0'); + + return { + vaultId, + totalVaultTvl, + depositorConcentrations: concentrations.map(c => ({ + userId: c.userId, + concentration: c.concentration, + percentage: `${(c.concentration * 100).toFixed(2)}%`, + })), + maxConcentration: Math.max(...concentrations.map(c => c.concentration), 0), + threshold: vault.depositorConcentrationThreshold, + }; + } +} \ No newline at end of file diff --git a/harvest-finance/backend/src/common/filters/http-exception.filter.ts b/harvest-finance/backend/src/common/filters/http-exception.filter.ts index 276bebc6b..9607f8c6f 100644 --- a/harvest-finance/backend/src/common/filters/http-exception.filter.ts +++ b/harvest-finance/backend/src/common/filters/http-exception.filter.ts @@ -72,6 +72,11 @@ export class HttpExceptionFilter implements ExceptionFilter { errorCode = this.getErrorCodeFromStatus(status); } + // Determine error code: prefer existing errorCode on exception, fallback to status code + const errorCode = + (exception as any).errorCode || + (exception instanceof HttpException ? status.toString() : '500'); + const errorResponse = { statusCode: status, message: message, @@ -112,3 +117,5 @@ export class HttpExceptionFilter implements ExceptionFilter { return statusMap[status] || 'UNKNOWN_ERROR'; } } + + diff --git a/harvest-finance/backend/src/database/entities/vault.entity.ts b/harvest-finance/backend/src/database/entities/vault.entity.ts index 94d2722ac..802c1d596 100644 --- a/harvest-finance/backend/src/database/entities/vault.entity.ts +++ b/harvest-finance/backend/src/database/entities/vault.entity.ts @@ -115,6 +115,9 @@ export class Vault { @Column({ type: 'decimal', precision: 18, scale: 8, default: 0 }) interestRate: number; + @Column({ type: 'decimal', precision: 5, scale: 4, default: 0.5 }) + depositorConcentrationThreshold: number; + @Column({ name: 'compounding_frequency', type: 'varchar', diff --git a/harvest-finance/backend/src/database/entities/withdrawal.entity.ts b/harvest-finance/backend/src/database/entities/withdrawal.entity.ts index ee336a684..db6772532 100644 --- a/harvest-finance/backend/src/database/entities/withdrawal.entity.ts +++ b/harvest-finance/backend/src/database/entities/withdrawal.entity.ts @@ -16,6 +16,7 @@ import { Vault } from './vault.entity'; */ export enum WithdrawalStatus { PENDING = 'PENDING', + QUEUED = 'QUEUED', CONFIRMED = 'CONFIRMED', FAILED = 'FAILED', } diff --git a/harvest-finance/backend/src/main.ts b/harvest-finance/backend/src/main.ts index db3ef84a5..641819b2d 100644 --- a/harvest-finance/backend/src/main.ts +++ b/harvest-finance/backend/src/main.ts @@ -7,6 +7,7 @@ import { ConfigService } from '@nestjs/config'; import { NestFactory, HttpAdapterHost } from '@nestjs/core'; import { IoAdapter } from '@nestjs/platform-socket.io'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { HttpAdapterHost } from '@nestjs/core'; import { AppModule } from './app.module'; import { HttpExceptionFilter } from './common/filters/http-exception.filter'; import { ThrottlerExceptionFilter } from './common/filters/throttler-exception.filter'; diff --git a/harvest-finance/backend/src/vaults/vaults.controller.ts b/harvest-finance/backend/src/vaults/vaults.controller.ts index 37975610d..945c5c37e 100644 --- a/harvest-finance/backend/src/vaults/vaults.controller.ts +++ b/harvest-finance/backend/src/vaults/vaults.controller.ts @@ -6,6 +6,7 @@ import { Param, Body, Query, + 0, UseGuards, Request, HttpCode, @@ -40,6 +41,8 @@ import { import { PaginationQueryDto } from './dto/pagination-query.dto'; import { DepositEventResponseDto } from './dto/deposit-event-response.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RiskService } from '../analytics/risk.service'; +import { WithdrawalQueueService } from './withdrawal-queue.service'; @ApiTags('Vaults') @Controller({ diff --git a/harvest-finance/backend/src/vaults/vaults.service.ts b/harvest-finance/backend/src/vaults/vaults.service.ts index 22cbfdb88..4a71fac3f 100644 --- a/harvest-finance/backend/src/vaults/vaults.service.ts +++ b/harvest-finance/backend/src/vaults/vaults.service.ts @@ -216,6 +216,16 @@ export class VaultsService { return { deposit: savedDeposit, vault: updatedVault }; }); + // Process withdrawal queue after successful deposit + try { + await this.withdrawalQueueService.processWithdrawalQueue(vaultId); + } catch (error) { + this.logger.error( + `Error processing withdrawal queue for vault ${vaultId} after deposit:`, + error, + ); + } + if (amount >= LARGE_DEPOSIT_THRESHOLD) { await this.notificationsService.create( NotificationHelper.largeDepositAlert({ @@ -933,33 +943,57 @@ export class VaultsService { throw new BadRequestException('Insufficient balance for withdrawal'); } - const withdrawal = this.withdrawalRepository.create({ - userId, - vaultId, - amount, - status: WithdrawalStatus.PENDING, - }); + // Check if vault has sufficient liquidity for immediate withdrawal + if (Number(vault.totalDeposits) >= amount) { + // Process withdrawal immediately + const withdrawal = this.withdrawalRepository.create({ + userId, + vaultId, + amount, + status: WithdrawalStatus.PENDING, + }); - const result = await this.dataSource.transaction(async (manager) => { - const savedWithdrawal = await manager.save(withdrawal); + const result = await this.dataSource.transaction(async (manager) => { + const savedWithdrawal = await manager.save(withdrawal); - await manager.decrement(Vault, { id: vaultId }, 'totalDeposits', amount); + await manager.decrement(Vault, { id: vaultId }, 'totalDeposits', amount); - const updatedVault = await manager.findOne(Vault, { - where: { id: vaultId }, + const updatedVault = await manager.findOne(Vault, { + where: { id: vaultId }, + }); + + if (updatedVault && updatedVault.status === VaultStatus.FULL_CAPACITY) { + await manager.update( + Vault, + { id: vaultId }, + { status: VaultStatus.ACTIVE }, + ); + updatedVault.status = VaultStatus.ACTIVE; + } + + return { withdrawal: savedWithdrawal, vault: updatedVault }; }); - if (updatedVault && updatedVault.status === VaultStatus.FULL_CAPACITY) { - await manager.update( - Vault, - { id: vaultId }, - { status: VaultStatus.ACTIVE }, - ); - updatedVault.status = VaultStatus.ACTIVE; + await this.withdrawalRepository.update(result.withdrawal.id, { + status: WithdrawalStatus.CONFIRMED, + confirmedAt: new Date(), + transactionHash: `mock_withdraw_tx_${Date.now()}`, + }); + + const confirmedWithdrawal = await this.withdrawalRepository.findOne({ + where: { id: result.withdrawal.id }, + }); + + if (!confirmedWithdrawal) { + throw new NotFoundException('Withdrawal not found after confirmation'); } - return { withdrawal: savedWithdrawal, vault: updatedVault }; - }); + await this.notificationsService.create({ + userId, + title: 'Withdrawal Confirmed', + message: `Your withdrawal of ${amount} from vault ${vault.vaultName} has been confirmed.`, + type: NotificationType.WITHDRAWAL, // Fixed: should be WITHDRAWAL, not DEPOSIT + }); return { withdrawal: result.withdrawal, diff --git a/harvest-finance/backend/src/vaults/withdrawal-queue.service.ts b/harvest-finance/backend/src/vaults/withdrawal-queue.service.ts new file mode 100644 index 000000000..eb4474e28 --- /dev/null +++ b/harvest-finance/backend/src/vaults/withdrawal-queue.service.ts @@ -0,0 +1,134 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, In, LessThanOrEqual } from 'typeorm'; +import { Withdrawal, WithdrawalStatus } from '../database/entities/withdrawal.entity'; +import { Vault } from '../database/entities/vault.entity'; +import { NotificationsService } from '../notifications/notifications.service'; +import { NotificationType } from '../database/entities/notification.entity'; + +@Injectable() +export class WithdrawalQueueService { + private readonly logger = new Logger(WithdrawalQueueService.name); + + constructor( + @InjectRepository(Withdrawal) + private withdrawalRepo: Repository, + @InjectRepository(Vault) + private vaultRepo: Repository, + private readonly notificationService: NotificationsService, + ) {} + + /** + * Add a withdrawal to the queue (set status to QUEUED) when there is insufficient liquidity. + * @param withdrawalId The ID of the withdrawal to queue + */ + async enqueueWithdrawal(withdrawalId: string): Promise { + await this.withdrawalRepo.update( + { id: withdrawalId }, + { status: WithdrawalStatus.QUEUED }, + ); + this.logger.log(`Withdrawal ${withdrawalId} queued due to insufficient liquidity`); + } + + /** + * Process the withdrawal queue for a given vault in FIFO order. + * Should be called after a deposit increases liquidity. + * @param vaultId The ID of the vault to process the queue for + */ + async processWithdrawalQueue(vaultId: string): Promise { + this.logger.debug(`Processing withdrawal queue for vault ${vaultId}`); + + // Get the vault to check current liquidity and for notifications + const vault = await this.vaultRepo.findOne({ where: { id: vaultId } }); + if (!vault) { + this.logger.error(`Vault ${vaultId} not found`); + return; + } + + // Get all queued withdrawals for this vault, ordered by creation time (FIFO) + const queuedWithdrawals = await this.withdrawalRepo.find({ + where: { + vaultId: vaultId, + status: WithdrawalStatus.QUEUED, + }, + order: { + createdAt: 'ASC', + }, + }); + + for (const withdrawal of queuedWithdrawals) { + // Check if vault has sufficient liquidity for this withdrawal + if (Number(vault.totalDeposits) >= withdrawal.amount) { + // Process the withdrawal: deduct from vault and mark as confirmed + vault.totalDeposits = Number(vault.totalDeposits) - withdrawal.amount; + await this.vaultRepo.save(vault); + + await this.withdrawalRepo.update( + { id: withdrawal.id }, + { + status: WithdrawalStatus.CONFIRMED, + confirmedAt: new Date(), + }, + ); + + // Send notification to user + await this.notificationService.create({ + userId: withdrawal.userId, + title: 'Withdrawal Confirmed', + message: `Your withdrawal of ${withdrawal.amount} from vault ${vault.vaultName} has been confirmed.`, + type: NotificationType.WITHDRAWAL, + }); + + this.logger.log( + `Withdrawal ${withdrawal.id} for amount ${withdrawal.amount} processed and confirmed`, + ); + } else { + // Not enough liquidity, stop processing since the queue is FIFO + this.logger.debug( + `Insufficient liquidity to process withdrawal ${withdrawal.id}. Stopping queue processing.`, + ); + break; + } + } + } + + /** + * Get the position of a withdrawal in the queue for its vault. + * Returns null if the withdrawal is not queued. + * @param withdrawalId The ID of the withdrawal + * @returns The 1-based position in the queue, or null if not queued + */ + async getQueuePosition(withdrawalId: string): Promise { + const withdrawal = await this.withdrawalRepo.findOne({ + where: { id: withdrawalId }, + }); + + if (!withdrawal || withdrawal.status !== WithdrawalStatus.QUEUED) { + return null; + } + + // Count how many queued withdrawals for the same vault were created before this one + const position = await this.withdrawalRepo.count({ + where: { + vaultId: withdrawal.vaultId, + status: WithdrawalStatus.QUEUED, + createdAt: LessThanOrEqual: withdrawal.createdAt, + }, + }); + + return position; + } + + /** + * Get the estimated wait time for a withdrawal in the queue. + * This is a simplified estimate based on average deposit rate. + * For now, we return null as it requires more complex forecasting. + * @param withdrawalId The ID of the withdrawal + * @returns Estimated wait time in seconds, or null if not queued or cannot estimate + */ + async getEstimatedWaitTime(withdrawalId: string): Promise { + // In a real implementation, we would calculate based on historical deposit rates. + // For simplicity, we return null indicating that estimation is not implemented. + return null; + } +} \ No newline at end of file