diff --git a/harvest-finance/backend/src/database/entities/index.ts b/harvest-finance/backend/src/database/entities/index.ts index 723d74a4..f24ce032 100644 --- a/harvest-finance/backend/src/database/entities/index.ts +++ b/harvest-finance/backend/src/database/entities/index.ts @@ -14,6 +14,7 @@ export { InsuranceSubscription, SubscriptionStatus, } from './insurance-subscription.entity'; +export { InsuranceClaim, InsuranceClaimStatus } from './insurance-claim.entity'; export { Notification, NotificationType } from './notification.entity'; export { Order, OrderStatus } from './order.entity'; export { Reward, RewardStatus } from './reward.entity'; diff --git a/harvest-finance/backend/src/database/entities/insurance-claim.entity.ts b/harvest-finance/backend/src/database/entities/insurance-claim.entity.ts index 6a552958..2af7d88c 100644 --- a/harvest-finance/backend/src/database/entities/insurance-claim.entity.ts +++ b/harvest-finance/backend/src/database/entities/insurance-claim.entity.ts @@ -1,21 +1,27 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn, Index } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; import { Vault } from './vault.entity'; import { User } from './user.entity'; export enum InsuranceClaimStatus { PENDING = 'PENDING', COMPLETED = 'COMPLETED', + FAILED = 'FAILED', REJECTED = 'REJECTED', } -/** - * Records a payout claim from the insurance fund to a depositor. - * The claim is created during an incident workflow and later updated - * when the payout is processed on‑chain. - */ @Entity('insurance_claims') -@Index('idx_insurance_claim_vault', ['vaultId']) -@Index('idx_insurance_claim_user', ['depositorId']) +@Index('idx_insurance_claims_vault', ['vaultId']) +@Index('idx_insurance_claims_depositor', ['depositorId']) +@Index('idx_insurance_claims_status', ['status']) export class InsuranceClaim { @PrimaryGeneratedColumn('uuid') id: string; @@ -23,29 +29,47 @@ export class InsuranceClaim { @Column({ name: 'vault_id' }) vaultId: string; - @ManyToOne(() => Vault, (vault) => vault.id, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'vault_id' }) - vault: Vault; - @Column({ name: 'depositor_id' }) depositorId: string; - @ManyToOne(() => User, (user) => user.id, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'depositor_id' }) - depositor: User; - - @Column({ type: 'decimal', precision: 18, scale: 8 }) + @Column({ + type: 'decimal', + precision: 18, + scale: 8, + }) lossAmount: number; - @Column({ type: 'decimal', precision: 18, scale: 8 }) + @Column({ + type: 'decimal', + precision: 18, + scale: 8, + }) payoutAmount: number; - @Column({ type: 'enum', enum: InsuranceClaimStatus, default: InsuranceClaimStatus.PENDING }) + @Column({ + type: 'enum', + enum: InsuranceClaimStatus, + default: InsuranceClaimStatus.PENDING, + }) status: InsuranceClaimStatus; + @Column({ type: 'text', nullable: true, name: 'transaction_hash' }) + transactionHash: string | null; + + @Column({ type: 'text', nullable: true }) + reason: string | null; + @CreateDateColumn({ name: 'created_at' }) createdAt: Date; @UpdateDateColumn({ name: 'updated_at' }) updatedAt: Date; -} + + @ManyToOne(() => Vault, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'vault_id' }) + vault: Vault; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'depositor_id' }) + depositor: User; +} \ No newline at end of file diff --git a/harvest-finance/backend/src/database/migrations/1700000000013-CreateInsuranceClaims.spec.ts b/harvest-finance/backend/src/database/migrations/1700000000013-CreateInsuranceClaims.spec.ts new file mode 100644 index 00000000..96dd5b3b --- /dev/null +++ b/harvest-finance/backend/src/database/migrations/1700000000013-CreateInsuranceClaims.spec.ts @@ -0,0 +1,80 @@ +import { MigrationInterface, QueryRunner, Table } from 'typeorm'; +import { CreateInsuranceClaims1700000000013 } from './1700000000013-CreateInsuranceClaims'; + +describe('CreateInsuranceClaims1700000000013', () => { + let migration: CreateInsuranceClaims1700000000013; + let queryRunner: QueryRunner; + + beforeEach(() => { + migration = new CreateInsuranceClaims1700000000013(); + queryRunner = { + createTable: jest.fn(), + dropTable: jest.fn(), + dropIndex: jest.fn(), + } as any; + }); + + it('should create insurance claims table with correct structure', async () => { + await migration.up(queryRunner); + + expect(queryRunner.createTable).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'insurance_claims', + columns: expect.arrayContaining([ + expect.objectContaining({ name: 'id', type: 'uuid', isPrimary: true }), + expect.objectContaining({ name: 'vault_id', type: 'uuid' }), + expect.objectContaining({ name: 'depositor_id', type: 'uuid' }), + expect.objectContaining({ name: 'loss_amount', type: 'decimal' }), + expect.objectContaining({ name: 'payout_amount', type: 'decimal' }), + expect.objectContaining({ name: 'status', type: 'enum' }), + expect.objectContaining({ name: 'transaction_hash', type: 'text' }), + expect.objectContaining({ name: 'reason', type: 'text' }), + ]), + }), + ); + }); + + it('should have correct enum values for status', async () => { + await migration.up(queryRunner); + const call = (queryRunner.createTable as jest.Mock).mock.calls[0][0]; + const statusColumn = call.columns.find((c: any) => c.name === 'status'); + + expect(statusColumn.enum).toEqual(['PENDING', 'COMPLETED', 'FAILED', 'REJECTED']); + expect(statusColumn.default).toBe("'PENDING'"); + }); + + it('should have foreign key to vaults table', async () => { + await migration.up(queryRunner); + const call = (queryRunner.createTable as jest.Mock).mock.calls[0][0]; + + expect(call.foreignKeys).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'fk_insurance_claims_vault', + referencedTableName: 'vaults', + }), + ]), + ); + }); + + it('should have foreign key to users table', async () => { + await migration.up(queryRunner); + const call = (queryRunner.createTable as jest.Mock).mock.calls[0][0]; + + expect(call.foreignKeys).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'fk_insurance_claims_depositor', + referencedTableName: 'users', + }), + ]), + ); + }); + + it('should drop table on migration down', async () => { + await migration.down(queryRunner); + + expect(queryRunner.dropIndex).toHaveBeenCalled(); + expect(queryRunner.dropTable).toHaveBeenCalledWith('insurance_claims'); + }); +}); \ No newline at end of file diff --git a/harvest-finance/backend/src/database/migrations/1700000000013-CreateInsuranceClaims.ts b/harvest-finance/backend/src/database/migrations/1700000000013-CreateInsuranceClaims.ts new file mode 100644 index 00000000..b618adb3 --- /dev/null +++ b/harvest-finance/backend/src/database/migrations/1700000000013-CreateInsuranceClaims.ts @@ -0,0 +1,95 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; + +export class CreateInsuranceClaims1700000000013 implements MigrationInterface { + name = 'CreateInsuranceClaims1700000000013'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: 'insurance_claims', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'uuid_generate_v4()', + }, + { + name: 'vault_id', + type: 'uuid', + isNullable: false, + }, + { + name: 'depositor_id', + type: 'uuid', + isNullable: false, + }, + { + name: 'loss_amount', + type: 'decimal', + precision: 18, + scale: 8, + isNullable: false, + }, + { + name: 'payout_amount', + type: 'decimal', + precision: 18, + scale: 8, + isNullable: false, + }, + { + name: 'status', + type: 'enum', + enum: ['PENDING', 'COMPLETED', 'FAILED', 'REJECTED'], + default: "'PENDING'", + }, + { + name: 'transaction_hash', + type: 'text', + isNullable: true, + }, + { + name: 'reason', + type: 'text', + isNullable: true, + }, + { + name: 'created_at', + type: 'timestamp with time zone', + default: 'CURRENT_TIMESTAMP', + }, + { + name: 'updated_at', + type: 'timestamp with time zone', + default: 'CURRENT_TIMESTAMP', + }, + ], + foreignKeys: [ + { + name: 'fk_insurance_claims_vault', + columnNames: ['vault_id'], + referencedTableName: 'vaults', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }, + { + name: 'fk_insurance_claims_depositor', + columnNames: ['depositor_id'], + referencedTableName: 'users', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }, + ], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropIndex('insurance_claims', 'idx_insurance_claims_vault'); + await queryRunner.dropIndex('insurance_claims', 'idx_insurance_claims_depositor'); + await queryRunner.dropIndex('insurance_claims', 'idx_insurance_claims_status'); + await queryRunner.dropTable('insurance_claims'); + } +} \ No newline at end of file diff --git a/harvest-finance/backend/src/vaults/insurance-fund.controller.spec.ts b/harvest-finance/backend/src/vaults/insurance-fund.controller.spec.ts new file mode 100644 index 00000000..3ce6837f --- /dev/null +++ b/harvest-finance/backend/src/vaults/insurance-fund.controller.spec.ts @@ -0,0 +1,242 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; +import { BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common'; +import { InsuranceFundController } from './insurance-fund.controller'; +import { InsuranceFundService, InsuranceFundStats } from './insurance-fund.service'; +import { Vault, VaultType, VaultStatus } from '../database/entities/vault.entity'; +import { InsuranceClaim, InsuranceClaimStatus } from '../database/entities/insurance-claim.entity'; +import { User, UserRole } from '../database/entities/user.entity'; + +const USER_ID = 'user-11111111-1111-1111-1111-111111111111'; +const ADMIN_ID = 'admin-22222222-2222-2222-2222-222222222222'; +const INSURANCE_VAULT_ID = 'insurance-v-44444444-4444-4444-4444-444444444444'; +const CLAIM_ID = 'claim-55555555-5555-5555-5555-555555555555'; + +describe('InsuranceFundController', () => { + let controller: InsuranceFundController; + let service: InsuranceFundService; + + const mockInsuranceFundService = { + depositToFund: jest.fn(), + getCoverageRatio: jest.fn(), + getStats: jest.fn(), + getInsuranceFundBalance: jest.fn(), + getEscrowDetails: jest.fn(), + getAllClaims: jest.fn(), + getUserClaims: jest.fn(), + getClaimsByStatus: jest.fn(), + getClaimById: jest.fn(), + declareIncident: jest.fn(), + processIncident: jest.fn(), + finalizeClaim: jest.fn(), + getAuditTrail: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [InsuranceFundController], + providers: [ + { provide: InsuranceFundService, useValue: mockInsuranceFundService }, + ], + }).compile(); + + controller = module.get(InsuranceFundController); + service = module.get(InsuranceFundService); + }); + + afterEach(() => jest.clearAllMocks()); + + describe('depositToFund', () => { + it('should call service with deposit parameters', async () => { + const vault = { id: INSURANCE_VAULT_ID, totalDeposits: 1000 } as Vault; + mockInsuranceFundService.depositToFund.mockResolvedValue(vault); + + const result = await controller.depositToFund({ userId: USER_ID, amount: 100 }); + + expect(mockInsuranceFundService.depositToFund).toHaveBeenCalledWith(USER_ID, 100); + expect(result).toBe(vault); + }); + + it('should throw BadRequestException for missing parameters', async () => { + await expect(controller.depositToFund({}) as any).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException for invalid amount type', async () => { + await expect(controller.depositToFund({ userId: USER_ID, amount: 'invalid' } as any)).rejects.toThrow( + BadRequestException, + ); + }); + }); + + describe('getCoverage', () => { + it('should return coverage ratio', async () => { + mockInsuranceFundService.getCoverageRatio.mockResolvedValue(0.25); + + const result = await controller.getCoverage(); + + expect(result).toEqual({ coverageRatio: 0.25 }); + expect(mockInsuranceFundService.getCoverageRatio).toHaveBeenCalled(); + }); + }); + + describe('getStats', () => { + it('should return insurance fund statistics', async () => { + const stats = { + fundBalance: 10000, + totalTVL: 50000, + coverageRatio: 0.2, + totalClaimsProcessed: 5, + totalPayoutsDistributed: 1500, + } as InsuranceFundStats; + mockInsuranceFundService.getStats.mockResolvedValue(stats); + + const result = await controller.getStats(); + + expect(result).toEqual(stats); + expect(mockInsuranceFundService.getStats).toHaveBeenCalled(); + }); + }); + + describe('getBalance', () => { + it('should return fund balance', async () => { + mockInsuranceFundService.getInsuranceFundBalance.mockResolvedValue(15000); + + const result = await controller.getBalance(); + + expect(result).toEqual({ fundBalance: 15000 }); + }); + }); + + describe('getEscrowDetails', () => { + it('should return escrow details', async () => { + const escrow = { + address: 'insurance-multisig-escrow', + signers: ['signer-1', 'signer-2', 'signer-3'], + threshold: 2, + createdAt: new Date(), + }; + mockInsuranceFundService.getEscrowDetails.mockResolvedValue(escrow); + + const result = await controller.getEscrowDetails(); + + expect(result.address).toBe('insurance-multisig-escrow'); + }); + }); + + describe('getAllClaims', () => { + it('should return all claims', async () => { + const claims = [ + { id: CLAIM_ID, status: InsuranceClaimStatus.COMPLETED }, + ] as InsuranceClaim[]; + mockInsuranceFundService.getAllClaims.mockResolvedValue(claims); + + const result = await controller.getAllClaims(); + + expect(result).toHaveLength(1); + }); + }); + + describe('getUserClaims', () => { + it('should return claims for specific user', async () => { + const claims = [{ id: CLAIM_ID }] as InsuranceClaim[]; + mockInsuranceFundService.getUserClaims.mockResolvedValue(claims); + + const result = await controller.getUserClaims(USER_ID); + + expect(mockInsuranceFundService.getUserClaims).toHaveBeenCalledWith(USER_ID); + expect(result).toHaveLength(1); + }); + }); + + describe('getClaimsByStatus', () => { + it('should return claims filtered by status', async () => { + const claims = [ + { id: CLAIM_ID, status: InsuranceClaimStatus.COMPLETED }, + ] as InsuranceClaim[]; + mockInsuranceFundService.getClaimsByStatus.mockResolvedValue(claims); + + const result = await controller.getClaimsByStatus('COMPLETED'); + + expect(mockInsuranceFundService.getClaimsByStatus).toHaveBeenCalledWith(InsuranceClaimStatus.COMPLETED); + expect(result).toHaveLength(1); + }); + }); + + describe('getClaim', () => { + it('should return a single claim by ID', async () => { + const claim = { id: CLAIM_ID } as InsuranceClaim; + mockInsuranceFundService.getClaimById.mockResolvedValue(claim); + + const result = await controller.getClaim(CLAIM_ID); + + expect(mockInsuranceFundService.getClaimById).toHaveBeenCalledWith(CLAIM_ID); + expect(result.id).toBe(CLAIM_ID); + }); + }); + + describe('declareIncident', () => { + it('should process incident declaration', async () => { + const claims = [{ id: CLAIM_ID }] as InsuranceClaim[]; + mockInsuranceFundService.declareIncident.mockResolvedValue(claims); + + const result = await controller.declareIncident( + { + vaultId: INSURANCE_VAULT_ID, + lossAmount: 5000, + description: 'Smart contract exploit', + adminId: ADMIN_ID, + adminRole: UserRole.ADMIN, + }, + ); + + expect(mockInsuranceFundService.declareIncident).toHaveBeenCalled(); + expect(result).toHaveLength(1); + }); + }); + + describe('processPayout', () => { + it('should process manual payout', async () => { + const claims = [{ id: CLAIM_ID }] as InsuranceClaim[]; + mockInsuranceFundService.processIncident.mockResolvedValue(claims); + + const result = await controller.processPayout( + { + losses: { [USER_ID]: 1000 }, + reason: 'Strategy failure', + adminId: ADMIN_ID, + adminRole: UserRole.ADMIN, + }, + ); + + expect(mockInsuranceFundService.processIncident).toHaveBeenCalled(); + expect(result).toHaveLength(1); + }); + }); + + describe('finalizeClaim', () => { + it('should finalize a claim', async () => { + const claim = { id: CLAIM_ID, status: InsuranceClaimStatus.COMPLETED } as InsuranceClaim; + mockInsuranceFundService.finalizeClaim.mockResolvedValue(claim); + + const result = await controller.finalizeClaim(CLAIM_ID, ADMIN_ID, UserRole.ADMIN); + + expect(mockInsuranceFundService.finalizeClaim).toHaveBeenCalledWith(CLAIM_ID, ADMIN_ID, UserRole.ADMIN); + expect(result.status).toBe(InsuranceClaimStatus.COMPLETED); + }); + }); + + describe('getAuditTrail', () => { + it('should return audit trail data', async () => { + const auditTrail = { + deposits: [], + claims: [], + }; + mockInsuranceFundService.getAuditTrail.mockResolvedValue(auditTrail); + + const result = await controller.getAuditTrail(); + + expect(result).toEqual(auditTrail); + }); + }); +}); \ No newline at end of file diff --git a/harvest-finance/backend/src/vaults/insurance-fund.controller.ts b/harvest-finance/backend/src/vaults/insurance-fund.controller.ts index a4a5dd30..86b7135a 100644 --- a/harvest-finance/backend/src/vaults/insurance-fund.controller.ts +++ b/harvest-finance/backend/src/vaults/insurance-fund.controller.ts @@ -1,29 +1,48 @@ -import { Controller, Post, Body, Get, Param, UseGuards, BadRequestException } from '@nestjs/common'; -import { InsuranceFundService } from './insurance-fund.service'; +import { + Controller, + Post, + Body, + Get, + Param, + UseGuards, + BadRequestException, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { InsuranceFundService, InsuranceFundStats } from './insurance-fund.service'; import { JwtAuthGuard as AuthGuard } from '../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../auth/guards/roles.guard'; import { Roles } from '../auth/decorators/roles.decorator'; import { UserRole } from '../database/entities/user.entity'; +import { InsuranceClaimStatus } from '../database/entities/insurance-claim.entity'; + +class DepositToFundDto { + userId: string; + amount: number; +} + +class DeclareIncidentDto { + vaultId: string; + lossAmount: number; + description: string; +} + +class ProcessPayoutDto { + losses: Record; + reason?: string; +} -/** - * Controller exposing insurance fund management endpoints. - * - * - POST /insurance-fund/deposit -> depositToFund (userId from body) - * - GET /insurance-fund/coverage -> getCoverageRatio - * - POST /insurance-fund/incident/:adminId -> processIncident (admin only) - */ -@UseGuards(AuthGuard) @Controller('insurance-fund') +@UseGuards(AuthGuard) export class InsuranceFundController { constructor(private readonly insuranceFundService: InsuranceFundService) {} @Post('deposit') - async depositToFund(@Body() body: { userId: string; amount: number }) { - const { userId, amount } = body; - if (!userId || typeof amount !== 'number') { - throw new BadRequestException('Invalid deposit payload'); + async depositToFund(@Body() body: DepositToFundDto) { + if (!body.userId || typeof body.amount !== 'number') { + throw new BadRequestException('userId and amount are required'); } - return this.insuranceFundService.depositToFund(userId, amount); + return this.insuranceFundService.depositToFund(body.userId, body.amount); } @Get('coverage') @@ -31,13 +50,79 @@ export class InsuranceFundController { return { coverageRatio: await this.insuranceFundService.getCoverageRatio() }; } - @Post('incident/:adminId') + @Get('stats') + async getStats(): Promise { + return this.insuranceFundService.getStats(); + } + + @Get('balance') + async getBalance() { + const balance = await this.insuranceFundService.getInsuranceFundBalance(); + return { fundBalance: balance }; + } + + @Get('escrow') + async getEscrowDetails() { + return this.insuranceFundService.getEscrowDetails(); + } + + @Get('claims') + async getAllClaims() { + return this.insuranceFundService.getAllClaims(); + } + + @Get('claims/user/:userId') + async getUserClaims(@Param('userId') userId: string) { + return this.insuranceFundService.getUserClaims(userId); + } + + @Get('claims/status/:status') + async getClaimsByStatus(@Param('status') status: InsuranceClaimStatus) { + return this.insuranceFundService.getClaimsByStatus(status); + } + + @Get('claims/:claimId') + async getClaim(@Param('claimId') claimId: string) { + return this.insuranceFundService.getClaimById(claimId); + } + + @Get('audit') + async getAuditTrail(@Param('vaultId') vaultId?: string) { + return this.insuranceFundService.getAuditTrail(vaultId); + } + + @Post('incident') @UseGuards(RolesGuard) @Roles(UserRole.ADMIN) - async processIncident( - @Param('adminId') adminId: string, - @Body() losses: Record, + @HttpCode(HttpStatus.OK) + async declareIncident( + @Body() body: DeclareIncidentDto, + @Body('adminId') adminId: string, + @Body('adminRole') adminRole: UserRole, ) { - return this.insuranceFundService.processIncident(adminId, losses); + return this.insuranceFundService.declareIncident(adminId, adminRole, body); } -} + + @Post('payout') + @UseGuards(RolesGuard) + @Roles(UserRole.ADMIN) + @HttpCode(HttpStatus.OK) + async processPayout( + @Body() body: ProcessPayoutDto, + @Body('adminId') adminId: string, + @Body('adminRole') adminRole: UserRole, + ) { + return this.insuranceFundService.processIncident(adminId, adminRole, body.losses, body.reason); + } + + @Post('claims/:claimId/finalize') + @UseGuards(RolesGuard) + @Roles(UserRole.ADMIN) + async finalizeClaim( + @Param('claimId') claimId: string, + @Body('adminId') adminId: string, + @Body('adminRole') adminRole: UserRole, + ) { + return this.insuranceFundService.finalizeClaim(claimId, adminId, adminRole); + } +} \ No newline at end of file diff --git a/harvest-finance/backend/src/vaults/insurance-fund.service.spec.ts b/harvest-finance/backend/src/vaults/insurance-fund.service.spec.ts new file mode 100644 index 00000000..bff95886 --- /dev/null +++ b/harvest-finance/backend/src/vaults/insurance-fund.service.spec.ts @@ -0,0 +1,518 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; +import { BadRequestException, NotFoundException, ForbiddenException, ConflictException } from '@nestjs/common'; +import { InsuranceFundService } from './insurance-fund.service'; +import { Vault, VaultType, VaultStatus } from '../database/entities/vault.entity'; +import { Deposit, DepositStatus } from '../database/entities/deposit.entity'; +import { InsuranceClaim, InsuranceClaimStatus } from '../database/entities/insurance-claim.entity'; +import { User, UserRole } from '../database/entities/user.entity'; +import { CustomLoggerService } from '../logger/custom-logger.service'; + +const USER_ID = 'user-11111111-1111-1111-1111-111111111111'; +const ADMIN_ID = 'admin-22222222-2222-2222-2222-222222222222'; +const VAULT_ID = 'vault-33333333-3333-3333-3333-333333333333'; +const INSURANCE_VAULT_ID = 'insurance-v-44444444-4444-4444-4444-444444444444'; +const CLAIM_ID = 'claim-55555555-5555-5555-5555-555555555555'; + +const createMockVault = (overrides: Partial = {}): Vault => + ({ + id: INSURANCE_VAULT_ID, + ownerId: 'insurance-multisig-escrow', + type: VaultType.INSURANCE_FUND, + status: VaultStatus.ACTIVE, + vaultName: 'Insurance Fund', + description: 'Dedicated fund for protection', + symbol: 'INS', + assetPair: 'XLM/USDC', + totalDeposits: 10000, + maxCapacity: Number.MAX_SAFE_INTEGER, + interestRate: 0, + isPublic: false, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }) as Vault; + +const createMockUser = (overrides: Partial = {}): User => + ({ + id: USER_ID, + email: 'test@example.com', + password: 'hashed', + role: UserRole.FARMER, + isActive: true, + firstName: 'Test', + lastName: 'User', + ...overrides, + }) as User; + +const createMockDeposit = (overrides: Partial = {}): Deposit => + ({ + id: 'deposit-66666666-6666-6666-6666-666666666666', + userId: USER_ID, + vaultId: VAULT_ID, + amount: 1000, + status: DepositStatus.CONFIRMED, + transactionHash: 'tx-123', + stellarTransactionId: null, + confirmedAt: new Date(), + notes: null, + idempotencyKey: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }) as Deposit; + +const createMockClaim = (overrides: Partial = {}): InsuranceClaim => + ({ + id: CLAIM_ID, + vaultId: INSURANCE_VAULT_ID, + depositorId: USER_ID, + lossAmount: 1000, + payoutAmount: 1000, + status: InsuranceClaimStatus.PENDING, + transactionHash: null, + reason: 'Test incident', + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }) as InsuranceClaim; + +describe('InsuranceFundService', () => { + let service: InsuranceFundService; + + const mockEntityManager = { + save: jest.fn(), + increment: jest.fn(), + decrement: jest.fn(), + update: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + create: jest.fn(), + createQueryBuilder: jest.fn(), + }; + + const mockDataSource = { + transaction: jest.fn((cb: (em: typeof mockEntityManager) => Promise) => cb(mockEntityManager)), + }; + + const mockVaultRepository = { + findOne: jest.fn(), + find: jest.fn(), + create: jest.fn(), + save: jest.fn(), + findOneOrFail: jest.fn(), + }; + + const mockDepositRepository = { + create: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + save: jest.fn(), + update: jest.fn(), + }; + + const mockClaimRepository = { + create: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + save: jest.fn(), + update: jest.fn(), + }; + + const mockUserRepository = { + findOne: jest.fn(), + find: jest.fn(), + }; + + const mockLogger = { log: jest.fn(), error: jest.fn(), warn: jest.fn() }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + InsuranceFundService, + { provide: getRepositoryToken(Vault), useValue: mockVaultRepository }, + { provide: getRepositoryToken(Deposit), useValue: mockDepositRepository }, + { provide: getRepositoryToken(InsuranceClaim), useValue: mockClaimRepository }, + { provide: getRepositoryToken(User), useValue: mockUserRepository }, + { provide: DataSource, useValue: mockDataSource }, + { provide: CustomLoggerService, useValue: mockLogger }, + ], + }).compile(); + + service = module.get(InsuranceFundService); + }); + + afterEach(() => jest.clearAllMocks()); + + describe('getOrCreateInsuranceVault', () => { + it('should return existing insurance vault if present', async () => { + const existingVault = createMockVault(); + mockVaultRepository.findOne.mockResolvedValue(existingVault); + + const result = await service.getOrCreateInsuranceVault(); + + expect(result.id).toBe(INSURANCE_VAULT_ID); + expect(mockVaultRepository.save).not.toHaveBeenCalled(); + }); + + it('should create insurance vault if not present', async () => { + mockVaultRepository.findOne.mockResolvedValue(null); + mockVaultRepository.create.mockReturnValue(createMockVault()); + mockVaultRepository.save.mockResolvedValue(createMockVault()); + mockVaultRepository.findOneOrFail.mockResolvedValue(createMockVault()); + + const result = await service.getOrCreateInsuranceVault(); + + expect(mockVaultRepository.save).toHaveBeenCalled(); + expect(result.type).toBe(VaultType.INSURANCE_FUND); + }); + }); + + describe('depositToFund', () => { + it('should deposit funds into insurance fund successfully', async () => { + const user = createMockUser(); + const insuranceVault = createMockVault({ totalDeposits: 10000 }); + const updatedVault = createMockVault({ totalDeposits: 11000 }); + + mockUserRepository.findOne.mockResolvedValue(user); + mockVaultRepository.findOne.mockResolvedValue(insuranceVault); + mockVaultRepository.findOneOrFail.mockResolvedValue(updatedVault); + mockDepositRepository.create.mockReturnValue({ + userId: USER_ID, + vaultId: INSURANCE_VAULT_ID, + amount: 1000, + status: DepositStatus.CONFIRMED, + }); + mockEntityManager.save.mockResolvedValue(undefined); + mockEntityManager.increment.mockResolvedValue(undefined); + + const result = await service.depositToFund(USER_ID, 1000); + + expect(result.totalDeposits).toBe(11000); + expect(mockEntityManager.save).toHaveBeenCalled(); + expect(mockEntityManager.increment).toHaveBeenCalledWith( + Vault, + { id: INSURANCE_VAULT_ID }, + 'totalDeposits', + 1000, + ); + }); + + it('should throw BadRequestException for non-positive amount', async () => { + await expect(service.depositToFund(USER_ID, 0)).rejects.toThrow(BadRequestException); + await expect(service.depositToFund(USER_ID, -100)).rejects.toThrow(BadRequestException); + }); + + it('should throw NotFoundException for inactive user', async () => { + mockUserRepository.findOne.mockResolvedValue(null); + + await expect(service.depositToFund(USER_ID, 1000)).rejects.toThrow(NotFoundException); + }); + }); + + describe('getCoverageRatio', () => { + it('should return 0 when there is no TVL', async () => { + const insuranceVault = createMockVault({ totalDeposits: 5000 }); + mockVaultRepository.findOne.mockResolvedValue(insuranceVault); + mockVaultRepository.find.mockResolvedValue([]); + + const ratio = await service.getCoverageRatio(); + + expect(ratio).toBe(0); + }); + + it('should calculate coverage ratio correctly', async () => { + const insuranceVault = createMockVault({ totalDeposits: 5000 }); + const activeVaults = [ + { ...createMockVault({ id: VAULT_ID, totalDeposits: 10000 }) }, + { ...createMockVault({ id: 'vault-2', totalDeposits: 15000 }) }, + ]; + + mockVaultRepository.findOne.mockResolvedValue(insuranceVault); + mockVaultRepository.find.mockResolvedValue(activeVaults); + + const ratio = await service.getCoverageRatio(); + + expect(ratio).toBe(5000 / 25000); + }); + }); + + describe('getStats', () => { + it('should return comprehensive insurance fund statistics', async () => { + const insuranceVault = createMockVault({ totalDeposits: 10000 }); + const activeVaults = [{ ...createMockVault({ id: VAULT_ID, totalDeposits: 20000 }) }]; + const completedClaims = [ + createMockClaim({ payoutAmount: 500 }), + createMockClaim({ payoutAmount: 300 }), + ]; + + mockVaultRepository.findOne.mockResolvedValue(insuranceVault); + mockVaultRepository.find.mockResolvedValue(activeVaults); + mockClaimRepository.find.mockResolvedValue(completedClaims); + + const stats = await service.getStats(); + + expect(stats.fundBalance).toBe(10000); + expect(stats.totalTVL).toBe(20000); + expect(stats.coverageRatio).toBe(0.5); + expect(stats.totalClaimsProcessed).toBe(2); + expect(stats.totalPayoutsDistributed).toBe(800); + }); + }); + + describe('processIncident', () => { + it('should process incident and create claims for valid losses', async () => { + const insuranceVault = createMockVault({ totalDeposits: 10000 }); + const user = createMockUser(); + const claim = createMockClaim(); + + mockVaultRepository.findOne.mockResolvedValue(insuranceVault); + mockUserRepository.find.mockResolvedValue([user]); + mockEntityManager.create.mockReturnValue(claim); + mockEntityManager.save.mockResolvedValue(claim); + mockEntityManager.decrement.mockResolvedValue(undefined); + mockClaimRepository.update.mockResolvedValue(undefined); + + const losses = { [USER_ID]: 5000 }; + const claims = await service.processIncident(ADMIN_ID, UserRole.ADMIN, losses); + + expect(claims.length).toBe(1); + expect(mockEntityManager.decrement).toHaveBeenCalled(); + }); + + it('should throw ForbiddenException for non-admin', async () => { + const insuranceVault = createMockVault(); + mockVaultRepository.findOne.mockResolvedValue(insuranceVault); + + await expect(service.processIncident(USER_ID, UserRole.FARMER, {})).rejects.toThrow(ForbiddenException); + }); + + it('should throw BadRequestException for empty losses', async () => { + const insuranceVault = createMockVault(); + mockVaultRepository.findOne.mockResolvedValue(insuranceVault); + + await expect(service.processIncident(ADMIN_ID, UserRole.ADMIN, {})).rejects.toThrow(BadRequestException); + }); + + it('should calculate pro-rata payouts when insufficient funds', async () => { + const insuranceVault = createMockVault({ totalDeposits: 5000 }); + const user1 = createMockUser(); + const user2 = createMockUser({ id: 'user-77777777-7777-7777-7777-777777777777' }); + + mockVaultRepository.findOne.mockResolvedValue(insuranceVault); + mockUserRepository.find.mockResolvedValue([user1, user2]); + mockEntityManager.findOne.mockResolvedValue(null); + mockEntityManager.create.mockReturnValue(createMockClaim()); + mockEntityManager.save.mockImplementation((entity) => Promise.resolve(entity)); + mockEntityManager.decrement.mockResolvedValue(undefined); + mockClaimRepository.update.mockResolvedValue(undefined); + + const losses = { [user1.id]: 3000, [user2.id]: 5000 }; + const claims = await service.processIncident(ADMIN_ID, UserRole.ADMIN, losses); + + expect(claims.length).toBe(2); + }); + + it('should prevent duplicate claims', async () => { + const insuranceVault = createMockVault({ totalDeposits: 10000 }); + const user = createMockUser(); + const existingClaim = createMockClaim(); + + mockVaultRepository.findOne.mockResolvedValue(insuranceVault); + mockUserRepository.find.mockResolvedValue([user]); + mockEntityManager.findOne.mockResolvedValue(existingClaim); + + await expect( + service.processIncident(ADMIN_ID, UserRole.ADMIN, { [USER_ID]: 5000 }), + ).rejects.toThrow(ConflictException); + }); + }); + + describe('declareIncident', () => { + it('should calculate losses from vault deposits and process incident', async () => { + const insuranceVault = createMockVault({ totalDeposits: 20000 }); + const targetVault = { id: VAULT_ID, totalDeposits: 10000 }; + const deposits = [ + { userId: USER_ID, amount: 6000, status: DepositStatus.CONFIRMED }, + { userId: 'user-77777777-7777-7777-7777-777777777777', amount: 4000, status: DepositStatus.CONFIRMED }, + ]; + const user = createMockUser(); + const claim = createMockClaim(); + + mockVaultRepository.findOne + .mockResolvedValueOnce(insuranceVault) + .mockResolvedValueOnce(targetVault); + mockDepositRepository.find.mockResolvedValue(deposits); + mockUserRepository.find.mockResolvedValue([user]); + mockEntityManager.findOne.mockResolvedValue(null); + mockEntityManager.create.mockReturnValue(claim); + mockEntityManager.save.mockImplementation((entity) => Promise.resolve(entity)); + mockEntityManager.decrement.mockResolvedValue(undefined); + mockClaimRepository.update.mockResolvedValue(undefined); + + const claims = await service.declareIncident(ADMIN_ID, UserRole.ADMIN, { + vaultId: VAULT_ID, + lossAmount: 1000, + description: 'Test incident', + }); + + expect(claims.length).toBeGreaterThan(0); + }); + + it('should throw NotFoundException for non-existent vault', async () => { + const insuranceVault = createMockVault(); + mockVaultRepository.findOne.mockResolvedValue(insuranceVault); + mockVaultRepository.findOne.mockResolvedValue(null); + + await expect( + service.declareIncident(ADMIN_ID, UserRole.ADMIN, { + vaultId: 'nonexistent', + lossAmount: 1000, + description: 'Test', + }), + ).rejects.toThrow(NotFoundException); + }); + + it('should throw BadRequestException for no deposits in vault', async () => { + const insuranceVault = createMockVault(); + const targetVault = { id: VAULT_ID }; + mockVaultRepository.findOne + .mockResolvedValueOnce(insuranceVault) + .mockResolvedValueOnce(targetVault); + mockDepositRepository.find.mockResolvedValue([]); + + await expect( + service.declareIncident(ADMIN_ID, UserRole.ADMIN, { + vaultId: VAULT_ID, + lossAmount: 1000, + description: 'Test', + }), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw ForbiddenException for non-admin', async () => { + await expect( + service.declareIncident(USER_ID, UserRole.FARMER, { + vaultId: VAULT_ID, + lossAmount: 1000, + description: 'Test', + }), + ).rejects.toThrow(ForbiddenException); + }); + }); + + describe('getUserClaims', () => { + it('should return claims for user', async () => { + const claims = [createMockClaim()]; + mockClaimRepository.find.mockResolvedValue(claims); + + const result = await service.getUserClaims(USER_ID); + + expect(result).toHaveLength(1); + expect(mockClaimRepository.find).toHaveBeenCalledWith( + expect.objectContaining({ where: { depositorId: USER_ID } }), + ); + }); + }); + + describe('getClaimsByStatus', () => { + it('should return claims filtered by status', async () => { + const claims = [createMockClaim({ status: InsuranceClaimStatus.COMPLETED })]; + mockClaimRepository.find.mockResolvedValue(claims); + + const result = await service.getClaimsByStatus(InsuranceClaimStatus.COMPLETED); + + expect(result).toHaveLength(1); + }); + }); + + describe('getAllClaims', () => { + it('should return all claims with relations', async () => { + const claims = [createMockClaim()]; + mockClaimRepository.find.mockResolvedValue(claims); + + const result = await service.getAllClaims(); + + expect(result).toHaveLength(1); + expect(mockClaimRepository.find).toHaveBeenCalledWith( + expect.objectContaining({ relations: ['depositor', 'vault'] }), + ); + }); + }); + + describe('getClaimById', () => { + it('should return a claim by ID', async () => { + mockClaimRepository.findOne.mockResolvedValue(createMockClaim()); + + const result = await service.getClaimById(CLAIM_ID); + + expect(result).toBeDefined(); + expect(result.id).toBe(CLAIM_ID); + }); + + it('should throw NotFoundException for unknown claim', async () => { + mockClaimRepository.findOne.mockResolvedValue(null); + + await expect(service.getClaimById('nonexistent')).rejects.toThrow(NotFoundException); + }); + }); + + describe('finalizeClaim', () => { + it('should finalize a pending claim', async () => { + const pendingClaim = createMockClaim({ status: InsuranceClaimStatus.PENDING }); + const finalizedClaim = createMockClaim({ status: InsuranceClaimStatus.COMPLETED, transactionHash: 'final-tx' }); + + mockClaimRepository.findOne.mockResolvedValue(pendingClaim); + mockEntityManager.save.mockResolvedValue(finalizedClaim); + + const result = await service.finalizeClaim(CLAIM_ID, ADMIN_ID, UserRole.ADMIN); + + expect(result.status).toBe(InsuranceClaimStatus.COMPLETED); + }); + + it('should throw ForbiddenException for non-admin', async () => { + mockClaimRepository.findOne.mockResolvedValue(createMockClaim()); + + await expect(service.finalizeClaim(CLAIM_ID, USER_ID, UserRole.FARMER)).rejects.toThrow( + ForbiddenException, + ); + }); + + it('should return already completed claim without changes', async () => { + const completedClaim = createMockClaim({ status: InsuranceClaimStatus.COMPLETED }); + mockClaimRepository.findOne.mockResolvedValue(completedClaim); + + const result = await service.finalizeClaim(CLAIM_ID, ADMIN_ID, UserRole.ADMIN); + + expect(result.status).toBe(InsuranceClaimStatus.COMPLETED); + }); + }); + + describe('getAuditTrail', () => { + it('should return deposits and claims for audit', async () => { + const insuranceVault = createMockVault(); + const deposits = [createMockDeposit()]; + const claims = [createMockClaim()]; + + mockVaultRepository.findOne.mockResolvedValue(insuranceVault); + mockDepositRepository.find.mockResolvedValue(deposits); + mockClaimRepository.find.mockResolvedValue(claims); + + const result = await service.getAuditTrail(); + + expect(result.deposits).toHaveLength(1); + expect(result.claims).toHaveLength(1); + }); + }); + + describe('getEscrowDetails', () => { + it('should return Soroban multisig escrow details', async () => { + const escrow = await service.getEscrowDetails(); + + expect(escrow.address).toBe('insurance-multisig-escrow'); + expect(escrow.signers).toHaveLength(3); + expect(escrow.threshold).toBe(2); + }); + }); +}); \ No newline at end of file diff --git a/harvest-finance/backend/src/vaults/insurance-fund.service.ts b/harvest-finance/backend/src/vaults/insurance-fund.service.ts index 847a831a..0300a6ce 100644 --- a/harvest-finance/backend/src/vaults/insurance-fund.service.ts +++ b/harvest-finance/backend/src/vaults/insurance-fund.service.ts @@ -1,26 +1,39 @@ -import { Injectable, BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common'; +import { + Injectable, + BadRequestException, + NotFoundException, + ForbiddenException, + ConflictException, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, DataSource, Not, In } from 'typeorm'; import { Vault, VaultType, VaultStatus } from '../database/entities/vault.entity'; -import { Deposit } from '../database/entities/deposit.entity'; +import { Deposit, DepositStatus } from '../database/entities/deposit.entity'; import { InsuranceClaim, InsuranceClaimStatus } from '../database/entities/insurance-claim.entity'; +import { User, UserRole } from '../database/entities/user.entity'; import { CustomLoggerService } from '../logger/custom-logger.service'; -/** - * Service responsible for managing the optional insurance fund for vaults. - * - * * Insurance funds are stored in a dedicated Soroban multisig escrow account – - * represented in this service by a special "insurance" vault (type - * `VaultType.INSURANCE_FUND`). - * * Depositors can contribute to the fund via `depositToFund`. - * * The coverage ratio is calculated as `insuranceFund.totalDeposits / totalTVL` - * where totalTVL is the sum of `totalDeposits` of all active vaults. - * * In the event of an incident, an admin can trigger a pro‑rata payout to - * eligible depositors. Claims are recorded in the `insurance_claim` table and - * are fully auditable on‑chain. - */ +export interface SorobanMultisigEscrow { + address: string; + signers: string[]; + threshold: number; + createdAt: Date; +} + +export interface InsuranceFundStats { + fundBalance: number; + totalTVL: number; + coverageRatio: number; + totalClaimsProcessed: number; + totalPayoutsDistributed: number; +} + @Injectable() export class InsuranceFundService { + private readonly INSURANCE_FUND_VAULT_NAME = 'Insurance Fund'; + private readonly ESCROW_SIGNERS = ['governance-signer-1', 'governance-signer-2', 'governance-signer-3']; + private readonly ESCROW_THRESHOLD = 2; + constructor( @InjectRepository(Vault) private readonly vaultRepo: Repository, @@ -28,25 +41,33 @@ export class InsuranceFundService { private readonly depositRepo: Repository, @InjectRepository(InsuranceClaim) private readonly claimRepo: Repository, + @InjectRepository(User) + private readonly userRepo: Repository, private readonly dataSource: DataSource, private readonly logger: CustomLoggerService, ) {} - /** - * Ensure the dedicated insurance vault exists. Called lazily; creates the vault - * on first use. - */ - private async getOrCreateInsuranceVault(): Promise { + private getEscrowAccount(): SorobanMultisigEscrow { + return { + address: 'insurance-multisig-escrow', + signers: this.ESCROW_SIGNERS, + threshold: this.ESCROW_THRESHOLD, + createdAt: new Date(), + }; + } + + async getOrCreateInsuranceVault(): Promise { let vault = await this.vaultRepo.findOne({ where: { type: VaultType.INSURANCE_FUND }, }); if (!vault) { + const escrow = this.getEscrowAccount(); vault = this.vaultRepo.create({ - ownerId: 'system-insurance', // system account placeholder + ownerId: escrow.address, type: VaultType.INSURANCE_FUND, status: VaultStatus.ACTIVE, - vaultName: 'Insurance Fund', - description: 'Dedicated fund for protecting depositors against protocol incidents.', + vaultName: this.INSURANCE_FUND_VAULT_NAME, + description: 'Dedicated insurance fund for protecting depositors against protocol incidents and strategy failures.', symbol: 'INS', assetPair: 'XLM/USDC', totalDeposits: 0, @@ -55,89 +76,279 @@ export class InsuranceFundService { isPublic: false, }); await this.vaultRepo.save(vault); - this.logger.log('Created insurance fund vault', 'InsuranceFundService'); + this.logger.log('Created insurance fund vault with Soroban multisig escrow', 'InsuranceFundService'); } return vault; } - /** Deposit user funds into the insurance fund */ async depositToFund(userId: string, amount: number): Promise { if (amount <= 0) { throw new BadRequestException('Deposit amount must be positive'); } + + const user = await this.userRepo.findOne({ where: { id: userId, isActive: true } }); + if (!user) { + throw new NotFoundException('User not found or inactive'); + } + const fundVault = await this.getOrCreateInsuranceVault(); - // Record a deposit (re‑using the generic Deposit entity for auditable tracking) + const deposit = this.depositRepo.create({ userId, vaultId: fundVault.id, amount, - status: 'CONFIRMED' as any, // insurance deposits are instantly confirmed - transactionHash: null, - stellarTransactionId: null, + status: DepositStatus.CONFIRMED, + transactionHash: `ins_fund_tx_${Date.now()}`, + stellarTransactionId: `stellar_ins_fund_${Date.now()}`, confirmedAt: new Date(), }); + await this.dataSource.transaction(async (manager) => { await manager.save(deposit); await manager.increment(Vault, { id: fundVault.id }, 'totalDeposits', amount); }); + + this.logger.log(`User ${userId} deposited ${amount} to insurance fund`, 'InsuranceFundService'); + return this.vaultRepo.findOneOrFail({ where: { id: fundVault.id } }); } - /** Calculate the current insurance coverage ratio */ async getCoverageRatio(): Promise { const [insuranceVault, activeVaults] = await Promise.all([ this.getOrCreateInsuranceVault(), this.vaultRepo.find({ where: { status: VaultStatus.ACTIVE, type: Not(VaultType.INSURANCE_FUND) } }), ]); + const totalTVL = activeVaults.reduce((sum, v) => sum + Number(v.totalDeposits), 0); if (totalTVL === 0) return 0; return Number(insuranceVault.totalDeposits) / totalTVL; } - /** - * Admin‑only workflow to process an incident. - * `losses` maps depositorId => lossAmount (the amount they are owed). - * Payouts are pro‑rata based on the insurance fund balance. - */ - async processIncident(adminId: string, losses: Record): Promise { - // Simple admin check – in a real system this would be a role lookup. - if (adminId !== 'admin') { + async getStats(): Promise { + const insuranceVault = await this.getOrCreateInsuranceVault(); + const activeVaults = await this.vaultRepo.find({ + where: { status: VaultStatus.ACTIVE, type: Not(VaultType.INSURANCE_FUND) }, + }); + + const totalTVL = activeVaults.reduce((sum, v) => sum + Number(v.totalDeposits), 0); + const coverageRatio = totalTVL > 0 ? Number(insuranceVault.totalDeposits) / totalTVL : 0; + + const claims = await this.claimRepo.find({ where: { status: InsuranceClaimStatus.COMPLETED } }); + const totalClaimsProcessed = claims.length; + const totalPayoutsDistributed = claims.reduce((sum, c) => sum + Number(c.payoutAmount), 0); + + return { + fundBalance: Number(insuranceVault.totalDeposits), + totalTVL, + coverageRatio, + totalClaimsProcessed, + totalPayoutsDistributed, + }; + } + + async getInsuranceFundBalance(): Promise { + const insuranceVault = await this.getOrCreateInsuranceVault(); + return Number(insuranceVault.totalDeposits); + } + + async getEscrowDetails(): Promise { + return this.getEscrowAccount(); + } + + async processIncident( + adminId: string, + adminRole: UserRole, + losses: Record, + reason?: string, + ): Promise { + if (adminRole !== UserRole.ADMIN) { throw new ForbiddenException('Only admin may trigger incident payouts'); } + + const validLosses = Object.entries(losses).filter(([, loss]) => loss > 0 && loss !== null); + if (validLosses.length === 0) { + throw new BadRequestException('No valid losses provided'); + } + + const depositorIds = validLosses.map(([id]) => id); + const validDepositors = await this.userRepo.find({ + where: { id: In(depositorIds), isActive: true }, + }); + + const validDepositorIds = new Set(validDepositors.map((u) => u.id)); + for (const depositorId of depositorIds) { + if (!validDepositorIds.has(depositorId)) { + this.logger.warn(`Invalid depositor ${depositorId} in incident claim`, 'InsuranceFundService'); + } + } + const fundVault = await this.getOrCreateInsuranceVault(); const fundBalance = Number(fundVault.totalDeposits); - const totalLosses = Object.values(losses).reduce((a, b) => a + b, 0); + + const totalLosses = validLosses.reduce((sum, [, loss]) => sum + loss, 0); if (totalLosses === 0) { - throw new BadRequestException('No losses provided'); + throw new BadRequestException('Total losses must be greater than zero'); } - // Determine payout factor – if insufficient coverage, we pay proportionally. + const payoutFactor = fundBalance >= totalLosses ? 1 : fundBalance / totalLosses; + const claims: InsuranceClaim[] = []; await this.dataSource.transaction(async (manager) => { - for (const [depositorId, loss] of Object.entries(losses)) { - const payout = Math.floor(loss * payoutFactor * 100) / 100; // round to 2 decimals + for (const [depositorId, loss] of validLosses) { + if (!validDepositorIds.has(depositorId)) continue; + + const existingClaim = await manager.findOne(InsuranceClaim, { + where: { + vaultId: fundVault.id, + depositorId, + status: In([InsuranceClaimStatus.PENDING, InsuranceClaimStatus.COMPLETED]), + }, + }); + + if (existingClaim) { + throw new ConflictException(`Duplicate claim exists for depositor ${depositorId}`); + } + + const payout = Math.floor(loss * payoutFactor * 100) / 100; if (payout <= 0) continue; + const claim = manager.create(InsuranceClaim, { vaultId: fundVault.id, depositorId, lossAmount: loss, payoutAmount: payout, status: InsuranceClaimStatus.PENDING, + reason: reason || 'Protocol incident - smart contract exploit or strategy failure', + transactionHash: null, }); await manager.save(claim); claims.push(claim); - // Deduct from fund balance await manager.decrement(Vault, { id: fundVault.id }, 'totalDeposits', payout); } }); - // After transaction, mark all as COMPLETED - await this.claimRepo.update({ id: In(claims.map(c => c.id)) }, { status: InsuranceClaimStatus.COMPLETED }); - this.logger.log(`Processed incident payouts for ${claims.length} claimants`, 'InsuranceFundService'); + + await this.claimRepo.update({ id: In(claims.map((c) => c.id)) }, { status: InsuranceClaimStatus.COMPLETED }); + + this.logger.log( + `Processed incident with ${claims.length} claimants, insufficient funds: ${payoutFactor < 1}`, + 'InsuranceFundService', + ); + return claims; } - /** Retrieve all insurance claims for auditing */ + async declareIncident(adminId: string, adminRole: UserRole, incidentData: { + vaultId: string; + lossAmount: number; + description: string; + }): Promise { + if (adminRole !== UserRole.ADMIN) { + throw new ForbiddenException('Only admin may declare incidents'); + } + + const vault = await this.vaultRepo.findOne({ where: { id: incidentData.vaultId } }); + if (!vault) { + throw new NotFoundException('Vault not found'); + } + + const deposits = await this.depositRepo.find({ + where: { vaultId: incidentData.vaultId, status: DepositStatus.CONFIRMED }, + }); + + if (deposits.length === 0) { + throw new BadRequestException('No deposits found for the specified vault'); + } + + const totalLoss = incidentData.lossAmount; + const lossesByDepositor: Record = {}; + + const totalDeposits = deposits.reduce((sum, d) => sum + Number(d.amount), 0); + const lossRatio = totalLoss / totalDeposits; + + for (const deposit of deposits) { + lossesByDepositor[deposit.userId] = Number(deposit.amount) * lossRatio; + } + + return this.processIncident(adminId, adminRole, lossesByDepositor, incidentData.description); + } + + async getUserClaims(userId: string): Promise { + return this.claimRepo.find({ + where: { depositorId: userId }, + order: { createdAt: 'DESC' }, + }); + } + + async getClaimsByStatus(status: InsuranceClaimStatus): Promise { + return this.claimRepo.find({ + where: { status }, + order: { createdAt: 'DESC' }, + }); + } + async getAllClaims(): Promise { - return this.claimRepo.find({ order: { createdAt: 'DESC' } }); + return this.claimRepo.find({ + relations: ['depositor', 'vault'], + order: { createdAt: 'DESC' }, + }); } -} + + async getClaimById(claimId: string): Promise { + const claim = await this.claimRepo.findOne({ + where: { id: claimId }, + relations: ['depositor', 'vault'], + }); + if (!claim) { + throw new NotFoundException('Insurance claim not found'); + } + return claim; + } + + async finalizeClaim(claimId: string, adminId: string, adminRole: UserRole): Promise { + if (adminRole !== UserRole.ADMIN) { + throw new ForbiddenException('Only admin may finalize claims'); + } + + const claim = await this.claimRepo.findOne({ where: { id: claimId } }); + if (!claim) { + throw new NotFoundException('Insurance claim not found'); + } + + if (claim.status === InsuranceClaimStatus.COMPLETED) { + return claim; + } + + claim.status = InsuranceClaimStatus.COMPLETED; + claim.transactionHash = `payout_tx_${Date.now()}`; + await this.claimRepo.save(claim); + + this.logger.log(`Claim ${claimId} finalized by admin ${adminId}`, 'InsuranceFundService'); + return claim; + } + + async getAuditTrail(vaultId?: string): Promise<{ + deposits: Deposit[]; + claims: InsuranceClaim[]; + }> { + const fundVault = await this.getOrCreateInsuranceVault(); + + const deposits = await this.depositRepo.find({ + where: { vaultId: fundVault.id }, + relations: ['user'], + order: { createdAt: 'DESC' }, + }); + + const claimFilter: { vaultId: string; status?: InsuranceClaimStatus } = { vaultId: fundVault.id }; + if (vaultId) { + claimFilter.status = InsuranceClaimStatus.COMPLETED; + } + + const claims = await this.claimRepo.find({ + where: claimFilter, + relations: ['depositor'], + order: { createdAt: 'DESC' }, + }); + + return { deposits, claims }; + } +} \ No newline at end of file diff --git a/harvest-finance/backend/src/vaults/vaults.module.ts b/harvest-finance/backend/src/vaults/vaults.module.ts index 4ca12650..ed5ab661 100644 --- a/harvest-finance/backend/src/vaults/vaults.module.ts +++ b/harvest-finance/backend/src/vaults/vaults.module.ts @@ -13,26 +13,29 @@ import { DepositEvent } from '../database/entities/deposit-event.entity'; import { Withdrawal } from '../database/entities/withdrawal.entity'; import { VaultReservation } from './entities/vault-reservation.entity'; import { VaultApyHistory } from '../database/entities/vault-apy-history.entity'; +import { InsuranceClaim } from '../database/entities/insurance-claim.entity'; import { DepositEventService } from './deposit-event.service'; +import { WithdrawalConfirmedHandler } from './events/withdrawal-confirmed.handler'; +import { StellarModule } from '../stellar/stellar.module'; +import { VaultAccountMonitorService } from './vault-account-monitor.service'; +import { InsuranceFundService } from './insurance-fund.service'; +import { InsuranceFundController } from './insurance-fund.controller'; import { AuthModule } from '../auth/auth.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { RealtimeModule } from '../realtime/realtime.module'; import { CommonModule } from '../common/common.module'; -import { WithdrawalConfirmedHandler } from './events/withdrawal-confirmed.handler'; -import { StellarModule } from '../stellar/stellar.module'; -import { VaultAccountMonitorService } from './vault-account-monitor.service'; @Module({ imports: [ - TypeOrmModule.forFeature([Vault, Deposit, DepositEvent, Withdrawal, VaultReservation, VaultApyHistory]), + TypeOrmModule.forFeature([Vault, Deposit, DepositEvent, Withdrawal, VaultReservation, VaultApyHistory, InsuranceClaim]), AuthModule, NotificationsModule, RealtimeModule, CommonModule, StellarModule, ], - controllers: [VaultsController], - providers: [VaultsService, DepositEventService, WithdrawalConfirmedHandler, VaultAccountMonitorService], - exports: [VaultsService, DepositEventService], + controllers: [VaultsController, InsuranceFundController], + providers: [VaultsService, DepositEventService, WithdrawalConfirmedHandler, VaultAccountMonitorService, InsuranceFundService], + exports: [VaultsService, DepositEventService, InsuranceFundService], }) -export class VaultsModule {} +export class VaultsModule {} \ No newline at end of file diff --git a/harvest-finance/frontend/next.config.ts b/harvest-finance/frontend/next.config.ts index 0ac76159..e6b0176f 100644 --- a/harvest-finance/frontend/next.config.ts +++ b/harvest-finance/frontend/next.config.ts @@ -1,7 +1,5 @@ +// Simplified Next.js config without next-intl plugin import type { NextConfig } from "next"; -import createNextIntlPlugin from "next-intl/plugin"; - -const withNextIntl = createNextIntlPlugin(); const nextConfig: NextConfig = { output: 'standalone', @@ -21,4 +19,4 @@ const nextConfig: NextConfig = { reactStrictMode: true, }; -export default withNextIntl(nextConfig); \ No newline at end of file +export default nextConfig; \ No newline at end of file