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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 4 additions & 8 deletions harvest-finance/backend/src/database/entities/strategy.entity.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,4 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm';

export enum CompoundingFrequency {
DAILY = 'daily',
Expand All @@ -19,14 +13,16 @@ export const COMPOUNDING_FREQUENCY_N: Record<CompoundingFrequency, number> = {
};

@Entity('strategies')
@Index('idx_strategies_compounding_frequency', ['compoundingFrequency'])
export class Strategy {
@PrimaryGeneratedColumn('uuid')
id: string;

@Column({ length: 100 })
@Column({ type: 'varchar', length: 100 })
name: string;

@Column({
name: 'compounding_frequency',
type: 'enum',
enum: CompoundingFrequency,
default: CompoundingFrequency.DAILY,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,10 @@ export class VaultApyHistory {
@JoinColumn({ name: 'vault_id' })
vault: Vault;

@Column({
type: 'decimal',
precision: 18,
scale: 8,
})
@Column({ type: 'decimal', precision: 18, scale: 8, nullable: true })
apr: number | null;

@Column({ type: 'decimal', precision: 18, scale: 8 })
apy: number;

@Column({ name: 'snapshot_date', type: 'date' })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,13 @@ export class CreateStrategyAndApyHistory1700000000017 implements MigrationInterf
type: 'uuid',
isNullable: false,
},
{
name: 'apr',
type: 'decimal',
precision: 18,
scale: 8,
isNullable: true,
},
{
name: 'apy',
type: 'decimal',
Expand Down
7 changes: 7 additions & 0 deletions harvest-finance/backend/src/vaults/dto/vault-response.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@
})
apy: number;

@ApiProperty({
example: 'daily',
description: 'Compounding frequency used for APY calculation',
enum: ['daily', 'weekly', 'monthly'],
})
compoundingFrequency: string;

@ApiProperty({
example: '2024-12-31T23:59:59Z',
description: 'Vault maturity date',
Expand Down
129 changes: 129 additions & 0 deletions harvest-finance/backend/src/vaults/vaults.apy.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { VaultsService } from './vaults.service';
import { Vault, VaultStatus, VaultType } from '../database/entities/vault.entity';
import { Deposit } from '../database/entities/deposit.entity';
import { Withdrawal } from '../database/entities/withdrawal.entity';
import { Strategy, CompoundingFrequency } from '../database/entities/strategy.entity';
import { VaultApyHistory } from '../database/entities/vault-apy-history.entity';
import { NotificationsService } from '../notifications/notifications.service';
import { CustomLoggerService } from '../logger/custom-logger.service';
import { VaultGateway } from '../realtime/vault.gateway';
import { ContractCacheService } from '../common/cache/contract-cache.service';
import { InputSanitizerService } from '../common/sanitization/input-sanitizer.service';
import { DepositEventService } from './deposit-event.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { AuthService } from '../auth/auth.service';

describe('VaultsService APY behavior', () => {
let service: VaultsService;
const mockVaultRepository = { findOne: jest.fn(), update: jest.fn(), find: jest.fn(), save: jest.fn(), create: jest.fn() };
const mockDepositRepository = { create: jest.fn(), findOne: jest.fn(), find: jest.fn(), update: jest.fn(), createQueryBuilder: jest.fn() };
const mockWithdrawalRepository = { create: jest.fn(), findOne: jest.fn(), update: jest.fn() };
const mockStrategyRepository = { findOne: jest.fn() };
const mockApyHistoryRepository = { create: jest.fn(), save: jest.fn(), createQueryBuilder: jest.fn() };
const mockDataSource = { transaction: jest.fn(), getRepository: jest.fn(), createQueryBuilder: jest.fn() };
const mockNotificationsService = { create: jest.fn() };
const mockLogger = { log: jest.fn(), error: jest.fn(), warn: jest.fn() };
const mockVaultGateway = { emitDeposit: jest.fn(), emitWithdrawal: jest.fn() };
const mockEventEmitter = { emit: jest.fn() };
const mockContractCache = { getVaultState: jest.fn((_id: string, loader: () => Promise<Vault>) => loader()) };
const mockSanitizer = { validateUUID: jest.fn((id: string) => id) };
const mockDepositEventService = { appendEvent: jest.fn(), getDepositHistory: jest.fn(), getUserDepositHistory: jest.fn(), getVaultDepositHistory: jest.fn(), mapEventToResponse: jest.fn() };
const mockAuthService = { isEmailVerified: jest.fn().mockResolvedValue(true) };

beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
VaultsService,
{ provide: getRepositoryToken(Vault), useValue: mockVaultRepository },
{ provide: getRepositoryToken(Deposit), useValue: mockDepositRepository },
{ provide: getRepositoryToken(Withdrawal), useValue: mockWithdrawalRepository },
{ provide: getRepositoryToken(Strategy), useValue: mockStrategyRepository },
{ provide: getRepositoryToken(VaultApyHistory), useValue: mockApyHistoryRepository },
{ provide: DataSource, useValue: mockDataSource },
{ provide: NotificationsService, useValue: mockNotificationsService },
{ provide: CustomLoggerService, useValue: mockLogger },
{ provide: VaultGateway, useValue: mockVaultGateway },
{ provide: ContractCacheService, useValue: mockContractCache },
{ provide: InputSanitizerService, useValue: mockSanitizer },
{ provide: DepositEventService, useValue: mockDepositEventService },
{ provide: EventEmitter2, useValue: mockEventEmitter },
{ provide: AuthService, useValue: mockAuthService },
],
}).compile();

service = module.get<VaultsService>(VaultsService);
});

it('calculates APY for daily, weekly, and monthly compounding', () => {
expect(service.calculateApy(5, CompoundingFrequency.DAILY)).toBeCloseTo(5.13, 2);
expect(service.calculateApy(5, CompoundingFrequency.WEEKLY)).toBeCloseTo(5.12, 2);
expect(service.calculateApy(5, CompoundingFrequency.MONTHLY)).toBeCloseTo(5.11, 2);
});

it('returns zero APY for zero APR and defaults invalid frequencies to daily', () => {
expect(service.calculateApy(0, CompoundingFrequency.DAILY)).toBe(0);
expect(service.calculateApy(5, 'invalid' as CompoundingFrequency)).toBeCloseTo(5.13, 2);
});

it('persists a daily snapshot with APR and APY for a vault', async () => {
const insertBuilder = {
into: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
orIgnore: jest.fn().mockReturnThis(),
execute: jest.fn().mockResolvedValue(undefined),
};
mockDataSource.createQueryBuilder.mockReturnValue(insertBuilder);
mockVaultRepository.findOne.mockResolvedValue({
id: 'vault-1',
interestRate: 5,
strategy: { compoundingFrequency: CompoundingFrequency.MONTHLY },
});

await service.recordApySnapshot('vault-1');

expect(insertBuilder.values).toHaveBeenCalledWith(expect.objectContaining({
vault_id: 'vault-1',
apr: 5,
apy: expect.any(Number),
snapshot_date: expect.any(Date),
}));
});

it('includes compounding frequency in the vault response payload', () => {
const vault = {
id: 'vault-1',
ownerId: 'user-1',
type: VaultType.CROP_PRODUCTION,
status: VaultStatus.ACTIVE,
vaultName: 'Test Vault',
description: 'Test vault description',
symbol: 'TEST',
assetPair: 'XLM/USDC',
totalDeposits: 1000,
maxCapacity: 10000,
interestRate: 5,
maturityDate: null,
lockPeriodEnd: null,
isPublic: true,
requiresMultiSignature: false,
approvalThreshold: 1,
currentApprovals: 0,
strategy: { compoundingFrequency: CompoundingFrequency.WEEKLY },
createdAt: new Date('2025-01-01'),
updatedAt: new Date('2025-01-01'),
availableCapacity: 9000,
utilizationPercentage: 10,
approvalStatus: 'NOT_REQUIRED',
} as Vault;

const response = service.mapVaultToResponse(vault);

expect(response.apr).toBe(5);
expect(response.apy).toBeCloseTo(5.12, 2);
expect(response.compoundingFrequency).toBe(CompoundingFrequency.WEEKLY);
});
});
90 changes: 39 additions & 51 deletions harvest-finance/backend/src/vaults/vaults.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { Vault, VaultStatus } from '../database/entities/vault.entity';
import { Deposit, DepositStatus } from '../database/entities/deposit.entity';
import { VaultApyHistory } from '../database/entities/vault-apy-history.entity';
import { DepositEvent, DepositEventType } from '../database/entities/deposit-event.entity';
import { ExternalPaymentEventType } from './dto/external-payment-notification.dto';
import {
Expand All @@ -19,6 +18,8 @@ import {

import { Strategy, CompoundingFrequency, COMPOUNDING_FREQUENCY_N } from '../database/entities/strategy.entity';
import { VaultApyHistory } from '../database/entities/vault-apy-history.entity';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { AuthService } from '../auth/auth.service';

import { VaultReservation } from './entities/vault-reservation.entity';
import { CreateReservationDto } from './dto/create-reservation.dto';
Expand Down Expand Up @@ -65,8 +66,6 @@ export class VaultsService {

@InjectRepository(VaultReservation)
private reservationRepository: Repository<VaultReservation>,
@InjectRepository(VaultApyHistory)
private vaultApyHistoryRepository: Repository<VaultApyHistory>,

private dataSource: DataSource,
private notificationsService: NotificationsService,
Expand All @@ -90,23 +89,36 @@ export class VaultsService {
*/
calculateApy(
apr: number,
frequency: CompoundingFrequency = CompoundingFrequency.DAILY,
frequency: CompoundingFrequency | string | null | undefined = CompoundingFrequency.DAILY,
): number {
if (apr === 0) return 0;

const n = COMPOUNDING_FREQUENCY_N[frequency];
const normalizedFrequency = this.normalizeCompoundingFrequency(frequency);
const n = COMPOUNDING_FREQUENCY_N[normalizedFrequency];
const decimalApr = apr / 100;
const apy = Math.pow(1 + decimalApr / n, n) - 1;

return Math.round(apy * 10000) / 100; // Return as percentage, rounded to 2 decimal places
return Number((apy * 100).toFixed(2));
}

/**
* Get the effective compounding frequency for a vault.
* Falls back to DAILY if no strategy is assigned.
* Falls back to DAILY if no strategy is assigned or the stored value is invalid.
*/
private getVaultCompoundingFrequency(vault: Vault): CompoundingFrequency {
return vault.strategy?.compoundingFrequency ?? CompoundingFrequency.DAILY;
return this.normalizeCompoundingFrequency(vault.strategy?.compoundingFrequency);
}

private normalizeCompoundingFrequency(
frequency: CompoundingFrequency | string | null | undefined,
): CompoundingFrequency {
if (frequency === CompoundingFrequency.WEEKLY) {
return CompoundingFrequency.WEEKLY;
}
if (frequency === CompoundingFrequency.MONTHLY) {
return CompoundingFrequency.MONTHLY;
}
return CompoundingFrequency.DAILY;
}

async getVaultById(vaultId: string): Promise<Vault> {
Expand Down Expand Up @@ -687,13 +699,9 @@ export class VaultsService {

mapVaultToResponse(vault: Vault): VaultResponseDto {
const apr = Number(vault.interestRate);

const apy = this.calculateApy(apr, this.getVaultCompoundingFrequency(vault));

const compoundingFrequency = vault.compoundingFrequency || 'daily';
const compoundingFrequency = this.getVaultCompoundingFrequency(vault);
const apy = this.calculateApy(apr, compoundingFrequency);


return {
id: vault.id,
ownerId: vault.ownerId,
Expand All @@ -707,12 +715,10 @@ export class VaultsService {
maxCapacity: Number(vault.maxCapacity),
availableCapacity: vault.availableCapacity,
utilizationPercentage: vault.utilizationPercentage,
interestRate: apr,
interestRate: apr,
apr,
apy,

compoundingFrequency,

maturityDate: vault.maturityDate,
lockPeriodEnd: vault.lockPeriodEnd,
isPublic: vault.isPublic,
Expand Down Expand Up @@ -752,10 +758,11 @@ export class VaultsService {
.into('vault_apy_history')
.values({
vault_id: vault.id,
apr,
apy,
snapshot_date: snapshotDate,
})
.orIgnore() // Ignore if a snapshot for this vault/date already exists
.orIgnore()
.execute();
}

Expand Down Expand Up @@ -914,7 +921,6 @@ export class VaultsService {
vaultId?: string,
timeRange: string = '30d',
): Promise<any[]> {
// Calculate date range
const now = new Date();
let daysBack = 30;

Expand All @@ -934,55 +940,37 @@ export class VaultsService {

const startDate = new Date(now.getTime() - daysBack * 24 * 60 * 60 * 1000);


const query = this.apyHistoryRepository
.createQueryBuilder('history')
.where('history.snapshotDate >= :startDate', {
startDate: startDate.toISOString().split('T')[0],
})
.orderBy('history.snapshotDate', 'ASC');

if (vaultId) {
query.andWhere('history.vaultId = :vaultId', { vaultId });

const queryBuilder = this.vaultApyHistoryRepository
.createQueryBuilder('history')
.where('history.snapshotDate >= :startDate', {
startDate: startDate.toISOString().split('T')[0],
})
.orderBy('history.snapshotDate', 'ASC');

if (vaultId) {
query.andWhere('history.vaultId = :vaultId', { vaultId });
}

const rows = await query.getMany();

if (records.length > 0) {
return records.map(r => ({
date: typeof r.date === 'string' ? r.date : new Date(r.date).toISOString().split('T')[0],
apy: Number(r.apy),
vaultId: r.vaultId,
}));
}

// Fallback: If no real data exists, generate some mock data so charts aren't blank
const dataPoints: { date: string; apy: number; vaultId: string }[] = [];
for (let i = 0; i < daysBack; i++) {
const date = new Date(startDate.getTime() + i * 24 * 60 * 60 * 1000);
const baseApy = 8 + Math.sin(i / 10) * 2 + Math.random() * 1;
const apy = Math.max(0, Math.min(15, baseApy));

dataPoints.push({
date: date.toISOString().split('T')[0],
apy: Math.round(apy * 100) / 100,
vaultId: vaultId || 'all',
});
if (rows.length === 0) {
// Fallback: If no real data exists, generate some mock data so charts aren't blank
const dataPoints: { date: string; apy: number; vaultId: string }[] = [];
for (let i = 0; i < daysBack; i++) {
const date = new Date(startDate.getTime() + i * 24 * 60 * 60 * 1000);
const baseApy = 8 + Math.sin(i / 10) * 2 + Math.random() * 1;
const apy = Math.max(0, Math.min(15, baseApy));

dataPoints.push({
date: date.toISOString().split('T')[0],
apy: Math.round(apy * 100) / 100,
vaultId: vaultId || 'all',
});
}

return dataPoints;
}

const rows = await query.getMany();

return rows.map((row) => ({
date: row.snapshotDate.toISOString().split('T')[0],
apy: Number(row.apy),
Expand Down
Loading