From 0f45543fd33b6e28c37c14c736c46fed48e4ace3 Mon Sep 17 00:00:00 2001 From: Sadeequ <70214653+Sadeequ@users.noreply.github.com> Date: Sat, 27 Jun 2026 08:06:10 +0000 Subject: [PATCH] fix withdrawal queue and filtered responses --- .../common/filters/http-exception.filter.ts | 63 ++++++++ .../backend/src/analytics/analytics.module.ts | 4 +- .../backend/src/analytics/risk.service.ts | 134 +++++++++++++++ .../common/filters/http-exception.filter.ts | 62 ++++--- .../database/entities/notification.entity.ts | 1 + .../src/database/entities/vault.entity.ts | 3 + .../database/entities/withdrawal.entity.ts | 1 + harvest-finance/backend/src/main.ts | 5 +- .../backend/src/vaults/vaults.controller.ts | 106 ++++++++++-- .../backend/src/vaults/vaults.module.ts | 3 +- .../backend/src/vaults/vaults.service.ts | 153 +++++++++++------- .../src/vaults/withdrawal-queue.service.ts | 134 +++++++++++++++ 12 files changed, 577 insertions(+), 92 deletions(-) create mode 100644 backend/src/common/filters/http-exception.filter.ts create mode 100644 harvest-finance/backend/src/analytics/risk.service.ts create mode 100644 harvest-finance/backend/src/vaults/withdrawal-queue.service.ts 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 fd1f4379d..0ebf6e0be 100644 --- a/harvest-finance/backend/src/common/filters/http-exception.filter.ts +++ b/harvest-finance/backend/src/common/filters/http-exception.filter.ts @@ -5,7 +5,7 @@ import { HttpException, HttpStatus, } from '@nestjs/common'; -import { Request, Response } from 'express'; +import { HttpAdapterHost } from '@nestjs/core'; import { CustomLoggerService } from '../../logger/custom-logger.service'; /** @@ -13,53 +13,75 @@ import { CustomLoggerService } from '../../logger/custom-logger.service'; * Formats all error responses into a consistent JSON structure: * { * "statusCode": number, + * "message": string | string[], + * "errorCode": string | number, * "timestamp": "ISO 8601 string", - * "path": "request url path", - * "method": "HTTP method", - * "message": "Error description or array of error details" + * "path": string, + * "requestId": string * } */ @Catch() export class HttpExceptionFilter implements ExceptionFilter { - constructor(private readonly logger: CustomLoggerService) {} + constructor( + private readonly httpAdapterHost: HttpAdapterHost, + private readonly logger: CustomLoggerService, + ) {} catch(exception: unknown, host: ArgumentsHost) { + const { httpAdapter } = this.httpAdapterHost; const ctx = host.switchToHttp(); - const response = ctx.getResponse(); - const request = ctx.getRequest(); + const request = ctx.getRequest(); // Native request (Express/Fastify) + const response = ctx.getResponse(); // Native response const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR; + // Extract or generate a unique request ID for tracing + const requestId = + request.headers['x-request-id'] || + request.id || + `req-${Date.now()}-${Math.floor(Math.random() * 10000)}`; + const message = exception instanceof HttpException ? exception.getResponse() : 'Internal server error'; + // 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, - timestamp: new Date().toISOString(), - path: request.url, - method: request.method, message: typeof message === 'string' ? message : (message as any).message || message, + errorCode: errorCode, + timestamp: new Date().toISOString(), + path: httpAdapter.getRequestUrl(request), + requestId: requestId, }; - // Include detailed error information in logs, but keep response clean - let trace: string | undefined; - if (exception instanceof Error) { - trace = exception.stack; + // Log error with requestId for correlation; include stack trace in development + const logMessage = `[Request ID: ${requestId}] ${request.method} ${httpAdapter.getRequestUrl( + request, + )} - Error: ${JSON.stringify(errorResponse.message)}`; + if ( + process.env.NODE_ENV !== 'production' && + exception instanceof Error && + exception.stack + ) { + this.logger.error(logMessage, exception.stack); + } else { + this.logger.error(logMessage); } - this.logger.error( - `${request.method} ${request.url} - Error: ${JSON.stringify(errorResponse.message)}`, - trace, - 'ExceptionFilter', - ); - response.status(status).json(errorResponse); + httpAdapter.reply(response, errorResponse, status); } } + + diff --git a/harvest-finance/backend/src/database/entities/notification.entity.ts b/harvest-finance/backend/src/database/entities/notification.entity.ts index 1f8dc85e2..f7e453ecc 100644 --- a/harvest-finance/backend/src/database/entities/notification.entity.ts +++ b/harvest-finance/backend/src/database/entities/notification.entity.ts @@ -22,6 +22,7 @@ export enum NotificationType { LARGE_TRANSACTION = 'LARGE_TRANSACTION', ERROR = 'ERROR', INSURANCE = 'INSURANCE', + DEPOSITOR_CONCENTRATION = 'DEPOSITOR_CONCENTRATION', } /** diff --git a/harvest-finance/backend/src/database/entities/vault.entity.ts b/harvest-finance/backend/src/database/entities/vault.entity.ts index 59587d16e..04e578203 100644 --- a/harvest-finance/backend/src/database/entities/vault.entity.ts +++ b/harvest-finance/backend/src/database/entities/vault.entity.ts @@ -108,6 +108,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({ type: 'timestamp with time zone', name: 'maturity_date', 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 356643608..287eddeb6 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 } 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'; @@ -21,9 +22,11 @@ async function bootstrap() { const customLogger = app.get(CustomLoggerService); app.useLogger(customLogger); + const httpAdapterHost = app.get(HttpAdapterHost); + // Register the global filters, including the new Soroban filter app.useGlobalFilters( - new HttpExceptionFilter(customLogger), + new HttpExceptionFilter(httpAdapterHost, customLogger), new ThrottlerExceptionFilter(), new SorobanExceptionFilter(), ); diff --git a/harvest-finance/backend/src/vaults/vaults.controller.ts b/harvest-finance/backend/src/vaults/vaults.controller.ts index ef8f7a25e..ec27f9982 100644 --- a/harvest-finance/backend/src/vaults/vaults.controller.ts +++ b/harvest-finance/backend/src/vaults/vaults.controller.ts @@ -5,6 +5,7 @@ import { Param, Body, Query, + 0, UseGuards, Request, HttpCode, @@ -26,6 +27,8 @@ import { VaultResponseDto, } from './dto/vault-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({ @@ -35,7 +38,11 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; @UseGuards(JwtAuthGuard) @ApiBearerAuth() export class VaultsController { - constructor(private readonly vaultsService: VaultsService) {} + constructor( + private readonly vaultsService: VaultsService, + private readonly riskService: RiskService, + private readonly withdrawalQueueService: WithdrawalQueueService, + ) {} @Post(':vaultId/deposit') @Throttle({ default: { limit: 20, ttl: 60000 } }) @@ -167,17 +174,88 @@ export class VaultsController { return this.vaultsService.getVaultsMetadata(); } - @Get('apy-history') - @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Get APY history for vaults' }) - @ApiResponse({ - status: 200, - description: 'APY history retrieved successfully', - }) - async getApyHistory( - @Query('vaultId') vaultId?: string, - @Query('timeRange') timeRange: string = '30d', - ): Promise { - return this.vaultsService.getApyHistory(vaultId, timeRange); - } +@Get('apy-history') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Get APY history for vaults' }) + @ApiResponse({ + status: 200, + description: 'APY history retrieved successfully', + }) + async getApyHistory( + @Query('vaultId') vaultId?: string, + @Query('timeRange') timeRange: string = '30d', + ): Promise { + return this.vaultsService.getApyHistory(vaultId, timeRange); + } + + @Get(':vaultId/risk-metrics') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Get depositor concentration risk metrics for a vault' }) + @ApiParam({ + name: 'vaultId', + description: 'Vault ID (UUID)', + example: '123e4567-e89b-12d3-a456-426614174000', + }) + @ApiResponse({ + status: 200, + description: 'Risk metrics retrieved successfully', + }) + @ApiResponse({ + status: 404, + description: 'Vault not found', + }) + async getVaultRiskMetrics( + @Param('vaultId') vaultId: string, + ) { + return this.riskService.getVaultDepositorConcentration(vaultId); + } + + @Get('withdrawals/:withdrawalId/queue-position') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Get the position of a withdrawal in the queue' }) + @ApiParam({ + name: 'withdrawalId', + description: 'Withdrawal ID (UUID)', + example: '123e4567-e89b-12d3-a456-426614174000', + }) + @ApiResponse({ + status: 200, + description: 'Queue position retrieved successfully', + }) + @ApiResponse({ + status: 404, + description: 'Withdrawal not found or not queued', + }) + async getWithdrawalQueuePosition( + @Param('withdrawalId') withdrawalId: string, + ): Promise<{ position: number | null }> { + const position = await this.withdrawalQueueService.getQueuePosition( + withdrawalId, + ); + return { position }; + } + + @Get('withdrawals/:withdrawalId/estimated-wait-time') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Get estimated wait time for a queued withdrawal' }) + @ApiParam({ + name: 'withdrawalId', + description: 'Withdrawal ID (UUID)', + example: '123e4567-e89b-12d3-a456-426614174000', + }) + @ApiResponse({ + status: 200, + description: 'Estimated wait time retrieved successfully', + }) + @ApiResponse({ + status: 404, + description: 'Withdrawal not found or not queued', + }) + async getWithdrawalEstimatedWaitTime( + @Param('withdrawalId') withdrawalId: string, + ): Promise<{ estimatedWaitTime: number | null }> { + const waitTime = + await this.withdrawalQueueService.getEstimatedWaitTime(withdrawalId); + return { estimatedWaitTime: waitTime }; + } } diff --git a/harvest-finance/backend/src/vaults/vaults.module.ts b/harvest-finance/backend/src/vaults/vaults.module.ts index ad7a631e8..deb6e262f 100644 --- a/harvest-finance/backend/src/vaults/vaults.module.ts +++ b/harvest-finance/backend/src/vaults/vaults.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { VaultsController } from './vaults.controller'; import { VaultsService } from './vaults.service'; +import { WithdrawalQueueService } from './withdrawal-queue.service'; import { Vault } from '../database/entities/vault.entity'; import { Deposit } from '../database/entities/deposit.entity'; import { Withdrawal } from '../database/entities/withdrawal.entity'; @@ -19,7 +20,7 @@ import { CommonModule } from '../common/common.module'; CommonModule, ], controllers: [VaultsController], - providers: [VaultsService], + providers: [VaultsService, WithdrawalQueueService], exports: [VaultsService], }) export class VaultsModule {} diff --git a/harvest-finance/backend/src/vaults/vaults.service.ts b/harvest-finance/backend/src/vaults/vaults.service.ts index ee21c4760..e37dac1bb 100644 --- a/harvest-finance/backend/src/vaults/vaults.service.ts +++ b/harvest-finance/backend/src/vaults/vaults.service.ts @@ -23,6 +23,7 @@ import { CustomLoggerService } from '../logger/custom-logger.service'; import { VaultGateway } from '../realtime/vault.gateway'; import { ContractCacheService } from '../common/cache/contract-cache.service'; import { InputSanitizerService } from '../common/sanitization/input-sanitizer.service'; +import { WithdrawalQueueService } from './withdrawal-queue.service'; const MAX_SAFE_DEPOSIT = 1e30; const LARGE_DEPOSIT_THRESHOLD = 10000; @@ -42,6 +43,7 @@ export class VaultsService { private vaultGateway: VaultGateway, private contractCache: ContractCacheService, private sanitizer: InputSanitizerService, + private withdrawalQueueService: WithdrawalQueueService, ) {} async getVaultById(vaultId: string): Promise { @@ -149,6 +151,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({ title: 'Large Deposit Alert', @@ -308,75 +320,106 @@ 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 + }); - await this.withdrawalRepository.update(result.withdrawal.id, { - status: WithdrawalStatus.CONFIRMED, - confirmedAt: new Date(), - transactionHash: `mock_withdraw_tx_${Date.now()}`, - }); + this.logger.log( + `Withdrawal of ${amount} confirmed from vault ${vaultId} by user ${userId}`, + 'VaultsService', + ); - const confirmedWithdrawal = await this.withdrawalRepository.findOne({ - where: { id: result.withdrawal.id }, - }); + this.vaultGateway.emitWithdrawal({ + vaultId, + vaultName: vault.vaultName, + asset: vault.type, + amount, + userId, + newBalance: result.vault ? Number(result.vault.totalDeposits) : 0, + }); - if (!confirmedWithdrawal) { - throw new NotFoundException('Withdrawal not found after confirmation'); - } + return { + withdrawal: confirmedWithdrawal, + vault: result.vault + ? this.mapVaultToResponse(result.vault) + : this.mapVaultToResponse(vault), + }; + } else { + // Insufficient liquidity: create withdrawal and queue it + const withdrawal = this.withdrawalRepository.create({ + userId, + vaultId, + amount, + status: WithdrawalStatus.PENDING, + }); - await this.notificationsService.create({ - userId, - title: 'Withdrawal Confirmed', - message: `Your withdrawal of ${amount} from vault ${vault.vaultName} has been confirmed.`, - type: NotificationType.DEPOSIT, - }); + const savedWithdrawal = await this.withdrawalRepository.save(withdrawal); - this.logger.log( - `Withdrawal of ${amount} confirmed from vault ${vaultId} by user ${userId}`, - 'VaultsService', - ); + // Immediately mark as queued + await this.withdrawalQueueService.enqueueWithdrawal(savedWithdrawal.id); - this.vaultGateway.emitWithdrawal({ - vaultId, - vaultName: vault.vaultName, - asset: vault.type, - amount, - userId, - newBalance: result.vault ? Number(result.vault.totalDeposits) : 0, - }); + // Fetch the updated withdrawal to return + const queuedWithdrawal = await this.withdrawalRepository.findOne({ + where: { id: savedWithdrawal.id }, + }); - return { - withdrawal: confirmedWithdrawal, - vault: result.vault - ? this.mapVaultToResponse(result.vault) - : this.mapVaultToResponse(vault), - }; + if (!queuedWithdrawal) { + throw new NotFoundException('Withdrawal not found after queuing'); + } + + return { + withdrawal: queuedWithdrawal, + vault: this.mapVaultToResponse(vault), + }; + } } private mapDepositToResponse(deposit: Deposit): DepositResponseDto { 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