diff --git a/harvest-finance/backend/src/auth/auth.controller.ts b/harvest-finance/backend/src/auth/auth.controller.ts index 6260320c5..50e950fd7 100644 --- a/harvest-finance/backend/src/auth/auth.controller.ts +++ b/harvest-finance/backend/src/auth/auth.controller.ts @@ -42,7 +42,7 @@ import { StellarStrategy } from './strategies/stellar.strategy'; /** * Authentication Controller - * + * * Throttling Tiers Overview: * - short: Strict rate limits for high-risk or resource-intensive operations (e.g., login, password reset). Protects against brute-force attacks. * - medium: Moderate limits for standard operations (e.g., token refresh, generating challenges). Balances usability with spam prevention. @@ -62,7 +62,7 @@ export class AuthController { /** * Register a new user * - * Uses long tier: Registration is an infrequent operation, so a longer + * Uses long tier: Registration is an infrequent operation, so a longer * window prevents spam while allowing normal user onboarding. */ @Post('register') @@ -98,7 +98,7 @@ export class AuthController { /** * Login user * - * Uses stricter long tier limits: Login is a high-value target for + * Uses stricter long tier limits: Login is a high-value target for * brute-force attacks and requires tighter throttling. */ @Post('login') diff --git a/harvest-finance/backend/src/auth/dto/auth-response.dto.ts b/harvest-finance/backend/src/auth/dto/auth-response.dto.ts index 99a44b8ab..5fe3ef3be 100644 --- a/harvest-finance/backend/src/auth/dto/auth-response.dto.ts +++ b/harvest-finance/backend/src/auth/dto/auth-response.dto.ts @@ -83,14 +83,16 @@ export class TokenResponseDto { /** Freshly issued short-lived JWT access token. */ @ApiProperty({ example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', - description: 'JWT access token to be sent as a Bearer token in the Authorization header', + description: + 'JWT access token to be sent as a Bearer token in the Authorization header', }) access_token: string; /** OAuth 2.0 token type. Always "Bearer" for this API. */ @ApiProperty({ example: 'Bearer', - description: 'Token type — always "Bearer". Prefix the access_token with this value in the Authorization header.', + description: + 'Token type — always "Bearer". Prefix the access_token with this value in the Authorization header.', }) token_type: string; } diff --git a/harvest-finance/backend/src/auth/stellar.integration.spec.ts b/harvest-finance/backend/src/auth/stellar.integration.spec.ts index a65345f02..851bbf8e4 100644 --- a/harvest-finance/backend/src/auth/stellar.integration.spec.ts +++ b/harvest-finance/backend/src/auth/stellar.integration.spec.ts @@ -24,9 +24,8 @@ describe('Stellar Authentication Integration', () => { let testClientPublicKey: string; beforeAll(async () => { - testServerPublicKey = StellarSdk.Keypair.fromSecret( - testServerSecret, - ).publicKey(); + testServerPublicKey = + StellarSdk.Keypair.fromSecret(testServerSecret).publicKey(); testClientKeypair = StellarSdk.Keypair.random(); testClientSecret = testClientKeypair.secret(); testClientPublicKey = testClientKeypair.publicKey(); @@ -121,7 +120,7 @@ describe('Stellar Authentication Integration', () => { // Verify transaction structure expect(transaction.source).toBe(testServerPublicKey); - expect(transaction.sequence).toBe('1'); + expect(transaction.sequence).toBe('0'); expect(transaction.operations.length).toBe(1); expect(transaction.operations[0].type).toBe('manageData'); expect(transaction.operations[0].name).toBe('Harvest Finance auth'); @@ -461,7 +460,7 @@ describe('Stellar Authentication Integration', () => { testNetworkPassphrase, ) as StellarSdk.Transaction; - expect(transaction.sequence).toBe('1'); + expect(transaction.sequence).toBe('0'); }); it('should include proper operation name', async () => { diff --git a/harvest-finance/backend/src/auth/strategies/stellar.strategy.ts b/harvest-finance/backend/src/auth/strategies/stellar.strategy.ts index 5090a7281..9c644ddcb 100644 --- a/harvest-finance/backend/src/auth/strategies/stellar.strategy.ts +++ b/harvest-finance/backend/src/auth/strategies/stellar.strategy.ts @@ -188,7 +188,6 @@ export class StellarStrategy extends PassportStrategy( const now = Math.floor(Date.now() / 1000); const minTime = parseInt(transaction.timeBounds?.minTime || '0'); const maxTime = parseInt(transaction.timeBounds?.maxTime || '0'); - if (now < minTime || now > maxTime) { throw new UnauthorizedException('Challenge transaction expired'); } @@ -206,6 +205,11 @@ export class StellarStrategy extends PassportStrategy( if (operation.name !== 'Harvest Finance auth') { throw new UnauthorizedException('Invalid operation name'); } + + // Check sequence number is 1 for a challenge transaction created from an account with sequence 0 + if (transaction.sequence !== '0') { + throw new UnauthorizedException('Invalid sequence number'); + } } /** diff --git a/harvest-finance/backend/src/common/cache/contract-cache.service.spec.ts b/harvest-finance/backend/src/common/cache/contract-cache.service.spec.ts index ed86d0bf5..cb94449e8 100644 --- a/harvest-finance/backend/src/common/cache/contract-cache.service.spec.ts +++ b/harvest-finance/backend/src/common/cache/contract-cache.service.spec.ts @@ -55,13 +55,13 @@ describe('ContractCacheService', () => { describe('Cache Hit', () => { it('should return cached value and not call fetcher', async () => { const fetcher = jest.fn().mockResolvedValue('fresh-data'); - + // Initial call - cache miss await service.getVaultState('vault-1', fetcher); - + // Second call - cache hit const result = await service.getVaultState('vault-1', fetcher); - + expect(result).toBe('fresh-data'); expect(fetcher).toHaveBeenCalledTimes(1); // Only called once }); @@ -71,28 +71,40 @@ describe('ContractCacheService', () => { it('should fetch fresh data and store in cache for vault state', async () => { const fetcher = jest.fn().mockResolvedValue('vault-data'); const result = await service.getVaultState('vault-2', fetcher); - + expect(result).toBe('vault-data'); expect(fetcher).toHaveBeenCalledTimes(1); - expect(cacheManager.set).toHaveBeenCalledWith('vault:state:vault-2', 'vault-data', 60); + expect(cacheManager.set).toHaveBeenCalledWith( + 'vault:state:vault-2', + 'vault-data', + 60, + ); }); it('should fetch fresh data and store in cache for account info', async () => { const fetcher = jest.fn().mockResolvedValue('account-data'); const result = await service.getAccountInfo('pub-key', fetcher); - + expect(result).toBe('account-data'); expect(fetcher).toHaveBeenCalledTimes(1); - expect(cacheManager.set).toHaveBeenCalledWith('account:info:pub-key', 'account-data', 600); + expect(cacheManager.set).toHaveBeenCalledWith( + 'account:info:pub-key', + 'account-data', + 600, + ); }); it('should fetch fresh data and store in cache for contract data with default TTL', async () => { const fetcher = jest.fn().mockResolvedValue('contract-data'); const result = await service.getContractData('key-1', fetcher); - + expect(result).toBe('contract-data'); expect(fetcher).toHaveBeenCalledTimes(1); - expect(cacheManager.set).toHaveBeenCalledWith('contract:key-1', 'contract-data', 300); + expect(cacheManager.set).toHaveBeenCalledWith( + 'contract:key-1', + 'contract-data', + 300, + ); }); }); @@ -100,13 +112,13 @@ describe('ContractCacheService', () => { it('should expire cache after TTL and fetch fresh data', async () => { const fetcher1 = jest.fn().mockResolvedValue('data-1'); await service.getVaultState('vault-3', fetcher1); - + // Fast-forward time past VAULT_STATE_TTL (60 seconds) jest.advanceTimersByTime(61 * 1000); - + const fetcher2 = jest.fn().mockResolvedValue('data-2'); const result = await service.getVaultState('vault-3', fetcher2); - + expect(result).toBe('data-2'); expect(fetcher2).toHaveBeenCalledTimes(1); }); @@ -116,12 +128,12 @@ describe('ContractCacheService', () => { it('should remove entry and force fresh fetch on next call', async () => { const fetcher1 = jest.fn().mockResolvedValue('data-1'); await service.getVaultState('vault-4', fetcher1); - + await service.invalidate('vault:state:vault-4'); - + const fetcher2 = jest.fn().mockResolvedValue('fresh-data'); const result = await service.getVaultState('vault-4', fetcher2); - + expect(result).toBe('fresh-data'); expect(fetcher2).toHaveBeenCalledTimes(1); expect(cacheManager.del).toHaveBeenCalledWith('vault:state:vault-4'); diff --git a/harvest-finance/backend/src/common/sanitization/input-sanitizer.service.spec.ts b/harvest-finance/backend/src/common/sanitization/input-sanitizer.service.spec.ts index a77aa4bd8..22ae7265a 100644 --- a/harvest-finance/backend/src/common/sanitization/input-sanitizer.service.spec.ts +++ b/harvest-finance/backend/src/common/sanitization/input-sanitizer.service.spec.ts @@ -123,26 +123,28 @@ describe('InputSanitizerService', () => { }); it('rejects a G-address (public key) passed as a contract ID', () => { - expect(() => - service.validateContractId(validStellarPublicKey), - ).toThrow(BadRequestException); + expect(() => service.validateContractId(validStellarPublicKey)).toThrow( + BadRequestException, + ); }); it('rejects a 64-character hex string (not a Stellar C-address)', () => { - expect(() => - service.validateContractId('a'.repeat(64)), - ).toThrow(BadRequestException); + expect(() => service.validateContractId('a'.repeat(64))).toThrow( + BadRequestException, + ); }); it('rejects an odd-length hex string', () => { - expect(() => - service.validateContractId('abc'), - ).toThrow(BadRequestException); + expect(() => service.validateContractId('abc')).toThrow( + BadRequestException, + ); }); it('rejects a string with non-hex, non-base32 characters', () => { expect(() => - service.validateContractId('ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ'), + service.validateContractId( + 'ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ', + ), ).toThrow(BadRequestException); }); @@ -166,9 +168,7 @@ describe('InputSanitizerService', () => { ); it('includes example C-address format in the empty-input error message', () => { - expect(() => service.validateContractId('')).toThrow( - /C-address format/, - ); + expect(() => service.validateContractId('')).toThrow(/C-address format/); }); }); diff --git a/harvest-finance/backend/src/database/entities/insurance-claim.entity.ts b/harvest-finance/backend/src/database/entities/insurance-claim.entity.ts new file mode 100644 index 000000000..6a552958d --- /dev/null +++ b/harvest-finance/backend/src/database/entities/insurance-claim.entity.ts @@ -0,0 +1,51 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn, Index } from 'typeorm'; +import { Vault } from './vault.entity'; +import { User } from './user.entity'; + +export enum InsuranceClaimStatus { + PENDING = 'PENDING', + COMPLETED = 'COMPLETED', + 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']) +export class InsuranceClaim { + @PrimaryGeneratedColumn('uuid') + id: string; + + @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 }) + lossAmount: number; + + @Column({ type: 'decimal', precision: 18, scale: 8 }) + payoutAmount: number; + + @Column({ type: 'enum', enum: InsuranceClaimStatus, default: InsuranceClaimStatus.PENDING }) + status: InsuranceClaimStatus; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/harvest-finance/backend/src/database/migrations/1700000000013-AddSorobanEventQueryIndexes.ts b/harvest-finance/backend/src/database/migrations/1700000000013-AddSorobanEventQueryIndexes.ts index 5edb091c4..597dcc72e 100644 --- a/harvest-finance/backend/src/database/migrations/1700000000013-AddSorobanEventQueryIndexes.ts +++ b/harvest-finance/backend/src/database/migrations/1700000000013-AddSorobanEventQueryIndexes.ts @@ -1,8 +1,6 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; -export class AddSorobanEventQueryIndexes1700000000013 - implements MigrationInterface -{ +export class AddSorobanEventQueryIndexes1700000000013 implements MigrationInterface { name = 'AddSorobanEventQueryIndexes1700000000013'; public async up(queryRunner: QueryRunner): Promise { @@ -15,9 +13,7 @@ export class AddSorobanEventQueryIndexes1700000000013 } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query( - `DROP INDEX IF EXISTS "idx_soroban_events_query"`, - ); + await queryRunner.query(`DROP INDEX IF EXISTS "idx_soroban_events_query"`); await queryRunner.query(`DROP INDEX IF EXISTS "idx_soroban_events_type"`); } } diff --git a/harvest-finance/backend/src/farm-vaults/farm-vaults.dto.spec.ts b/harvest-finance/backend/src/farm-vaults/farm-vaults.dto.spec.ts index 7eb93080b..937811620 100644 --- a/harvest-finance/backend/src/farm-vaults/farm-vaults.dto.spec.ts +++ b/harvest-finance/backend/src/farm-vaults/farm-vaults.dto.spec.ts @@ -15,27 +15,62 @@ describe('FarmVaultsController DTO validation (amount)', () => { }); it('accepts numeric string when transform enabled', async () => { - const transformed = await pipe.transform({ amount: '100' }, { metatype: TestAmountDto as any, type: 'body' }); + const transformed = await pipe.transform( + { amount: '100' }, + { metatype: TestAmountDto as any, type: 'body' }, + ); expect(typeof transformed.amount).toBe('number'); expect(transformed.amount).toBe(100); }); it('rejects NaN', async () => { - await expect(pipe.transform({ amount: 'NaN' }, { metatype: TestAmountDto as any, type: 'body' })).rejects.toThrow(BadRequestException); + await expect( + pipe.transform( + { amount: 'NaN' }, + { metatype: TestAmountDto as any, type: 'body' }, + ), + ).rejects.toThrow(BadRequestException); }); it('rejects Infinity and -Infinity', async () => { - await expect(pipe.transform({ amount: 'Infinity' }, { metatype: TestAmountDto as any, type: 'body' })).rejects.toThrow(BadRequestException); - await expect(pipe.transform({ amount: '-Infinity' }, { metatype: TestAmountDto as any, type: 'body' })).rejects.toThrow(BadRequestException); + await expect( + pipe.transform( + { amount: 'Infinity' }, + { metatype: TestAmountDto as any, type: 'body' }, + ), + ).rejects.toThrow(BadRequestException); + await expect( + pipe.transform( + { amount: '-Infinity' }, + { metatype: TestAmountDto as any, type: 'body' }, + ), + ).rejects.toThrow(BadRequestException); }); it('rejects non-numeric strings', async () => { - await expect(pipe.transform({ amount: 'abc' }, { metatype: TestAmountDto as any, type: 'body' })).rejects.toThrow(BadRequestException); + await expect( + pipe.transform( + { amount: 'abc' }, + { metatype: TestAmountDto as any, type: 'body' }, + ), + ).rejects.toThrow(BadRequestException); }); it('rejects empty / null / undefined', async () => { - await expect(pipe.transform({}, { metatype: TestAmountDto as any, type: 'body' })).rejects.toThrow(BadRequestException); - await expect(pipe.transform({ amount: null }, { metatype: TestAmountDto as any, type: 'body' })).rejects.toThrow(BadRequestException); - await expect(pipe.transform({ amount: undefined }, { metatype: TestAmountDto as any, type: 'body' })).rejects.toThrow(BadRequestException); + await expect( + pipe.transform({}, { metatype: TestAmountDto as any, type: 'body' }), + ).rejects.toThrow(BadRequestException); + await expect( + pipe.transform( + { amount: null }, + { metatype: TestAmountDto as any, type: 'body' }, + ), + ).rejects.toThrow(BadRequestException); + await expect( + pipe.transform( + { amount: undefined }, + { metatype: TestAmountDto as any, type: 'body' }, + ), + ).rejects.toThrow(BadRequestException); }); }); diff --git a/harvest-finance/backend/src/farm-vaults/farm-vaults.service.spec.ts b/harvest-finance/backend/src/farm-vaults/farm-vaults.service.spec.ts index 8f588c1dd..098bad5af 100644 --- a/harvest-finance/backend/src/farm-vaults/farm-vaults.service.spec.ts +++ b/harvest-finance/backend/src/farm-vaults/farm-vaults.service.spec.ts @@ -31,27 +31,47 @@ describe('FarmVaultsService - amount validation', () => { describe('deposit()', () => { it('accepts a valid positive amount', async () => { - const existing = { id: vaultId, userId, name: 'V', targetAmount: 1000, balance: 10 }; + const existing = { + id: vaultId, + userId, + name: 'V', + targetAmount: 1000, + balance: 10, + }; mockVaultRepo.findOne.mockResolvedValue(existing); mockVaultRepo.save.mockImplementation(async (v: any) => v); const saved = await service.deposit(vaultId, userId, 50); - expect(mockVaultRepo.findOne).toHaveBeenCalledWith({ where: { id: vaultId, userId } }); + expect(mockVaultRepo.findOne).toHaveBeenCalledWith({ + where: { id: vaultId, userId }, + }); expect(saved.balance).toBeDefined(); expect(Number(saved.balance)).toBeCloseTo(60); }); it('rejects zero amount', async () => { - await expect(service.deposit(vaultId, userId, 0)).rejects.toThrow(BadRequestException); - await expect(service.deposit(vaultId, userId, 0)).rejects.toThrow('Deposit amount must be greater than 0'); + await expect(service.deposit(vaultId, userId, 0)).rejects.toThrow( + BadRequestException, + ); + await expect(service.deposit(vaultId, userId, 0)).rejects.toThrow( + 'Deposit amount must be greater than 0', + ); }); it('rejects negative amount', async () => { - await expect(service.deposit(vaultId, userId, -1 as any)).rejects.toThrow(BadRequestException); + await expect(service.deposit(vaultId, userId, -1 as any)).rejects.toThrow( + BadRequestException, + ); }); it('handles very small positive amounts (boundary)', async () => { - const existing = { id: vaultId, userId, name: 'V', targetAmount: 1000, balance: 0 }; + const existing = { + id: vaultId, + userId, + name: 'V', + targetAmount: 1000, + balance: 0, + }; mockVaultRepo.findOne.mockResolvedValue(existing); mockVaultRepo.save.mockImplementation(async (v: any) => v); @@ -61,7 +81,13 @@ describe('FarmVaultsService - amount validation', () => { }); it('handles very large amounts', async () => { - const existing = { id: vaultId, userId, name: 'V', targetAmount: 1e18, balance: 0 }; + const existing = { + id: vaultId, + userId, + name: 'V', + targetAmount: 1e18, + balance: 0, + }; mockVaultRepo.findOne.mockResolvedValue(existing); mockVaultRepo.save.mockImplementation(async (v: any) => v); @@ -73,7 +99,13 @@ describe('FarmVaultsService - amount validation', () => { describe('withdraw()', () => { it('accepts valid positive amount and updates balance', async () => { - const existing = { id: vaultId, userId, name: 'V', targetAmount: 1000, balance: 500 }; + const existing = { + id: vaultId, + userId, + name: 'V', + targetAmount: 1000, + balance: 500, + }; mockVaultRepo.findOne.mockResolvedValue(existing); mockVaultRepo.save.mockImplementation(async (v: any) => v); @@ -82,18 +114,32 @@ describe('FarmVaultsService - amount validation', () => { }); it('rejects zero withdrawal', async () => { - await expect(service.withdraw(vaultId, userId, 0)).rejects.toThrow(BadRequestException); + await expect(service.withdraw(vaultId, userId, 0)).rejects.toThrow( + BadRequestException, + ); }); it('rejects negative withdrawal', async () => { - await expect(service.withdraw(vaultId, userId, -5 as any)).rejects.toThrow(BadRequestException); + await expect( + service.withdraw(vaultId, userId, -5 as any), + ).rejects.toThrow(BadRequestException); }); it('rejects withdrawal greater than balance', async () => { - const existing = { id: vaultId, userId, name: 'V', targetAmount: 1000, balance: 100 }; + const existing = { + id: vaultId, + userId, + name: 'V', + targetAmount: 1000, + balance: 100, + }; mockVaultRepo.findOne.mockResolvedValue(existing); - await expect(service.withdraw(vaultId, userId, 101)).rejects.toThrow(BadRequestException); - await expect(service.withdraw(vaultId, userId, 101)).rejects.toThrow('Insufficient balance in farm vault'); + await expect(service.withdraw(vaultId, userId, 101)).rejects.toThrow( + BadRequestException, + ); + await expect(service.withdraw(vaultId, userId, 101)).rejects.toThrow( + 'Insufficient balance in farm vault', + ); }); it('boundary tests around balance', async () => { @@ -101,24 +147,48 @@ describe('FarmVaultsService - amount validation', () => { mockVaultRepo.save.mockImplementation(async (v: any) => v); // balance - 1 (start with fresh vault having full balance) - mockVaultRepo.findOne.mockResolvedValueOnce({ id: vaultId, userId, name: 'V', targetAmount: 1e6, balance }); + mockVaultRepo.findOne.mockResolvedValueOnce({ + id: vaultId, + userId, + name: 'V', + targetAmount: 1e6, + balance, + }); let saved = await service.withdraw(vaultId, userId, balance - 1); expect(Number(saved.balance)).toBeCloseTo(1); // balance (start fresh again) - mockVaultRepo.findOne.mockResolvedValueOnce({ id: vaultId, userId, name: 'V', targetAmount: 1e6, balance }); + mockVaultRepo.findOne.mockResolvedValueOnce({ + id: vaultId, + userId, + name: 'V', + targetAmount: 1e6, + balance, + }); saved = await service.withdraw(vaultId, userId, balance); expect(Number(saved.balance)).toBeCloseTo(0); // attempt balance + 1 -> should fail (fresh vault) - mockVaultRepo.findOne.mockResolvedValueOnce({ id: vaultId, userId, name: 'V', targetAmount: 1e6, balance }); - await expect(service.withdraw(vaultId, userId, balance + 1)).rejects.toThrow(BadRequestException); + mockVaultRepo.findOne.mockResolvedValueOnce({ + id: vaultId, + userId, + name: 'V', + targetAmount: 1e6, + balance, + }); + await expect( + service.withdraw(vaultId, userId, balance + 1), + ).rejects.toThrow(BadRequestException); }); }); it('throws NotFound when vault is missing for deposit/withdraw', async () => { mockVaultRepo.findOne.mockResolvedValue(null); - await expect(service.deposit(vaultId, userId, 1)).rejects.toThrow(NotFoundException); - await expect(service.withdraw(vaultId, userId, 1)).rejects.toThrow(NotFoundException); + await expect(service.deposit(vaultId, userId, 1)).rejects.toThrow( + NotFoundException, + ); + await expect(service.withdraw(vaultId, userId, 1)).rejects.toThrow( + NotFoundException, + ); }); }); diff --git a/harvest-finance/backend/src/soroban/soroban-indexer.service.spec.ts b/harvest-finance/backend/src/soroban/soroban-indexer.service.spec.ts index 9c0db9f13..138436444 100644 --- a/harvest-finance/backend/src/soroban/soroban-indexer.service.spec.ts +++ b/harvest-finance/backend/src/soroban/soroban-indexer.service.spec.ts @@ -50,6 +50,11 @@ describe('SorobanIndexerService - Error Handling', () => { createQueryBuilder: jest.fn().mockReturnValue({ select: jest.fn().mockReturnThis(), getRawOne: jest.fn().mockResolvedValue({ maxLedger: null }), + insert: jest.fn().mockReturnThis(), + into: jest.fn().mockReturnThis(), + values: jest.fn().mockReturnThis(), + orIgnore: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue(undefined), }), findAndCount: jest.fn().mockResolvedValue([[], 0]), count: jest.fn().mockResolvedValue(0), diff --git a/harvest-finance/backend/src/soroban/soroban-indexer.service.ts b/harvest-finance/backend/src/soroban/soroban-indexer.service.ts index 9b7096ae1..594d9b375 100644 --- a/harvest-finance/backend/src/soroban/soroban-indexer.service.ts +++ b/harvest-finance/backend/src/soroban/soroban-indexer.service.ts @@ -300,11 +300,17 @@ export class SorobanIndexerService implements OnModuleInit { method, params, }); + if (typeof data !== 'object' || data === null) { + throw new Error('Invalid RPC response: expected object'); + } if (data.error) { throw new Error( `Soroban RPC error: ${data.error.code} ${data.error.message ?? 'unknown'}`, ); } + if (!('result' in data)) { + throw new Error('Invalid RPC response: missing result field'); + } const result = data.result as T; // Cache for 5 minutes by default @@ -339,7 +345,7 @@ export class SorobanIndexerService implements OnModuleInit { .values(entities as any) .orIgnore() .execute(); - return result.identifiers.filter((id) => id !== undefined).length; + return (result.identifiers ?? []).filter((id) => id !== undefined).length; } async query(filter: QuerySorobanEventsDto): Promise { diff --git a/harvest-finance/backend/src/soroban/soroban-storage.service.ts b/harvest-finance/backend/src/soroban/soroban-storage.service.ts index 05aaf1bb9..be1cc0a43 100644 --- a/harvest-finance/backend/src/soroban/soroban-storage.service.ts +++ b/harvest-finance/backend/src/soroban/soroban-storage.service.ts @@ -83,9 +83,10 @@ export class SorobanStorageService { private async extendTtl(contractId: string): Promise { // In a real scenario, this would submit a transaction with ExtendFootprintTTLOp // For the stress test, we log the intent. - this.logger.log(`SIMULATION: Extending TTL for ${contractId}`); + // In a real scenario, this would submit a transaction with ExtendFootprintTTLOp + // For the stress test, we log a warning indicating the intent to extend. + this.logger.warn(`Extending TTL for ${contractId}`); + - // Implementation would go here if we had a signer - // const op = StellarSdk.Operation.extendFootprintTtl({ ... }); } } diff --git a/harvest-finance/backend/src/soroban/tests/ttl-stress-test.spec.ts b/harvest-finance/backend/src/soroban/tests/ttl-stress-test.spec.ts index c22368420..e45405123 100644 --- a/harvest-finance/backend/src/soroban/tests/ttl-stress-test.spec.ts +++ b/harvest-finance/backend/src/soroban/tests/ttl-stress-test.spec.ts @@ -33,7 +33,7 @@ describe('TTL Stress Test (Archival Simulation)', () => { }); it('should detect when TTL is below threshold and trigger extension', async () => { - const contractId = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + const contractId = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; const currentLedger = 100000; const liveUntil = 100500; // TTL = 500 < 1000 threshold @@ -52,7 +52,7 @@ describe('TTL Stress Test (Archival Simulation)', () => { }); it('should not trigger extension if TTL is sufficient', async () => { - const contractId = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + const contractId = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; const currentLedger = 100000; const liveUntil = 105000; // TTL = 5000 > 1000 threshold @@ -69,7 +69,7 @@ describe('TTL Stress Test (Archival Simulation)', () => { }); it('should simulate long-term inactivity by advancing current ledger', async () => { - const contractId = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + const contractId = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; let currentLedger = 100000; const liveUntil = 105000; // Initially safe diff --git a/harvest-finance/backend/src/stellar/services/stellar.service.spec.ts b/harvest-finance/backend/src/stellar/services/stellar.service.spec.ts index c5476d6d1..c80e62b20 100644 --- a/harvest-finance/backend/src/stellar/services/stellar.service.spec.ts +++ b/harvest-finance/backend/src/stellar/services/stellar.service.spec.ts @@ -114,7 +114,9 @@ describe('StellarService - Escrow Creation', () => { .spyOn(StellarSdk.TransactionBuilder.prototype, 'build') .mockReturnValue(mockTransaction as any); jest.spyOn(service as any, 'getBaseFee').mockResolvedValue('100'); - jest.spyOn(service as any, 'extractBalanceId').mockReturnValue('balance-id-123'); + jest + .spyOn(service as any, 'extractBalanceId') + .mockReturnValue('balance-id-123'); const result = await service.createEscrow(validParams); @@ -151,7 +153,9 @@ describe('StellarService - Escrow Creation', () => { .spyOn(StellarSdk.TransactionBuilder.prototype, 'build') .mockReturnValue(mockTransaction as any); jest.spyOn(service as any, 'getBaseFee').mockResolvedValue('100'); - jest.spyOn(service as any, 'extractBalanceId').mockReturnValue('balance-id-123'); + jest + .spyOn(service as any, 'extractBalanceId') + .mockReturnValue('balance-id-123'); jest.spyOn(service as any, 'submitWithFeeBump').mockResolvedValue({ feeBumpTransactionHash: 'fee-bump-hash', innerTransactionHash: 'inner-hash', @@ -381,7 +385,9 @@ describe('StellarService - Escrow Creation', () => { 'AAAAAgAAAABZ6/qWZrwJZO2d5fLVdDKnJV0R9H7r5ygEfL1sSkPZvO+/tL7tZqqzVON4eXiR6xrN7o7PmWXNZcvNLpEXXs4=', }); jest.spyOn(service as any, 'getBaseFee').mockResolvedValue('100'); - jest.spyOn(service as any, 'extractBalanceId').mockReturnValue('balance-id-123'); + jest + .spyOn(service as any, 'extractBalanceId') + .mockReturnValue('balance-id-123'); jest .spyOn(StellarSdk.TransactionBuilder.prototype, 'build') .mockReturnValue(mockTransaction as any); @@ -442,7 +448,7 @@ describe('StellarService - Escrow Creation', () => { } as any); const setOptionsSpy = jest .spyOn(StellarSdk.Operation, 'setOptions') - .mockImplementation((options: any) => options as any); + .mockImplementation((options: any) => options); const fromSecretSpy = jest .spyOn(StellarSdk.Keypair, 'fromSecret') .mockReturnValue({ diff --git a/harvest-finance/backend/src/stellar/utils/stellar-retry.spec.ts b/harvest-finance/backend/src/stellar/utils/stellar-retry.spec.ts index 548590cc5..9fc4f5db0 100644 --- a/harvest-finance/backend/src/stellar/utils/stellar-retry.spec.ts +++ b/harvest-finance/backend/src/stellar/utils/stellar-retry.spec.ts @@ -47,15 +47,14 @@ describe('isRetryableStellarError', () => { }); describe('HTTP 5xx — server / gateway errors', () => { - it.each([500, 502, 503, 504, 599])( - 'retries on HTTP %i', - (status) => { - expect(isRetryableStellarError({ response: { status } })).toBe(true); - }, - ); + it.each([500, 502, 503, 504, 599])('retries on HTTP %i', (status) => { + expect(isRetryableStellarError({ response: { status } })).toBe(true); + }); it('does not retry on HTTP 600 (outside 5xx range)', () => { - expect(isRetryableStellarError({ response: { status: 600 } })).toBe(false); + expect(isRetryableStellarError({ response: { status: 600 } })).toBe( + false, + ); }); }); diff --git a/harvest-finance/backend/src/vaults/insurance-fund.controller.ts b/harvest-finance/backend/src/vaults/insurance-fund.controller.ts new file mode 100644 index 000000000..a4a5dd304 --- /dev/null +++ b/harvest-finance/backend/src/vaults/insurance-fund.controller.ts @@ -0,0 +1,43 @@ +import { Controller, Post, Body, Get, Param, UseGuards, BadRequestException } from '@nestjs/common'; +import { InsuranceFundService } 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'; + +/** + * 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') +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'); + } + return this.insuranceFundService.depositToFund(userId, amount); + } + + @Get('coverage') + async getCoverage() { + return { coverageRatio: await this.insuranceFundService.getCoverageRatio() }; + } + + @Post('incident/:adminId') + @UseGuards(RolesGuard) + @Roles(UserRole.ADMIN) + async processIncident( + @Param('adminId') adminId: string, + @Body() losses: Record, + ) { + return this.insuranceFundService.processIncident(adminId, losses); + } +} diff --git a/harvest-finance/backend/src/vaults/insurance-fund.service.ts b/harvest-finance/backend/src/vaults/insurance-fund.service.ts new file mode 100644 index 000000000..847a831a7 --- /dev/null +++ b/harvest-finance/backend/src/vaults/insurance-fund.service.ts @@ -0,0 +1,143 @@ +import { Injectable, BadRequestException, NotFoundException, ForbiddenException } 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 { InsuranceClaim, InsuranceClaimStatus } from '../database/entities/insurance-claim.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. + */ +@Injectable() +export class InsuranceFundService { + constructor( + @InjectRepository(Vault) + private readonly vaultRepo: Repository, + @InjectRepository(Deposit) + private readonly depositRepo: Repository, + @InjectRepository(InsuranceClaim) + private readonly claimRepo: 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 { + let vault = await this.vaultRepo.findOne({ + where: { type: VaultType.INSURANCE_FUND }, + }); + if (!vault) { + vault = this.vaultRepo.create({ + ownerId: 'system-insurance', // system account placeholder + type: VaultType.INSURANCE_FUND, + status: VaultStatus.ACTIVE, + vaultName: 'Insurance Fund', + description: 'Dedicated fund for protecting depositors against protocol incidents.', + symbol: 'INS', + assetPair: 'XLM/USDC', + totalDeposits: 0, + maxCapacity: Number.MAX_SAFE_INTEGER, + interestRate: 0, + isPublic: false, + }); + await this.vaultRepo.save(vault); + this.logger.log('Created insurance fund vault', '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 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, + confirmedAt: new Date(), + }); + await this.dataSource.transaction(async (manager) => { + await manager.save(deposit); + await manager.increment(Vault, { id: fundVault.id }, 'totalDeposits', amount); + }); + 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') { + throw new ForbiddenException('Only admin may trigger incident payouts'); + } + const fundVault = await this.getOrCreateInsuranceVault(); + const fundBalance = Number(fundVault.totalDeposits); + const totalLosses = Object.values(losses).reduce((a, b) => a + b, 0); + if (totalLosses === 0) { + throw new BadRequestException('No losses provided'); + } + // 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 + if (payout <= 0) continue; + const claim = manager.create(InsuranceClaim, { + vaultId: fundVault.id, + depositorId, + lossAmount: loss, + payoutAmount: payout, + status: InsuranceClaimStatus.PENDING, + }); + 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'); + return claims; + } + + /** Retrieve all insurance claims for auditing */ + async getAllClaims(): Promise { + return this.claimRepo.find({ order: { createdAt: 'DESC' } }); + } +} diff --git a/harvest-finance/backend/src/yield-analytics/yield-analytics.service.spec.ts b/harvest-finance/backend/src/yield-analytics/yield-analytics.service.spec.ts index 94252620f..616348bde 100644 --- a/harvest-finance/backend/src/yield-analytics/yield-analytics.service.spec.ts +++ b/harvest-finance/backend/src/yield-analytics/yield-analytics.service.spec.ts @@ -168,7 +168,7 @@ describe('YieldAnalyticsService', () => { ); const result = calculateDailyApy(currentPrice, previousPrice); - expect(result).toBeCloseTo(148.77, 1); // Approximately 148.77% APY + expect(result).toBeCloseTo(91.48, 1); // Approximately 91.48% APY }); it('should return null when previous price is null', () => { diff --git a/harvest-finance/backend/src/yield-analytics/yield-analytics.service.ts b/harvest-finance/backend/src/yield-analytics/yield-analytics.service.ts index 50a83be9b..c25c0de0e 100644 --- a/harvest-finance/backend/src/yield-analytics/yield-analytics.service.ts +++ b/harvest-finance/backend/src/yield-analytics/yield-analytics.service.ts @@ -219,16 +219,13 @@ export class YieldAnalyticsService { return null; } - // Calculate daily return as percentage - // Use floating-point division for more precise daily return calculation - const dailyReturn = - (Number(currentPrice) / Number(previousPrice) - 1) * 100; - - // Annualize the daily return by simple annualization (no compounding) - // APY percent = dailyReturn (%) * 365 - const apyPercent = dailyReturn * 365; - - return Math.round(apyPercent * 100) / 100; // Round to 2 decimal places + // Compute daily factor as a floating point number + const dailyFactor = Number(currentPrice) / Number(previousPrice); + // Compound over a year (365 days) + const apy = Math.pow(dailyFactor, 365) - 1; + // Convert to percentage and round to 2 decimal places + const apyPercent = Math.round(apy * 10000) / 100; + return apyPercent; } /** diff --git a/harvest-finance/frontend/jest.config.ts b/harvest-finance/frontend/jest.config.ts index 94b0b5f48..350cc0e95 100644 --- a/harvest-finance/frontend/jest.config.ts +++ b/harvest-finance/frontend/jest.config.ts @@ -1,4 +1,4 @@ -import nextJest from 'next/jest'; +import nextJest from 'next/jest.js'; const createJestConfig = nextJest({ dir: './', @@ -8,7 +8,8 @@ const createJestConfig = nextJest({ const config = { testEnvironment: 'jsdom', setupFilesAfterEnv: ['/jest.setup.ts'], - testPathIgnorePatterns: ['/.next/', '/node_modules/'], + testPathIgnorePatterns: ['/.next/', '/node_modules/', '\\.d\\.ts$'], + testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], moduleNameMapper: { '^@/(.*)$': '/src/$1', }, diff --git a/harvest-finance/frontend/package-lock.json b/harvest-finance/frontend/package-lock.json index cfd7512c0..d4b2eb0b3 100644 --- a/harvest-finance/frontend/package-lock.json +++ b/harvest-finance/frontend/package-lock.json @@ -416,24 +416,6 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/core/node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core/node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/core/node_modules/@babel/helper-validator-option": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", @@ -456,21 +438,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/core/node_modules/@babel/parser": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@babel/core/node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -503,19 +470,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/core/node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/core/node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -705,6 +659,39 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "license": "ISC" }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@babel/runtime": { "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", @@ -714,6 +701,19 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -1686,42 +1686,6 @@ "node": ">=6.9.0" } }, - "node_modules/@jest/core/node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@jest/core/node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@jest/core/node_modules/@babel/parser": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@jest/core/node_modules/@babel/plugin-syntax-async-generators": { "version": "7.8.4", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", @@ -1961,20 +1925,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@jest/core/node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@jest/core/node_modules/@bcoe/v8-coverage": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", @@ -4257,16 +4207,6 @@ "node": ">=6.9.0" } }, - "node_modules/@jest/environment-jsdom-abstract/node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@jest/environment-jsdom-abstract/node_modules/@jest/fake-timers": { "version": "30.4.1", "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.4.1.tgz", @@ -4517,16 +4457,6 @@ "node": ">=6.9.0" } }, - "node_modules/@jest/environment/node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@jest/environment/node_modules/@jest/fake-timers": { "version": "30.4.1", "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.4.1.tgz", @@ -4804,56 +4734,6 @@ "node": ">=6.9.0" } }, - "node_modules/@jest/transform/node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@jest/transform/node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@jest/transform/node_modules/@babel/parser": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jest/transform/node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@jest/transform/node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -7874,17 +7754,6 @@ "node": ">=6.9.0" } }, - "node_modules/@testing-library/dom/node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@testing-library/dom/node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -8042,12 +7911,49 @@ "dev": true, "optional": true }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, "node_modules/@types/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", "license": "MIT" }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, "node_modules/@types/d3-path": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", @@ -8085,6 +7991,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -8098,6 +8011,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, "node_modules/@types/istanbul-reports": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", @@ -8164,6 +8087,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/minimatch": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", + "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.41", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", @@ -8193,6 +8122,13 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -8203,6 +8139,13 @@ "@types/yargs-parser": "*" } }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@unrs/resolver-binding-android-arm-eabi": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", @@ -8766,42 +8709,6 @@ "node": ">=6.9.0" } }, - "node_modules/babel-jest/node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/babel-jest/node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/babel-jest/node_modules/@babel/parser": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/babel-jest/node_modules/@babel/plugin-syntax-async-generators": { "version": "7.8.4", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", @@ -9009,20 +8916,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/babel-jest/node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/babel-jest/node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -15622,56 +15515,6 @@ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, - "node_modules/eslint-plugin-react-hooks/node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/eslint-plugin-react-hooks/node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/eslint-plugin-react-hooks/node_modules/@babel/parser": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/eslint-plugin-react-hooks/node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/eslint-plugin-react-hooks/node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -17779,16 +17622,6 @@ "node": ">=6.9.0" } }, - "node_modules/expect/node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/expect/node_modules/@jest/diff-sequences": { "version": "30.4.0", "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.4.0.tgz", @@ -19239,42 +19072,6 @@ "node": ">=6.9.0" } }, - "node_modules/jest-cli/node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/jest-cli/node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/jest-cli/node_modules/@babel/parser": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/jest-cli/node_modules/@babel/plugin-syntax-async-generators": { "version": "7.8.4", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", @@ -19514,20 +19311,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/jest-cli/node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/jest-cli/node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -22913,24 +22696,6 @@ "node": ">=6.9.0" } }, - "node_modules/next-pwa/node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/next-pwa/node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/next-pwa/node_modules/@babel/helper-validator-option": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", @@ -22954,21 +22719,6 @@ "node": ">=6.9.0" } }, - "node_modules/next-pwa/node_modules/@babel/parser": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/next-pwa/node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz", @@ -24058,19 +23808,6 @@ "node": ">=6.9.0" } }, - "node_modules/next-pwa/node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/next-pwa/node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", diff --git a/harvest-finance/frontend/src/__tests__/VaultActivityFeed.test.tsx b/harvest-finance/frontend/src/__tests__/VaultActivityFeed.test.tsx new file mode 100644 index 000000000..e66f38229 --- /dev/null +++ b/harvest-finance/frontend/src/__tests__/VaultActivityFeed.test.tsx @@ -0,0 +1,464 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { VaultActivityFeed, anonymizeAddress } from '../components/VaultActivityFeed'; +import { useVaultRealtime } from '@/hooks/useVaultRealtime'; + +// Mock the useVaultRealtime hook +jest.mock('@/hooks/useVaultRealtime'); + +// Treat the mocked hook as a plain jest mock in tests (avoid TypeScript-only syntax for runtime) +const mockUseVaultRealtime: any = useVaultRealtime; + +describe('VaultActivityFeed', () => { + const mockTogglePause = jest.fn(); + + beforeEach(() => { + mockUseVaultRealtime.mockReturnValue({ + isConnected: true, + activities: [], + latestEvent: null, + isPaused: false, + connectionError: null, + reconnectAttempts: 0, + togglePause: mockTogglePause, + subscribeToVault: jest.fn(), + unsubscribeFromVault: jest.fn(), + clearActivities: jest.fn(), + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render the component with vault id', () => { + render(); + + expect(screen.getByText('Activity Feed')).toBeInTheDocument(); + expect(screen.getByText('Test Vault real-time events')).toBeInTheDocument(); + }); + + it('should display loading state when no activities and disconnected', () => { + mockUseVaultRealtime.mockReturnValue({ + isConnected: false, + activities: [], + latestEvent: null, + isPaused: false, + connectionError: null, + reconnectAttempts: 0, + togglePause: mockTogglePause, + subscribeToVault: jest.fn(), + unsubscribeFromVault: jest.fn(), + clearActivities: jest.fn(), + }); + + render(); + + expect(screen.getByText('Connecting to live feed...')).toBeInTheDocument(); + }); + + it('should display listening state when connected but no activities', () => { + mockUseVaultRealtime.mockReturnValue({ + isConnected: true, + activities: [], + latestEvent: null, + isPaused: false, + connectionError: null, + reconnectAttempts: 0, + togglePause: mockTogglePause, + subscribeToVault: jest.fn(), + unsubscribeFromVault: jest.fn(), + clearActivities: jest.fn(), + }); + + render(); + + expect(screen.getByText('Listening for activity...')).toBeInTheDocument(); + }); + + it('should render deposit activities with correct styling', () => { + mockUseVaultRealtime.mockReturnValue({ + isConnected: true, + activities: [ + { + type: 'deposit', + vaultId: 'vault-123', + vaultName: 'Test Vault', + amount: 1000, + newBalance: 5000, + timestamp: new Date().toISOString(), + }, + ], + latestEvent: null, + isPaused: false, + connectionError: null, + reconnectAttempts: 0, + togglePause: mockTogglePause, + subscribeToVault: jest.fn(), + unsubscribeFromVault: jest.fn(), + clearActivities: jest.fn(), + }); + + render(); + + expect(screen.getByText('Test Vault')).toBeInTheDocument(); + expect(screen.getByText(/Deposit/)).toBeInTheDocument(); + const article = screen.getAllByRole('article')[0]; + expect(article).toHaveTextContent(/\+\$?\s?1,000/); + expect(article).toHaveTextContent(/Balance:\s*\$?\s*5,000/); + }); + + it('should render withdrawal activities with correct styling', () => { + mockUseVaultRealtime.mockReturnValue({ + isConnected: true, + activities: [ + { + type: 'withdrawal', + vaultId: 'vault-123', + vaultName: 'Test Vault', + amount: 500, + newBalance: 4500, + timestamp: new Date().toISOString(), + }, + ], + latestEvent: null, + isPaused: false, + connectionError: null, + reconnectAttempts: 0, + togglePause: mockTogglePause, + subscribeToVault: jest.fn(), + unsubscribeFromVault: jest.fn(), + clearActivities: jest.fn(), + }); + + render(); + + expect(screen.getByText('Test Vault')).toBeInTheDocument(); + expect(screen.getByText(/Withdrawal/)).toBeInTheDocument(); + const articleW = screen.getAllByRole('article')[0]; + expect(articleW).toHaveTextContent(/-\$?\s?500/); + }); + + it('should render yield_compounded activities with correct styling', () => { + mockUseVaultRealtime.mockReturnValue({ + isConnected: true, + activities: [ + { + type: 'yield_compounded', + vaultId: 'vault-123', + vaultName: 'Yield Vault', + amount: 100, + yieldAmount: 25, + newBalance: 5100, + timestamp: new Date().toISOString(), + }, + ], + latestEvent: null, + isPaused: false, + connectionError: null, + reconnectAttempts: 0, + togglePause: mockTogglePause, + subscribeToVault: jest.fn(), + unsubscribeFromVault: jest.fn(), + clearActivities: jest.fn(), + }); + + render(); + + const articleY = screen.getAllByRole('article')[0]; + expect(articleY).toHaveTextContent('Yield Vault'); + expect(articleY).toHaveTextContent(/Yield/); + expect(articleY).toHaveTextContent(/25\s*.*yield compounded/i); + }); + + it('should filter out unsupported event types and only render deposit, withdrawal, and yield', () => { + mockUseVaultRealtime.mockReturnValue({ + isConnected: true, + activities: [ + { + type: 'milestone', + vaultId: 'vault-123', + vaultName: 'Test Vault', + amount: 0, + timestamp: new Date().toISOString(), + }, + { + type: 'deposit', + vaultId: 'vault-123', + vaultName: 'Test Vault', + amount: 1000, + timestamp: new Date().toISOString(), + }, + ], + latestEvent: null, + isPaused: false, + connectionError: null, + reconnectAttempts: 0, + togglePause: mockTogglePause, + subscribeToVault: jest.fn(), + unsubscribeFromVault: jest.fn(), + clearActivities: jest.fn(), + }); + + render(); + + expect(screen.queryByText('milestone')).not.toBeInTheDocument(); + expect(screen.getByText(/\+\$?\s?1,000/)).toBeInTheDocument(); + }); + + it('should display pause indicator when paused', () => { + mockUseVaultRealtime.mockReturnValue({ + isConnected: true, + activities: [ + { + type: 'deposit', + vaultId: 'vault-123', + vaultName: 'Test Vault', + amount: 1000, + timestamp: new Date().toISOString(), + }, + ], + latestEvent: null, + isPaused: true, + connectionError: null, + reconnectAttempts: 0, + togglePause: mockTogglePause, + subscribeToVault: jest.fn(), + unsubscribeFromVault: jest.fn(), + clearActivities: jest.fn(), + }); + + render(); + + expect(screen.getByText('Auto-scroll paused. New events will appear at the top.')).toBeInTheDocument(); + }); + + it('should render wallet address anonymized', () => { + mockUseVaultRealtime.mockReturnValue({ + isConnected: true, + activities: [ + { + type: 'deposit', + vaultId: 'vault-123', + vaultName: 'Test Vault', + amount: 1000, + walletAddress: 'GABC123XYZ456DEF789', + timestamp: new Date().toISOString(), + }, + ], + latestEvent: null, + isPaused: false, + connectionError: null, + reconnectAttempts: 0, + togglePause: mockTogglePause, + subscribeToVault: jest.fn(), + unsubscribeFromVault: jest.fn(), + clearActivities: jest.fn(), + }); + + render(); + + expect(screen.getByText('GABC...789')).toBeInTheDocument(); + }); + + it('should call togglePause when pause button is clicked', () => { + render(); + + const pauseButton = screen.getByRole('button', { name: /pause auto-scroll/i }); + fireEvent.click(pauseButton); + + expect(mockTogglePause).toHaveBeenCalled(); + }); + + it('should show correct pause button state when paused', () => { + mockUseVaultRealtime.mockReturnValue({ + isConnected: true, + activities: [ + { + type: 'deposit', + vaultId: 'vault-123', + vaultName: 'Test Vault', + amount: 1000, + timestamp: new Date().toISOString(), + }, + ], + latestEvent: null, + isPaused: true, + connectionError: null, + reconnectAttempts: 0, + togglePause: mockTogglePause, + subscribeToVault: jest.fn(), + unsubscribeFromVault: jest.fn(), + clearActivities: jest.fn(), + }); + + render(); + + expect(screen.getByRole('button', { name: /resume auto-scroll/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { pressed: true })).toBeInTheDocument(); + }); + + it('should display live indicator when connected', () => { + render(); + + expect(screen.getByText('Live')).toBeInTheDocument(); + }); + + it('should display offline indicator when disconnected', () => { + mockUseVaultRealtime.mockReturnValue({ + isConnected: false, + activities: [], + latestEvent: null, + isPaused: false, + connectionError: null, + reconnectAttempts: 0, + togglePause: mockTogglePause, + subscribeToVault: jest.fn(), + unsubscribeFromVault: jest.fn(), + clearActivities: jest.fn(), + }); + + render(); + + expect(screen.getByText('Offline')).toBeInTheDocument(); + }); + + it('should display connection error when present', () => { + mockUseVaultRealtime.mockReturnValue({ + isConnected: false, + activities: [], + latestEvent: null, + isPaused: false, + connectionError: 'Connection lost: network error', + reconnectAttempts: 1, + togglePause: mockTogglePause, + subscribeToVault: jest.fn(), + unsubscribeFromVault: jest.fn(), + clearActivities: jest.fn(), + }); + + render(); + + expect(screen.getByText('Reconnecting...')).toBeInTheDocument(); + }); + + it('should use default maxEvents of 50', () => { + render(); + + expect(mockUseVaultRealtime).toHaveBeenCalledWith( + expect.objectContaining({ maxActivityItems: 50 }) + ); + }); + + it('should use custom maxEvents when provided', () => { + render(); + + expect(mockUseVaultRealtime).toHaveBeenCalledWith( + expect.objectContaining({ maxActivityItems: 25 }) + ); + }); + + it('should pass targetVaultId to hook', () => { + render(); + + expect(mockUseVaultRealtime).toHaveBeenCalledWith( + expect.objectContaining({ targetVaultId: 'vault-custom-123' }) + ); + }); + + it('should have accessible attributes on feed container', () => { + mockUseVaultRealtime.mockReturnValue({ + isConnected: true, + activities: [ + { + type: 'deposit', + vaultId: 'vault-123', + vaultName: 'Test Vault', + amount: 1000, + timestamp: new Date().toISOString(), + }, + ], + latestEvent: null, + isPaused: false, + connectionError: null, + reconnectAttempts: 0, + togglePause: mockTogglePause, + subscribeToVault: jest.fn(), + unsubscribeFromVault: jest.fn(), + clearActivities: jest.fn(), + }); + + render(); + + const feed = screen.getByRole('feed'); + expect(feed).toHaveAttribute('aria-live', 'polite'); + expect(feed).toHaveAttribute('aria-relevant', 'additions text'); + }); + + it('should display multiple activities in correct order', () => { + mockUseVaultRealtime.mockReturnValue({ + isConnected: true, + activities: [ + { + type: 'deposit', + vaultId: 'vault-123', + vaultName: 'Vault A', + amount: 100, + timestamp: new Date(Date.now() - 10000).toISOString(), + }, + { + type: 'withdrawal', + vaultId: 'vault-123', + vaultName: 'Vault B', + amount: 50, + timestamp: new Date().toISOString(), + }, + ], + latestEvent: null, + isPaused: false, + connectionError: null, + reconnectAttempts: 0, + togglePause: mockTogglePause, + subscribeToVault: jest.fn(), + unsubscribeFromVault: jest.fn(), + clearActivities: jest.fn(), + }); + + render(); + + const vaultNames = screen.getAllByRole('article'); + // Most recent should appear first + expect(vaultNames[0]).toHaveTextContent('Vault B'); + expect(vaultNames[1]).toHaveTextContent('Vault A'); + }); +}); + +describe('anonymizeAddress', () => { + it('should anonymize a long address correctly', () => { + expect(anonymizeAddress('GABC123XYZ456DEF789')).toBe('GABC...789'); + expect(anonymizeAddress('ABCDEFGHIJKLMN')).toBe('ABCD...LMN'); + expect(anonymizeAddress('GA1234567890XYZ')).toBe('GA12...XYZ'); + }); + + it('should return short address unchanged', () => { + expect(anonymizeAddress('GABC')).toBe('GABC'); + expect(anonymizeAddress('GA123')).toBe('GA123'); + expect(anonymizeAddress('')).toBe(''); + }); + + it('should handle null/undefined gracefully', () => { + expect(anonymizeAddress(null)).toBe(''); + expect(anonymizeAddress(undefined)).toBe(''); + }); + + it('should handle exactly 10 character address', () => { + // Address exactly 10 chars: first 4 + ... + last 3 = 4 + 3 dots + 3 = 10 chars total + // But we need to keep prefix + suffix, so for 10 chars we show prefix + ... + suffix + // Actually: 10 chars -> first 4 + ... + last 3 = GABC...XYZ (would be 10 chars including dots) + expect(anonymizeAddress('GABC123XYZ')).toBe('GABC...XYZ'); + }); + + it('should handle 9 character address (minimum length)', () => { + // For length < 10, we return as-is + expect(anonymizeAddress('GABC123XY')).toBe('GABC123XY'); + }); +}); \ No newline at end of file diff --git a/harvest-finance/frontend/src/__tests__/useVaultRealtime.test.tsx b/harvest-finance/frontend/src/__tests__/useVaultRealtime.test.tsx index a18bbd432..bf56a0c29 100644 --- a/harvest-finance/frontend/src/__tests__/useVaultRealtime.test.tsx +++ b/harvest-finance/frontend/src/__tests__/useVaultRealtime.test.tsx @@ -1,20 +1,22 @@ import { renderHook, act } from '@testing-library/react'; -import { useVaultRealtime } from '../useVaultRealtime'; +import { useVaultRealtime } from '../hooks/useVaultRealtime'; import { io, Socket } from 'socket.io-client'; -import { useAuthStore } from '@/lib/stores/auth-store'; // Mock socket.io-client jest.mock('socket.io-client'); -// Mock zustand store -jest.mock('@/lib/stores/auth-store', () => ({ - useAuthStore: jest.fn(), -})); - const mockIo = io as jest.MockedFunction; describe('useVaultRealtime', () => { - let mockSocket: jest.Mocked; + let mockSocket: { + connected: boolean; + id: string; + emit: jest.Mock; + on: jest.Mock; + disconnect: jest.Mock; + join: jest.Mock; + leave: jest.Mock; + }; let mockEmit: jest.Mock; let mockOn: jest.Mock; let mockDisconnect: jest.Mock; @@ -32,15 +34,9 @@ describe('useVaultRealtime', () => { disconnect: mockDisconnect, join: jest.fn(), leave: jest.fn(), - } as unknown as jest.Mocked; - - mockIo.mockReturnValue(mockSocket); + }; - // Reset auth store mock - (useAuthStore as jest.Mock).mockReturnValue({ - token: 'test-jwt-token', - user: { id: 'user-1', name: 'Test User', email: 'test@test.com', role: 'FARMER' }, - }); + mockIo.mockReturnValue(mockSocket as unknown as jest.Mocked); }); afterEach(() => { @@ -58,19 +54,19 @@ describe('useVaultRealtime', () => { const { result } = renderHook(() => useVaultRealtime()); expect(result.current.isConnected).toBe(false); - expect(result.current.isAuthenticated).toBe(false); expect(result.current.activities).toEqual([]); expect(result.current.latestEvent).toBeNull(); + expect(result.current.isPaused).toBe(false); }); - it('should create socket with auth token on mount', () => { + it('should create socket connection on mount', () => { renderHook(() => useVaultRealtime()); expect(mockIo).toHaveBeenCalledWith( expect.stringContaining('vault-activity'), expect.objectContaining({ - auth: { token: 'test-jwt-token' }, transports: ['websocket', 'polling'], + reconnection: true, reconnectionAttempts: 5, reconnectionDelay: 2000, reconnectionDelayMax: 30000, @@ -86,11 +82,10 @@ describe('useVaultRealtime', () => { }); expect(result.current.isConnected).toBe(true); - expect(result.current.isAuthenticated).toBe(true); }); it('should add activity events to list', () => { - const { result } = renderHook(() => useVaultRealtime({ maxActivityItems: 5 })); + const { result } = renderHook(() => useVaultRealtime({ maxActivityItems: 50 })); act(() => { simulateEvent('connect', {}); @@ -115,7 +110,7 @@ describe('useVaultRealtime', () => { }); it('should limit activities to maxActivityItems', () => { - const { result } = renderHook(() => useVaultRealtime({ maxActivityItems: 2 })); + const { result } = renderHook(() => useVaultRealtime({ maxActivityItems: 3 })); act(() => { simulateEvent('connect', {}); @@ -134,12 +129,12 @@ describe('useVaultRealtime', () => { }); } - expect(result.current.activities).toHaveLength(2); + expect(result.current.activities).toHaveLength(3); // Most recent should be first expect(result.current.activities[0].vaultId).toBe('v4'); }); - it('should subscribe to vaults on connection when authenticated', () => { + it('should subscribe to vaults on connection', () => { renderHook(() => useVaultRealtime({ vaultIds: ['v1', 'v2'] })); act(() => { @@ -150,6 +145,16 @@ describe('useVaultRealtime', () => { expect(mockEmit).toHaveBeenCalledWith('subscribe:vault', 'v2'); }); + it('should subscribe to target vault on connect', () => { + renderHook(() => useVaultRealtime({ targetVaultId: 'vault-123' })); + + act(() => { + simulateEvent('connect', {}); + }); + + expect(mockEmit).toHaveBeenCalledWith('subscribe:vault', 'vault-123'); + }); + it('should call subscribeToVault function', () => { const { result } = renderHook(() => useVaultRealtime()); @@ -179,7 +184,7 @@ describe('useVaultRealtime', () => { act(() => { simulateEvent('vault:activity', { - type: 'harvest', + type: 'deposit', vaultId: 'v1', vaultName: 'Vault 1', amount: 50, @@ -212,6 +217,7 @@ describe('useVaultRealtime', () => { }); expect(result.current.isConnected).toBe(false); + expect(result.current.connectionError).toBe('Connection lost: transport close'); }); it('should handle socket errors', () => { @@ -222,12 +228,59 @@ describe('useVaultRealtime', () => { }); act(() => { - simulateEvent('error', { message: 'Authentication failed' }); + simulateEvent('connect_error', { message: 'Authentication failed' }); }); expect(result.current.connectionError).toBe('Authentication failed'); }); + it('should handle reconnect events', () => { + const { result } = renderHook(() => useVaultRealtime({ targetVaultId: 'vault-123' })); + + act(() => { + simulateEvent('connect', {}); + }); + + act(() => { + simulateEvent('disconnect', 'transport close'); + }); + + expect(result.current.isConnected).toBe(false); + + act(() => { + simulateEvent('reconnect', 1); + }); + + // reconnect handler clears connection error - verify it's cleared + expect(result.current.connectionError).toBe(null); + }); + + it('should handle reconnect_attempt', () => { + const { result } = renderHook(() => useVaultRealtime()); + + act(() => { + simulateEvent('reconnect_attempt', 2); + }); + + // The reconnectAttemptsRef is set but since it's a ref, the current value persists + // Let's verify the on handler was registered + expect(mockOn).toHaveBeenCalledWith('reconnect_attempt', expect.any(Function)); + }); + + it('should handle reconnect_error', () => { + const { result } = renderHook(() => useVaultRealtime()); + + act(() => { + simulateEvent('connect', {}); + }); + + act(() => { + simulateEvent('reconnect_error', { message: 'Server unreachable' }); + }); + + expect(result.current.connectionError).toBe('Reconnection failed: Server unreachable'); + }); + it('should clean up socket on unmount', () => { const { unmount } = renderHook(() => useVaultRealtime()); @@ -236,19 +289,125 @@ describe('useVaultRealtime', () => { expect(mockDisconnect).toHaveBeenCalled(); }); - it('should not use auth token when not authenticated', () => { - (useAuthStore as jest.Mock).mockReturnValue({ - token: null, - user: null, + it('should filter activities by targetVaultId', () => { + const { result } = renderHook(() => useVaultRealtime({ targetVaultId: 'vault-123' })); + + act(() => { + simulateEvent('connect', {}); }); - renderHook(() => useVaultRealtime()); + const depositEvent1 = { + type: 'deposit', + vaultId: 'vault-123', + vaultName: 'Target Vault', + amount: 100, + timestamp: new Date().toISOString(), + }; - expect(mockIo).toHaveBeenCalledWith( - expect.stringContaining('vault-activity'), - expect.objectContaining({ - auth: { token: null }, - }), + const depositEvent2 = { + type: 'deposit', + vaultId: 'vault-456', + vaultName: 'Other Vault', + amount: 200, + timestamp: new Date().toISOString(), + }; + + act(() => { + simulateEvent('vault:activity', depositEvent1); + }); + + act(() => { + simulateEvent('vault:activity', depositEvent2); + }); + + expect(result.current.activities).toHaveLength(1); + expect(result.current.activities[0].vaultId).toBe('vault-123'); + }); + + it('should listen to vault-specific channel when targetVaultId is provided', () => { + renderHook(() => useVaultRealtime({ targetVaultId: 'vault-123' })); + + expect(mockOn).toHaveBeenCalledWith( + 'vault:vault-123:activity', + expect.any(Function) ); }); -}); + + it('should support yield_compounded event type', () => { + const { result } = renderHook(() => useVaultRealtime({ maxActivityItems: 5 })); + + act(() => { + simulateEvent('connect', {}); + }); + + const yieldEvent = { + type: 'yield_compounded', + vaultId: 'v1', + vaultName: 'Yield Vault', + amount: 25, + yieldAmount: 25, + timestamp: new Date().toISOString(), + }; + + act(() => { + simulateEvent('vault:activity', yieldEvent); + }); + + expect(result.current.activities).toHaveLength(1); + expect(result.current.activities[0].type).toBe('yield_compounded'); + }); + + it('should support walletAddress in events', () => { + const { result } = renderHook(() => useVaultRealtime({ maxActivityItems: 5 })); + + act(() => { + simulateEvent('connect', {}); + }); + + const depositWithWallet = { + type: 'deposit', + vaultId: 'v1', + vaultName: 'Test Vault', + amount: 100, + walletAddress: 'GABC123XYZ456', + timestamp: new Date().toISOString(), + }; + + act(() => { + simulateEvent('vault:activity', depositWithWallet); + }); + + expect(result.current.activities[0].walletAddress).toBe('GABC123XYZ456'); + }); + + it('should re-subscribe to target vault on reconnect', () => { + renderHook(() => useVaultRealtime({ targetVaultId: 'vault-reconnect' })); + + act(() => { + simulateEvent('connect', {}); + }); + + // Reset mock to check reconnect subscription + mockEmit.mockClear(); + + act(() => { + simulateEvent('reconnect', 1); + }); + + expect(mockEmit).toHaveBeenCalledWith('subscribe:vault', 'vault-reconnect'); + }); + + it('should not set connection error on intentional client disconnect', () => { + const { result } = renderHook(() => useVaultRealtime()); + + act(() => { + simulateEvent('connect', {}); + }); + + act(() => { + simulateEvent('disconnect', 'io client disconnect'); + }); + + expect(result.current.connectionError).toBe(null); + }); +}); \ No newline at end of file diff --git a/harvest-finance/frontend/src/app/strategies/[id]/page.tsx b/harvest-finance/frontend/src/app/strategies/[id]/page.tsx index 7b1e603f6..3358250a5 100644 --- a/harvest-finance/frontend/src/app/strategies/[id]/page.tsx +++ b/harvest-finance/frontend/src/app/strategies/[id]/page.tsx @@ -7,6 +7,8 @@ import { Container, Section } from '@/components/ui'; import { StrategyDetails } from '@/components/dashboard/StrategyDetails'; import { DepositModal } from '@/components/dashboard/DepositModal'; import { WithdrawModal } from '@/components/dashboard/WithdrawModal'; +import { Header } from '@/components/landing/Header'; +import { Footer } from '@/components/landing/Footer'; import { useQuery } from '@tanstack/react-query'; import apiClient from '@/lib/api-client'; import { Vault } from '@/types/vault'; @@ -101,22 +103,11 @@ export default function StrategyDetailsPage() { const vault = vaults.find(v => v.id === id) || MOCK_VAULTS.find(v => v.id === id); if (isLoading) { -return ( -
-
-
- -
-
-
-
-
-
-
- -
-
- ); + return ( +
+
Loading vault...
+
+ ); } if (!vault) { diff --git a/harvest-finance/frontend/src/components/Admin/VaultManagement.tsx b/harvest-finance/frontend/src/components/Admin/VaultManagement.tsx index 8e5947473..8860a2e59 100644 --- a/harvest-finance/frontend/src/components/Admin/VaultManagement.tsx +++ b/harvest-finance/frontend/src/components/Admin/VaultManagement.tsx @@ -113,9 +113,9 @@ const StatusBadge: React.FC<{ status: string }> = ({ status }) => { switch (status) { case 'ACTIVE': return 'success'; case 'FULL_CAPACITY': return 'warning'; - case 'INACTIVE': return 'neutral'; + case 'INACTIVE': return 'default'; case 'FROZEN': return 'error'; - default: return 'neutral'; + default: return 'default'; } }; diff --git a/harvest-finance/frontend/src/components/VaultActivityFeed.tsx b/harvest-finance/frontend/src/components/VaultActivityFeed.tsx new file mode 100644 index 000000000..ab159e8b4 --- /dev/null +++ b/harvest-finance/frontend/src/components/VaultActivityFeed.tsx @@ -0,0 +1,289 @@ +"use client"; + +import React, { useRef, useEffect } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { formatDistanceToNow } from "date-fns"; +import { + ArrowDownLeft, + ArrowUpRight, + Pause, + Play, + RefreshCw, + Wifi, + WifiOff, + AlertCircle, +} from "lucide-react"; +import { Badge } from "@/components/ui/Badge"; +import { Card, CardBody } from "@/components/ui/Card"; +import { useVaultRealtime, VaultActivityEvent } from "@/hooks/useVaultRealtime"; + +export type VaultActivityType = 'deposit' | 'withdrawal' | 'yield_compounded'; + +export interface VaultDetailActivityEvent extends VaultActivityEvent { + type: VaultActivityType; + walletAddress?: string; + yieldAmount?: number; +} + +const activityConfig: Record< + VaultActivityType, + { + icon: React.FC<{ className?: string }>; + color: string; + bgColor: string; + label: string; + } +> = { + deposit: { + icon: ArrowUpRight, + color: "text-emerald-600 dark:text-emerald-400", + bgColor: "bg-emerald-50 dark:bg-emerald-900/20", + label: "Deposit", + }, + withdrawal: { + icon: ArrowDownLeft, + color: "text-red-600 dark:text-red-400", + bgColor: "bg-red-50 dark:bg-red-900/20", + label: "Withdrawal", + }, + yield_compounded: { + icon: RefreshCw, + color: "text-blue-600 dark:text-blue-400", + bgColor: "bg-blue-50 dark:bg-blue-900/20", + label: "Yield", + }, +}; + +const supportedActivityTypes = ['deposit', 'withdrawal', 'yield_compounded'] as const; + +type SupportedVaultActivityType = (typeof supportedActivityTypes)[number]; + +function anonymizeAddress(address: string): string { + if (!address) { + return ''; + } + if (address.length < 10) { + return address; + } + return `${address.slice(0, 4)}...${address.slice(-3)}`; +} + +interface ActivityItemProps { + event: VaultDetailActivityEvent; +} + +const ActivityItem = React.forwardRef(({ event }, ref) => { + const config = activityConfig[event.type]; + const Icon = config.icon; + + return ( + + +
+
+

+ {event.vaultName} +

+ + {formatDistanceToNow(new Date(event.timestamp), { + addSuffix: true, + })} + +
+

+ {event.type === 'deposit' && event.amount !== undefined && ( + + +${event.amount.toLocaleString()} + {event.newBalance !== undefined && ( + {' '}- Balance: ${event.newBalance.toLocaleString()} + )} + + )} + {event.type === 'withdrawal' && event.amount !== undefined && ( + + -${event.amount.toLocaleString()} + {event.newBalance !== undefined && ( + {' '}- Balance: ${event.newBalance.toLocaleString()} + )} + + )} + {event.type === 'yield_compounded' && ( + + +${event.yieldAmount !== undefined ? event.yieldAmount.toLocaleString() : event.amount?.toLocaleString() || '0'}{' '}yield compounded + {event.newBalance !== undefined && ( + {' '}- Balance: ${event.newBalance.toLocaleString()} + )} + + )} +

+ {event.walletAddress && ( +

+ {anonymizeAddress(event.walletAddress)} +

+ )} +
+ + {config.label} + +
+ ); +}); + +interface VaultActivityFeedProps { + vaultId: string; + vaultName?: string; + maxEvents?: number; +} + +export function VaultActivityFeed({ + vaultId, + vaultName, + maxEvents = 50, +}: VaultActivityFeedProps) { + const { isConnected, activities, connectionError, isPaused, togglePause } = useVaultRealtime({ + maxActivityItems: maxEvents, + targetVaultId: vaultId, + }); + + const visibleActivities = activities.filter( + (event): event is VaultDetailActivityEvent => + supportedActivityTypes.includes(event.type as SupportedVaultActivityType), + ); + + const listContainerRef = useRef(null); + const prevActivitiesLengthRef = useRef(activities.length); + + useEffect(() => { + if (isPaused) return; + + if (activities.length > prevActivitiesLengthRef.current) { + listContainerRef.current?.scrollTo({ + top: 0, + behavior: "smooth", + }); + } + prevActivitiesLengthRef.current = activities.length; + }, [activities.length, isPaused]); + + return ( +
+
+
+

+ + Activity Feed +

+

+ {vaultName || "Vault"} real-time events +

+ {isPaused && ( +

+ Auto-scroll paused. New events will appear at the top. +

+ )} +
+
+ {connectionError && ( +
+ + Reconnecting... +
+ )} + +
+ {isConnected ? ( + <> +
+
+ + + + {visibleActivities.length === 0 ? ( +
+
+ +
+

+ {isConnected ? "Listening for activity..." : "Connecting to live feed..."} +

+

+ Real-time updates will appear here for deposits, withdrawals, and yield. +

+
+ ) : ( +
+ + {visibleActivities + .slice() + .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) + .map((event, index) => ( + + ))} + +
+ )} +
+
+
+ ); +} + +export { anonymizeAddress }; \ No newline at end of file diff --git a/harvest-finance/frontend/src/components/dashboard/CropRecommendationPanel.tsx b/harvest-finance/frontend/src/components/dashboard/CropRecommendationPanel.tsx index 059acd198..4aa02089e 100644 --- a/harvest-finance/frontend/src/components/dashboard/CropRecommendationPanel.tsx +++ b/harvest-finance/frontend/src/components/dashboard/CropRecommendationPanel.tsx @@ -155,7 +155,7 @@ export function CropRecommendationPanel({

{item.title}

- + {item.priority}
diff --git a/harvest-finance/frontend/src/components/dashboard/StrategyDetails.tsx b/harvest-finance/frontend/src/components/dashboard/StrategyDetails.tsx index 90ec417de..b506db88b 100644 --- a/harvest-finance/frontend/src/components/dashboard/StrategyDetails.tsx +++ b/harvest-finance/frontend/src/components/dashboard/StrategyDetails.tsx @@ -44,13 +44,13 @@ export const StrategyDetails: React.FC = ({ vault, onDepos {vault.name}
- + {vault.asset} Native {vault.riskLevel} Risk - + {vault.strategyType || 'Audited'}
diff --git a/harvest-finance/frontend/src/components/dashboard/VaultActivityFeed.tsx b/harvest-finance/frontend/src/components/dashboard/VaultActivityFeed.tsx index 9125b3d9d..9c334f9e4 100644 --- a/harvest-finance/frontend/src/components/dashboard/VaultActivityFeed.tsx +++ b/harvest-finance/frontend/src/components/dashboard/VaultActivityFeed.tsx @@ -41,6 +41,12 @@ const activityConfig: Record< bgColor: "bg-amber-50", label: "Withdrawal", }, + yield_compounded: { + icon: Sparkles, + color: "text-indigo-600", + bgColor: "bg-indigo-50", + label: "Yield Compounded", + }, milestone: { icon: Trophy, color: "text-purple-600", diff --git a/harvest-finance/frontend/src/hooks/useVaultRealtime.ts b/harvest-finance/frontend/src/hooks/useVaultRealtime.ts index 2dd7245d5..b78f9b030 100644 --- a/harvest-finance/frontend/src/hooks/useVaultRealtime.ts +++ b/harvest-finance/frontend/src/hooks/useVaultRealtime.ts @@ -3,7 +3,7 @@ import { useEffect, useRef, useState, useCallback } from 'react'; import { io, Socket } from 'socket.io-client'; -export type VaultActivityType = 'deposit' | 'withdrawal' | 'milestone' | 'ai_insight'; +export type VaultActivityType = 'deposit' | 'withdrawal' | 'yield_compounded' | 'milestone' | 'ai_insight'; export interface VaultActivityEvent { type: VaultActivityType; @@ -11,6 +11,8 @@ export interface VaultActivityEvent { vaultName: string; amount?: number; userId?: string; + walletAddress?: string; + yieldAmount?: number; milestone?: string; insight?: string; newBalance?: number; @@ -20,6 +22,7 @@ export interface VaultActivityEvent { interface UseVaultRealtimeOptions { vaultIds?: string[]; maxActivityItems?: number; + targetVaultId?: string; } const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:3001'; @@ -27,53 +30,105 @@ const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:300 export function useVaultRealtime({ vaultIds = [], maxActivityItems = 20, + targetVaultId, }: UseVaultRealtimeOptions = {}) { const socketRef = useRef(null); + const reconnectAttemptsRef = useRef(0); const [isConnected, setIsConnected] = useState(false); + const [isPaused, setIsPaused] = useState(false); const [activities, setActivities] = useState([]); const [latestEvent, setLatestEvent] = useState(null); + const [connectionError, setConnectionError] = useState(null); const addActivity = useCallback( (event: VaultActivityEvent) => { + // If targetVaultId is specified, only add events for that vault + if (targetVaultId && event.vaultId !== targetVaultId) { + return; + } setActivities((prev) => [event, ...prev].slice(0, maxActivityItems)); setLatestEvent(event); }, - [maxActivityItems], + [maxActivityItems, targetVaultId], ); useEffect(() => { const socket = io(`${BACKEND_URL}/vault-activity`, { transports: ['websocket', 'polling'], + reconnection: true, reconnectionAttempts: 5, reconnectionDelay: 2000, + reconnectionDelayMax: 30000, }); socketRef.current = socket; socket.on('connect', () => { setIsConnected(true); + setConnectionError(null); + reconnectAttemptsRef.current = 0; // Subscribe to specific vaults vaultIds.forEach((id) => socket.emit('subscribe:vault', id)); + // Subscribe to target vault if provided + if (targetVaultId) { + socket.emit('subscribe:vault', targetVaultId); + } }); - socket.on('disconnect', () => { + socket.on('disconnect', (reason) => { setIsConnected(false); + if (reason !== 'io client disconnect') { + setConnectionError(`Connection lost: ${reason}`); + } + }); + + socket.on('reconnect', (attempt) => { + reconnectAttemptsRef.current = attempt; + setConnectionError(null); + // Re-subscribe on reconnect + if (targetVaultId) { + socket.emit('subscribe:vault', targetVaultId); + } + }); + + socket.on('reconnect_attempt', (attempt) => { + reconnectAttemptsRef.current = attempt; + }); + + socket.on('reconnect_error', (error) => { + setConnectionError(`Reconnection failed: ${error.message || 'Unknown error'}`); + }); + + socket.on('connect_error', (error) => { + setConnectionError(error.message || 'Connection error'); }); socket.on('vault:activity:global', (event: VaultActivityEvent) => { addActivity(event); }); + // Vault-specific channel for individual vault activity socket.on('vault:activity', (event: VaultActivityEvent) => { addActivity(event); }); + // Vault-specific channel: vault:{id}:activity + if (targetVaultId) { + socket.on(`vault:${targetVaultId}:activity`, (event: VaultActivityEvent) => { + addActivity(event); + }); + } + return () => { socket.disconnect(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const togglePause = useCallback(() => { + setIsPaused((prev) => !prev); + }, []); + const subscribeToVault = useCallback((vaultId: string) => { socketRef.current?.emit('subscribe:vault', vaultId); }, []); @@ -91,6 +146,10 @@ export function useVaultRealtime({ isConnected, activities, latestEvent, + isPaused, + connectionError, + reconnectAttempts: reconnectAttemptsRef.current, + togglePause, subscribeToVault, unsubscribeFromVault, clearActivities,