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
74 changes: 60 additions & 14 deletions api/src/services/PairStateService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -297,4 +344,3 @@ export class PairStateService {
return this.program;
}
}

125 changes: 125 additions & 0 deletions api/src/utils/__tests__/rateCalculator.test.ts
Original file line number Diff line number Diff line change
@@ -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
);
});
});
Loading