Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions backend/src/common/filters/http-exception.filter.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
4 changes: 3 additions & 1 deletion harvest-finance/backend/src/analytics/analytics.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
134 changes: 134 additions & 0 deletions harvest-finance/backend/src/analytics/risk.service.ts
Original file line number Diff line number Diff line change
@@ -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<Vault>,
@InjectRepository(Deposit) private depositRepo: Repository<Deposit>,
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<Array<{ userId: string; concentration: number }>> {
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,
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,15 @@
message = exception.message;
}

if (errorCode === 'INTERNAL_SERVER_ERROR' && status !== HttpStatus.INTERNAL_SERVER_ERROR) {

Check failure on line 71 in harvest-finance/backend/src/common/filters/http-exception.filter.ts

View workflow job for this annotation

GitHub Actions / Backend — Lint

The two values in this comparison do not have a shared enum type
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,
Expand Down Expand Up @@ -112,3 +117,5 @@
return statusMap[status] || 'UNKNOWN_ERROR';
}
}


3 changes: 3 additions & 0 deletions harvest-finance/backend/src/database/entities/vault.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { Vault } from './vault.entity';
*/
export enum WithdrawalStatus {
PENDING = 'PENDING',
QUEUED = 'QUEUED',
CONFIRMED = 'CONFIRMED',
FAILED = 'FAILED',
}
Expand Down
1 change: 1 addition & 0 deletions harvest-finance/backend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
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';
Expand Down Expand Up @@ -161,7 +162,7 @@
httpServer.close((err) => {
if (err) reject(err);
else resolve();
});

Check failure on line 165 in harvest-finance/backend/src/main.ts

View workflow job for this annotation

GitHub Actions / Backend — Lint

Expected the Promise rejection reason to be an Error
});

// Close NestJS app
Expand All @@ -181,2 +182,2 @@
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
}
Expand Down
3 changes: 3 additions & 0 deletions harvest-finance/backend/src/vaults/vaults.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
Param,
Body,
Query,
0,
UseGuards,
Request,
HttpCode,
Expand Down Expand Up @@ -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({
Expand Down
74 changes: 54 additions & 20 deletions harvest-finance/backend/src/vaults/vaults.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading