diff --git a/api/src/services/PairStateService.ts b/api/src/services/PairStateService.ts index 5831b57..7919995 100644 --- a/api/src/services/PairStateService.ts +++ b/api/src/services/PairStateService.ts @@ -11,6 +11,7 @@ import { getOmnipairProgram } from '../config/program'; import { simulatePairGetter } from '../utils/pairSimulation'; +import { calculateInterestRate, RATE_PERCENT_SCALE } from '../utils/rateCalculator'; import type { Omnipair } from '../types/omnipair.mainnet'; export interface TokenMetadata { @@ -203,37 +204,83 @@ export class PairStateService { // Get rate model PDA from pair account const rateModelPda = new PublicKey(pairAccount.rateModel); - // Fetch EMA prices and rates using simulation (after pair update) + // Fetch EMA prices using simulation (after pair update) let emaPrice0 = spotPrice0; let emaPrice1 = spotPrice1; - let rate0 = 0; - let rate1 = 0; + let rate0Raw = Number(pairAccount.lastRate0 ?? 0); + let rate1Raw = Number(pairAccount.lastRate1 ?? 0); try { if (!this.program) { throw new Error('Program not initialized'); } - const [emaPrice0Result, emaPrice1Result, ratesResult] = await Promise.all([ + const [emaPrice0Result, emaPrice1Result] = await Promise.all([ simulatePairGetter(this.program, this.connection, pairPda, rateModelPda, { emaPrice0Nad: {} }), - simulatePairGetter(this.program, this.connection, pairPda, rateModelPda, { emaPrice1Nad: {} }), - simulatePairGetter(this.program, this.connection, pairPda, rateModelPda, { getRates: {} }), + simulatePairGetter(this.program, this.connection, pairPda, rateModelPda, { emaPrice1Nad: {} }) ]); // Parse EMA prices (stored as NAD - 9 decimals) emaPrice0 = Number(emaPrice0Result.value0) / Math.pow(10, 9); emaPrice1 = Number(emaPrice1Result.value0) / Math.pow(10, 9); - - // Parse rates (stored as raw values, need to convert to percentage) - rate0 = Number(ratesResult.value0); - rate1 = Number(ratesResult.value1); } catch (error) { - console.warn('Error fetching EMA prices or rates via simulation, falling back to spot prices:', error); + console.warn('Error fetching EMA prices via simulation, falling back to spot prices:', error); // Fallback to spot prices if simulation fails emaPrice0 = spotPrice0; emaPrice1 = spotPrice1; } + try { + if (!this.program) { + throw new Error('Program not initialized'); + } + const rateModelAccount = await (this.program.account as any).rateModel.fetch(rateModelPda); + const currentTimestamp = Math.floor(Date.now() / 1000); + + const rate0Result = calculateInterestRate( + { + utilizationPercent: utilization0, + lastRate: pairAccount.lastRate0, + lastUpdateTimestamp: pairAccount.lastUpdate, + currentTimestamp, + }, + { + expRate: rateModelAccount.expRate, + targetUtilStart: rateModelAccount.targetUtilStart, + targetUtilEnd: rateModelAccount.targetUtilEnd, + } + ); + + const rate1Result = calculateInterestRate( + { + utilizationPercent: utilization1, + lastRate: pairAccount.lastRate1, + lastUpdateTimestamp: pairAccount.lastUpdate, + currentTimestamp, + }, + { + expRate: rateModelAccount.expRate, + targetUtilStart: rateModelAccount.targetUtilStart, + targetUtilEnd: rateModelAccount.targetUtilEnd, + } + ); + + rate0Raw = rate0Result.rawRate; + rate1Raw = rate1Result.rawRate; + } catch (error) { + console.warn('Error calculating rates locally, falling back to simulation:', error); + try { + if (!this.program) { + throw new Error('Program not initialized'); + } + const ratesResult = await simulatePairGetter(this.program, this.connection, pairPda, rateModelPda, { getRates: {} }); + rate0Raw = Number(ratesResult.value0); + rate1Raw = Number(ratesResult.value1); + } catch (simError) { + console.warn('Error fetching rates via simulation:', simError); + } + } + return { token0: { @@ -263,8 +310,8 @@ export class PairStateService { token1: spotPrice1.toString(), }, rates: { - token0: Math.floor((Number(rate0) / 1e7) * 100) / 100, - token1: Math.floor((Number(rate1) / 1e7) * 100) / 100, + token0: Math.floor((rate0Raw / RATE_PERCENT_SCALE) * 100) / 100, + token1: Math.floor((rate1Raw / RATE_PERCENT_SCALE) * 100) / 100, }, totalDebts: { token0: totalDebt0.toString(), @@ -297,4 +344,3 @@ export class PairStateService { return this.program; } } - diff --git a/api/src/utils/__tests__/rateCalculator.test.ts b/api/src/utils/__tests__/rateCalculator.test.ts new file mode 100644 index 0000000..a7a178c --- /dev/null +++ b/api/src/utils/__tests__/rateCalculator.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'bun:test'; +import { calculateInterestRate, RateModelAccountData, RATE_PERCENT_SCALE } from '../rateCalculator'; + +const NAD = 1_000_000_000; +const HALF_LIFE_SECONDS = 3600; // 1 hour +const EXP_RATE = BigInt( + Math.round((Math.log(2) / HALF_LIFE_SECONDS) * NAD) +); +const TARGET_START = BigInt(Math.round(0.33 * NAD)); +const TARGET_END = BigInt(Math.round(0.66 * NAD)); + +const baseRateModel: RateModelAccountData = { + expRate: EXP_RATE, + targetUtilStart: TARGET_START, + targetUtilEnd: TARGET_END, +}; + +describe('calculateInterestRate', () => { + it('keeps the rate unchanged inside the utilization band', () => { + const result = calculateInterestRate( + { + utilizationPercent: 50, + lastRate: BigInt(500_000_000), + lastUpdateTimestamp: 0, + currentTimestamp: HALF_LIFE_SECONDS, + }, + baseRateModel + ); + + expect(result.rawRate).toBeCloseTo(500_000_000); + }); + + it('decays exponentially when utilization falls below the band', () => { + const lastRate = BigInt(800_000_000); + const result = calculateInterestRate( + { + utilizationPercent: 5, + lastRate, + lastUpdateTimestamp: 0, + currentTimestamp: HALF_LIFE_SECONDS, + }, + baseRateModel + ); + + const expected = Number(lastRate) / 2; + expect(Math.abs(result.rawRate - expected)).toBeLessThan(expected * 1e-6); + }); + + it('grows exponentially when utilization is above the band', () => { + const lastRate = BigInt(200_000_000); + const result = calculateInterestRate( + { + utilizationPercent: 90, + lastRate, + lastUpdateTimestamp: 0, + currentTimestamp: HALF_LIFE_SECONDS, + }, + baseRateModel + ); + + const expected = Number(lastRate) * 2; + expect(Math.abs(result.rawRate - expected)).toBeLessThan(expected * 1e-6); + }); + + it('ignores time deltas that are zero or negative', () => { + const lastRate = BigInt(350_000_000); + const result = calculateInterestRate( + { + utilizationPercent: 10, + lastRate, + lastUpdateTimestamp: 1_000, + currentTimestamp: 900, // negative delta + }, + baseRateModel + ); + + expect(result.rawRate).toBeCloseTo(Number(lastRate)); + }); + + it('handles zero last rate without producing NaN', () => { + const result = calculateInterestRate( + { + utilizationPercent: 90, + lastRate: BigInt(0), + lastUpdateTimestamp: 0, + currentTimestamp: HALF_LIFE_SECONDS, + }, + baseRateModel + ); + + expect(result.rawRate).toBe(0); + }); + + it('clamps utilization percentages outside the 0-100 range', () => { + const lastRate = BigInt(600_000_000); + const result = calculateInterestRate( + { + utilizationPercent: 150, // treated as > targetEnd + lastRate, + lastUpdateTimestamp: 0, + currentTimestamp: HALF_LIFE_SECONDS, + }, + baseRateModel + ); + + expect(result.rawRate).toBeGreaterThan(Number(lastRate)); + }); + + it('returns aprPercent scaled from the raw rate', () => { + const lastRate = BigInt(123_000_000); + const result = calculateInterestRate( + { + utilizationPercent: 50, + lastRate, + lastUpdateTimestamp: 0, + currentTimestamp: 10, + }, + baseRateModel + ); + + expect(result.aprPercent).toBeCloseTo( + Number(lastRate) / RATE_PERCENT_SCALE + ); + }); +}); diff --git a/api/src/utils/rateCalculator.ts b/api/src/utils/rateCalculator.ts index 3b9fb92..a57ec72 100644 --- a/api/src/utils/rateCalculator.ts +++ b/api/src/utils/rateCalculator.ts @@ -1,91 +1,163 @@ import { PublicKey } from '@solana/web3.js'; import { Program } from '@coral-xyz/anchor'; +const NAD_SCALE = 1_000_000_000; +export const RATE_PERCENT_SCALE = 1e7; +const MAX_EXPONENT = 709; // Safe bound before Math.exp overflows double precision + +type NumericLike = number | bigint | { toString(): string }; + +export interface RateModelAccountData { + expRate: NumericLike; + targetUtilStart: NumericLike; + targetUtilEnd: NumericLike; +} + +export interface RateCalculationContext { + utilizationPercent: number; + lastRate: NumericLike; + lastUpdateTimestamp: NumericLike; + currentTimestamp?: number; +} + +export interface RateCalculationResult { + rawRate: number; + aprPercent: number; +} + +function toNumber(value: NumericLike | undefined | null): number { + if (value === null || value === undefined) { + return 0; + } + if (typeof value === 'number') { + return value; + } + if (typeof value === 'bigint') { + return Number(value); + } + if (typeof (value as any).toString === 'function') { + const parsed = Number((value as any).toString()); + return Number.isNaN(parsed) ? 0 : parsed; + } + return 0; +} + +function clamp(value: number, minValue: number, maxValue: number): number { + if (!Number.isFinite(value)) { + return minValue; + } + return Math.min(Math.max(value, minValue), maxValue); +} + /** - * TODO: This is a simplified version of the rate calculation logic. - * TODO: Need to implement the actual rate calculation logic. - * Calculate interest rate based on utilization and rate model - * This replicates the on-chain rate calculation logic + * Replicates the on-chain interest rate model by applying the same + * exponential growth/decay that the program executes inside view_pair_data. + * + * The rate changes only when utilization exits the [targetUtilStart, targetUtilEnd] + * band. When below the band the rate decays (halves over the configured half-life), + * and when above it grows (doubles over the same window). Within the band the rate + * remains unchanged. */ export function calculateInterestRate( - utilization: number, // 0-100% - rateModel: { - expRate: bigint; - targetUtilStart: bigint; - targetUtilEnd: bigint; + context: RateCalculationContext, + rateModel: RateModelAccountData +): RateCalculationResult { + const utilizationPercent = clamp(context.utilizationPercent ?? 0, 0, 100); + const utilizationNad = (utilizationPercent / 100) * NAD_SCALE; + const targetStartRaw = toNumber(rateModel.targetUtilStart); + const targetEndRaw = toNumber(rateModel.targetUtilEnd); + const expRateRaw = Math.max(0, toNumber(rateModel.expRate)); + const lastRate = Math.max(0, toNumber(context.lastRate)); + const lastUpdate = Math.floor(toNumber(context.lastUpdateTimestamp)); + const currentTimestamp = + context.currentTimestamp ?? Math.floor(Date.now() / 1000); + const deltaSeconds = Math.max(0, currentTimestamp - lastUpdate); + + const lowerBound = Math.min(targetStartRaw, targetEndRaw); + const upperBound = Math.max(targetStartRaw, targetEndRaw); + let direction = 0; + if (utilizationNad < lowerBound) { + direction = -1; + } else if (utilizationNad > upperBound) { + direction = 1; } -): number { - // NAD constant (1e9) - const NAD = 1e9; - - // Convert utilization percentage to NAD scale (0-NAD) - const utilizationNad = (utilization / 100) * NAD; - - // Get rate model parameters - const expRate = Number(rateModel.expRate); - const targetUtilStart = Number(rateModel.targetUtilStart); - const targetUtilEnd = Number(rateModel.targetUtilEnd); - - // Calculate base rate from exponential rate - // This is a simplified version - adjust based on your actual rate model logic - const k_real = expRate / NAD; - - // Calculate rate based on utilization bands - let rate: number; - - if (utilizationNad < targetUtilStart) { - // Below target range - lower rate - const utilizationFactor = utilizationNad / targetUtilStart; - rate = k_real * utilizationFactor; - } else if (utilizationNad > targetUtilEnd) { - // Above target range - higher rate (exponential increase) - const excessUtil = (utilizationNad - targetUtilEnd) / (NAD - targetUtilEnd); - rate = k_real * (1 + excessUtil * 10); // Exponential factor - } else { - // Within target range - stable rate - rate = k_real; + + let updatedRate = lastRate; + if ( + direction !== 0 && + deltaSeconds > 0 && + expRateRaw > 0 && + lastRate > 0 + ) { + const kReal = expRateRaw / NAD_SCALE; + const exponent = clamp(direction * kReal * deltaSeconds, -MAX_EXPONENT, MAX_EXPONENT); + const growthFactor = Math.exp(exponent); + if (Number.isFinite(growthFactor)) { + updatedRate = lastRate * growthFactor; + } } - - // Convert to percentage (rate is already in decimal form) - return rate * 100; + + const sanitizedRate = Math.max(0, Number.isFinite(updatedRate) ? updatedRate : 0); + return { + rawRate: sanitizedRate, + aprPercent: sanitizedRate / RATE_PERCENT_SCALE, + }; +} + +export interface PairRateContext { + utilization0: number; + utilization1: number; + lastRate0: NumericLike; + lastRate1: NumericLike; + lastUpdate: NumericLike; + currentTimestamp?: number; } /** - * Fetch rate model account and calculate current rates + * Fetch rate model account and calculate the current rates for both tokens. */ export async function fetchRatesFromRateModel( program: Program, rateModelPubkey: PublicKey, - utilization0: number, - utilization1: number + context: PairRateContext ): Promise<{ rate0: number; rate1: number }> { try { - // Fetch the rate model account - const rateModel = await (program.account as any).rateModel.fetch(rateModelPubkey); - - // console.log('Rate Model:', { - // expRate: rateModel.expRate.toString(), - // targetUtilStart: rateModel.targetUtilStart.toString(), - // targetUtilEnd: rateModel.targetUtilEnd.toString(), - // }); - - // Calculate rates for both tokens - const rate0 = calculateInterestRate(utilization0, { - expRate: rateModel.expRate, - targetUtilStart: rateModel.targetUtilStart, - targetUtilEnd: rateModel.targetUtilEnd, - }); - - const rate1 = calculateInterestRate(utilization1, { - expRate: rateModel.expRate, - targetUtilStart: rateModel.targetUtilStart, - targetUtilEnd: rateModel.targetUtilEnd, - }); - - return { rate0, rate1 }; + const rateModel = await (program.account as any).rateModel.fetch( + rateModelPubkey + ); + + const timestamp = + context.currentTimestamp ?? Math.floor(Date.now() / 1000); + const rate0Result = calculateInterestRate( + { + utilizationPercent: context.utilization0, + lastRate: context.lastRate0, + lastUpdateTimestamp: context.lastUpdate, + currentTimestamp: timestamp, + }, + { + expRate: rateModel.expRate, + targetUtilStart: rateModel.targetUtilStart, + targetUtilEnd: rateModel.targetUtilEnd, + } + ); + const rate1Result = calculateInterestRate( + { + utilizationPercent: context.utilization1, + lastRate: context.lastRate1, + lastUpdateTimestamp: context.lastUpdate, + currentTimestamp: timestamp, + }, + { + expRate: rateModel.expRate, + targetUtilStart: rateModel.targetUtilStart, + targetUtilEnd: rateModel.targetUtilEnd, + } + ); + + return { rate0: rate0Result.rawRate, rate1: rate1Result.rawRate }; } catch (error) { console.error('Error fetching rate model:', error); - // Return 0 rates as fallback return { rate0: 0, rate1: 0 }; } }