/* eslint-disable max-classes-per-file */
import { AccountMeta, AccountRole, Address, Instruction, none, Option, Slot, some } from '@solana/kit';
import Decimal from 'decimal.js';
import { KaminoReserve } from './reserve';
import { Obligation } from '../@codegen/klend/accounts';
import { ElevationGroupDescription, KaminoMarket } from './market';
import BN from 'bn.js';
import { bfToDecimal, Fraction, ZERO_FRACTION } from './fraction';
import {
FixedTermBorrowRolloverConfigFields,
ObligationCollateral,
ObligationCollateralFields,
ObligationLiquidity,
ObligationLiquidityFields,
ReserveStatus,
} from '../@codegen/klend/types';
import { positiveOrZero, toBuffer, valueOrZero } from './utils';
import {
DEFAULT_PUBLIC_KEY,
getObligationPdaWithArgs,
getObligationType,
isNotNullPubkey,
ObligationType,
referrerTokenStatePda,
SECONDS_PER_DAY,
SLOTS_PER_SECOND,
TOTAL_NUMBER_OF_IDS_TO_CHECK,
U64_MAX,
} from '../utils';
import { ActionType } from './action';
import { borrowOrdersFromObligationState, BorrowOrderSlots, KaminoBorrowOrder } from './borrowOrder';
import { KaminoObligationOrder } from './obligationOrder';
import { FeeCalculation } from './shared';
import { refreshObligation } from '../@codegen/klend/instructions';
import { RolloverImpossibleReason, RolloverMode, RolloverPossibility } from './rolloverTypes';
import type { LedgerInstant } from '../utils/ledger';
export type Position = {
reserveAddress: Address;
mintAddress: Address;
mintFactor: Decimal;
/**
* Amount of tokens in lamports, including decimal places for interest accrued (no borrow factor weighting)
*/
amount: Decimal;
/**
* Market value of the position in USD (no borrow factor weighting)
*/
marketValueRefreshed: Decimal;
};
export type PositionChange = {
reserveAddress: Address;
amountChangeLamports: Decimal;
};
export type MaxWithdrawAmountResult = {
/**
* Maximum withdraw amount with reserve withdrawal limit applied
*/
maxWithdrawAmount: Decimal;
/**
* Maximum withdraw amount for queued withdrawals (no reserve withdrawal limit)
*/
maxWithdrawAmountQueue: Decimal;
};
export type ObligationStats = {
userTotalDeposit: Decimal;
userTotalCollateralDeposit: Decimal;
userTotalLiquidatableDeposit: Decimal;
userTotalBorrow: Decimal;
userTotalBorrowBorrowFactorAdjusted: Decimal;
borrowLimit: Decimal;
borrowLiquidationLimit: Decimal;
borrowUtilization: Decimal;
netAccountValue: Decimal;
/**
* The obligation's current LTV, *suitable for UI display*.
*
* Technically, this is a ratio:
* - of a sum of all borrows' values multiplied by reserves' borrowFactor (i.e. `userTotalBorrowBorrowFactorAdjusted`)
* - to a sum of values of all deposits having reserve's loanToValue > 0 (i.e. `userTotalCollateralDeposit`)
*
* Please note that this is different from the smart contract's definition of LTV (which divides by a sum of values
* of strictly all deposits, i.e. `userTotalDeposit`). Some parts of the SDK (e.g. obligation orders) need to use the
* smart contract's LTV definition.
*/
loanToValue: Decimal;
/**
* The LTV at which the obligation becomes subject to liquidation, *suitable for UI display*.
*
* Technically, this is a ratio:
* - of a sum of values of all deposits multiplied by reserves' liquidationLtv (i.e. `borrowLiquidationLimit`)
* - to a sum of values of all deposits having reserve's liquidationLtv > 0 (i.e. `userTotalLiquidatableDeposit`)
*
* Please note that this is different from the smart contract's definition of liquidation LTV (which divides by a sum
* of values of strictly all deposits, i.e. `userTotalDeposit`). Some parts of the SDK (e.g. obligation orders) need
* to use the smart contract's LTV definition.
*/
liquidationLtv: Decimal;
leverage: Decimal;
potentialElevationGroupUpdate: number;
};
interface BorrowStats {
borrows: Map
;
userTotalBorrow: Decimal;
userTotalBorrowBorrowFactorAdjusted: Decimal;
positions: number;
}
interface DepositStats {
deposits: Map;
userTotalDeposit: Decimal;
userTotalCollateralDeposit: Decimal;
userTotalLiquidatableDeposit: Decimal;
borrowLimit: Decimal;
liquidationLtv: Decimal;
borrowLiquidationLimit: Decimal;
}
export class KaminoObligation {
obligationAddress: Address;
state: Obligation;
market: KaminoMarket;
/**
* Deposits stored in a map of reserve address to position
*/
deposits: Map;
/**
* Borrows stored in a map of reserve address to position
*/
borrows: Map;
refreshedStats: ObligationStats;
obligationTag: number;
/**
* Every borrow-order slot of the obligation, in the program's index order (the head order followed by the
* tail ones). Inactive slots are present too, so an index here is the `orderIdx` the on-chain instructions
* take; use {@link getActiveBorrowOrders} to skip the empty ones.
*/
borrowOrders: BorrowOrderSlots;
/**
* Initialise a new Obligation from the deserialized state
* @param market
* @param obligationAddress
* @param obligation
* @param collateralExchangeRates - rates from the market by reserve address, will be calculated if not provided
* @param cumulativeBorrowRates - rates from the market by reserve address, will be calculated if not provided
*/
constructor(
market: KaminoMarket,
obligationAddress: Address,
obligation: Obligation,
collateralExchangeRates: Map,
cumulativeBorrowRates: Map
) {
this.market = market;
this.obligationAddress = obligationAddress;
this.state = obligation;
const { borrows, deposits, refreshedStats } = KaminoObligation.calculatePositions(
market,
obligation.deposits,
obligation.borrows,
obligation.elevationGroup,
collateralExchangeRates,
cumulativeBorrowRates
);
this.deposits = deposits;
this.borrows = borrows;
this.refreshedStats = refreshedStats;
this.obligationTag = obligation.tag.toNumber();
const [headOrder, firstTailOrder, secondTailOrder] = borrowOrdersFromObligationState(obligation);
this.borrowOrders = [
KaminoBorrowOrder.fromExistingBorrowOrderState(headOrder),
KaminoBorrowOrder.fromExistingBorrowOrderState(firstTailOrder),
KaminoBorrowOrder.fromExistingBorrowOrderState(secondTailOrder),
];
}
/**
* @param reserveAddress1 deposit/collateral reserve. Required for non-Vanilla tags.
* @param reserveAddress2 borrow/debt reserve. Required for Multiply/Leverage (both rate kinds).
*
* Reserves rather than mints because a single mint can map to a float-rate reserve plus
* multiple fixed-rate reserves — the caller must specify which one this obligation was
* seeded with. Mints for variable-rate tags are looked up internally from the reserve.
*/
async getObligationId(
market: KaminoMarket,
reserveAddress1: Option = none(),
reserveAddress2: Option = none()
) {
if (this.state.lendingMarket !== market.getAddress()) {
throw new Error('Obligation does not belong to this market');
}
let obligationId: number | undefined;
const type = getObligationType(market, this.obligationTag, reserveAddress1, reserveAddress2);
const baseArgs = type.toArgs();
for (let i = 0; i < TOTAL_NUMBER_OF_IDS_TO_CHECK; i++) {
const pda = await getObligationPdaWithArgs(
market.getAddress(),
this.state.owner,
{
...baseArgs,
id: i,
},
market.programId
);
if (pda === this.obligationAddress) {
obligationId = i;
break;
}
}
if (obligationId === undefined) {
throw new Error(`obligation id not found for obligation ${this.obligationAddress.toString()}`);
}
return obligationId;
}
static async load(kaminoMarket: KaminoMarket, obligationAddress: Address): Promise {
const res = await kaminoMarket.getRpc().getAccountInfo(obligationAddress, { encoding: 'base64' }).send();
if (!res.value) {
return null;
}
const accInfo = res.value;
if (accInfo.owner !== kaminoMarket.programId) {
throw new Error("account doesn't belong to this program");
}
const obligation = Obligation.decode(Buffer.from(accInfo.data[0], 'base64'));
if (obligation === null) {
return null;
}
const { collateralExchangeRates, cumulativeBorrowRates } = KaminoObligation.getRatesForObligation(
kaminoMarket,
obligation.deposits,
obligation.borrows,
res.context.slot
);
return new KaminoObligation(
kaminoMarket,
obligationAddress,
obligation,
collateralExchangeRates,
cumulativeBorrowRates
);
}
static async loadAll(
kaminoMarket: KaminoMarket,
obligationAddresses: Address[],
slot: Slot
): Promise<(KaminoObligation | null)[]> {
const currentSlot = slot;
const obligations = await Obligation.fetchMultiple(
kaminoMarket.getRpc(),
obligationAddresses,
kaminoMarket.programId
);
const cumulativeBorrowRates = new Map();
const collateralExchangeRates = new Map();
for (const obligation of obligations) {
if (obligation !== null) {
KaminoObligation.addRatesForObligation(
kaminoMarket,
obligation.deposits,
obligation.borrows,
collateralExchangeRates,
cumulativeBorrowRates,
currentSlot
);
}
}
return obligations.map((obligation, i) => {
if (obligation === null) {
return null;
}
return new KaminoObligation(
kaminoMarket,
obligationAddresses[i],
obligation,
collateralExchangeRates,
cumulativeBorrowRates
);
});
}
/**
* Construct a KaminoObligation from raw on-chain account data.
* Use this when you already have the account bytes (e.g., from a WebSocket
* notification) and don't want to make an RPC call.
*
* Decodes the obligation to find its lendingMarket, looks up the market
* from the provided map, computes rates, and returns the hydrated instance.
*
* Returns null if the obligation's market is not in the map.
* Throws if the data does not match the Obligation discriminator.
*/
static fromAccountData(
markets: Map,
obligationAddress: Address,
data: Buffer | Uint8Array,
slot: Slot
): KaminoObligation | null {
const decoded = Obligation.decode(toBuffer(data));
const market = markets.get(decoded.lendingMarket);
if (!market) return null;
const { collateralExchangeRates, cumulativeBorrowRates } = KaminoObligation.getRatesForObligation(
market,
decoded.deposits,
decoded.borrows,
slot
);
return new KaminoObligation(market, obligationAddress, decoded, collateralExchangeRates, cumulativeBorrowRates);
}
/**
* @returns the obligation borrows as a list
*/
getBorrows(): Array {
return [...this.borrows.values()];
}
/**
* @returns the obligation borrows as a list
*/
getDeposits(): Array {
return [...this.deposits.values()];
}
/**
* Returns obligation orders (including the null ones, i.e. non-active positions in the orders' array).
*/
getOrders(): Array {
return this.state.obligationOrders.map((order) => KaminoObligationOrder.fromState(order));
}
/**
* Returns active obligation orders (i.e. ones that *may* have their condition met).
*/
getActiveOrders(): Array {
return this.getOrders().filter((order) => order !== null);
}
/**
* @returns the total deposited value of the obligation (sum of all deposits)
*/
getDepositedValue(): Decimal {
return new Fraction(this.state.depositedValueSf).toDecimal();
}
/**
* @returns the total borrowed value of the obligation (sum of all borrows -- no borrow factor)
*/
getBorrowedMarketValue(): Decimal {
return new Fraction(this.state.borrowedAssetsMarketValueSf).toDecimal();
}
/**
* @returns the total borrowed value of the obligation (sum of all borrows -- with borrow factor weighting)
*/
getBorrowedMarketValueBFAdjusted(): Decimal {
return new Fraction(this.state.borrowFactorAdjustedDebtValueSf).toDecimal();
}
/**
* @returns total borrow power of the obligation, relative to max LTV of each asset's reserve
*/
getMaxAllowedBorrowValue(): Decimal {
return new Fraction(this.state.allowedBorrowValueSf).toDecimal();
}
/**
* @returns the borrow value at which the obligation gets liquidatable
* (relative to the liquidation threshold of each asset's reserve)
*/
getUnhealthyBorrowValue(): Decimal {
return new Fraction(this.state.unhealthyBorrowValueSf).toDecimal();
}
/**
*
* @returns Market value of the deposit in the specified obligation collateral/deposit asset (USD)
*/
getDepositMarketValue(deposit: ObligationCollateral): Decimal {
return new Fraction(deposit.marketValueSf).toDecimal();
}
getBorrowByReserve(reserve: Address): Position | undefined {
return this.borrows.get(reserve);
}
getDepositByReserve(reserve: Address): Position | undefined {
return this.deposits.get(reserve);
}
getBorrowsByMint(mint: Address): Position[] {
const positions: Position[] = [];
for (const value of this.borrows.values()) {
if (value.mintAddress === mint) {
positions.push(value);
}
}
return positions;
}
getBorrowAmountByReserve(reserve: KaminoReserve): Decimal {
const amountLamports = this.getBorrowByReserve(reserve.address)?.amount ?? new Decimal(0);
return amountLamports.div(reserve.getMintFactor());
}
getDepositsByMint(mint: Address): Position[] {
const positions: Position[] = [];
for (const value of this.deposits.values()) {
if (value.mintAddress === mint) {
positions.push(value);
}
}
return positions;
}
getDepositAmountByReserve(reserve: KaminoReserve): Decimal {
const amountLamports = this.getDepositByReserve(reserve.address)?.amount ?? new Decimal(0);
return amountLamports.div(reserve.getMintFactor());
}
/**
*
* @returns Market value of the borrow in the specified obligation liquidity/borrow asset (USD) (no borrow factor weighting)
*/
getBorrowMarketValue(borrow: ObligationLiquidity): Decimal {
return new Fraction(borrow.marketValueSf).toDecimal();
}
/**
*
* @returns Market value of the borrow in the specified obligation liquidity/borrow asset (USD) (with borrow factor weighting)
*/
getBorrowMarketValueBFAdjusted(borrow: ObligationLiquidity): Decimal {
return new Fraction(borrow.borrowFactorAdjustedMarketValueSf).toDecimal();
}
/**
* @param orderIdx - the slot to read, as taken by the on-chain instructions; `0` is the head order
* @returns the borrow order in that slot, active or not
* @throws if the obligation has no such slot
*/
getBorrowOrder(orderIdx: number): KaminoBorrowOrder {
if (orderIdx < 0 || orderIdx >= this.borrowOrders.length) {
throw new Error(`Borrow order index ${orderIdx} out of bounds (obligation has ${this.borrowOrders.length})`);
}
return this.borrowOrders[orderIdx];
}
/**
* @param currentTimestamp current unix time in seconds, used to tell which orders are still fillable
* @returns the borrow orders that still have debt left to fill and are still within their fillable deadline,
* each with the index it occupies
*
* An order past its deadline is left out even while the account still carries it: the program only zeroes
* expired orders when it next refreshes the obligation, so a state read before that refresh shows an order
* which nothing can fill any more.
*/
getActiveBorrowOrders(currentTimestamp: number): { orderIdx: number; borrowOrder: KaminoBorrowOrder }[] {
const timestamp = new BN(currentTimestamp);
return this.borrowOrders
.map((borrowOrder, orderIdx) => ({ orderIdx, borrowOrder }))
.filter(({ borrowOrder }) => borrowOrder.isActive() && !borrowOrder.isExpired(timestamp));
}
/**
* @param currentTimestamp current unix time in seconds, used to tell which orders are still fillable
* @returns the index of this obligation's only fillable borrow order — the order meant by an operation which
* did not name one
* @throws if no order is fillable, or if several are
*/
requireSoleActiveBorrowOrderIdx(currentTimestamp: number): number {
const activeOrders = this.getActiveBorrowOrders(currentTimestamp);
if (activeOrders.length === 0) {
throw new Error(`Obligation ${this.obligationAddress} has no active borrow order`);
}
if (activeOrders.length > 1) {
throw new Error(
`Obligation ${this.obligationAddress} has ${activeOrders.length} active borrow orders ` +
`(at indices ${activeOrders.map(({ orderIdx }) => orderIdx).join(', ')}); pass orderIdx to pick one`
);
}
return activeOrders[0].orderIdx;
}
/// computes the whole debt amount for the user receiving receivedBorrowAmount (debt amount without fees), including the fees
static getDebtWithFeesForBorrowAmount(
receivedBorrowAmount: Decimal,
market: KaminoMarket,
reserve: KaminoReserve,
hasReferrer: boolean
): Decimal {
const borrowFee = reserve.getBorrowFee();
const fees = reserve.calculateFees(
receivedBorrowAmount,
borrowFee,
FeeCalculation.Exclusive,
market.state.referralFeeBps,
hasReferrer
);
// The program rounds the origination fee to the nearest lamport (round half away from zero) before adding
// it to the borrowed amount (`calculate_borrow_exact`), so round here to mirror the exact on-chain debt
// rather than carrying the fractional fee.
const roundedFee = fees.protocolFees.add(fees.referrerFees).toDecimalPlaces(0, Decimal.ROUND_HALF_UP);
return receivedBorrowAmount.add(roundedFee);
}
/// computes the whole liquidity needed to fill the given borrow order, including the fees
static getBorrowOrderRemainingDebtAmountWithFees(
borrowOrder: KaminoBorrowOrder,
market: KaminoMarket,
reserve: KaminoReserve,
hasReferrer: boolean
): Decimal {
const orderDebtAmount = new Decimal(borrowOrder.remainingDebtAmount.toString());
return KaminoObligation.getDebtWithFeesForBorrowAmount(orderDebtAmount, market, reserve, hasReferrer);
}
/**
* Calculates the current ratio of borrowed value to deposited value (taking *all* deposits into account).
*
* Please note that the denominator here is different from the one found in `refreshedStats`:
* - the {@link ObligationStats#loanToValue} contains a value appropriate for display on the UI (i.e. taking into
* account *only* the deposits having `reserve.loanToValue > 0`).
* - the computation below follows the logic used by the KLend smart contract, and is appropriate e.g. for evaluating
* LTV-based obligation orders.
*/
loanToValue(): Decimal {
if (this.refreshedStats.userTotalDeposit.eq(0)) {
return new Decimal(0);
}
return this.refreshedStats.userTotalBorrowBorrowFactorAdjusted.div(this.refreshedStats.userTotalDeposit);
}
/**
* Calculates the ratio of borrowed value to deposited value (taking *all* deposits into account) at which the
* obligation is subject to liquidation.
*
* Please note that the denominator here is different from the one found in `refreshedStats`:
* - the {@link ObligationStats#liquidationLtv} contains a value appropriate for display on the UI (i.e. taking into
* account *only* the deposits having `reserve.liquidationLtv > 0`).
* - the computation below follows the logic used by the KLend smart contract, and is appropriate e.g. for evaluating
* LTV-based obligation orders.
*/
liquidationLtv(): Decimal {
if (this.refreshedStats.userTotalDeposit.eq(0)) {
return new Decimal(0);
}
return this.refreshedStats.borrowLiquidationLimit.div(this.refreshedStats.userTotalDeposit);
}
/**
* Calculate the current ratio of borrowed value to deposited value, disregarding the borrow factor.
*/
noBfLoanToValue(): Decimal {
if (this.refreshedStats.userTotalDeposit.eq(0)) {
return new Decimal(0);
}
return this.refreshedStats.userTotalBorrow.div(this.refreshedStats.userTotalDeposit);
}
/**
* @returns the total number of positions (deposits + borrows)
*/
getNumberOfPositions(): number {
return this.deposits.size + this.borrows.size;
}
getNetAccountValue(): Decimal {
return this.refreshedStats.netAccountValue;
}
getReferrer(): Option {
if (this.state.referrer === DEFAULT_PUBLIC_KEY) {
return none();
}
return some(this.state.referrer);
}
/**
* Get the loan to value and liquidation loan to value for a collateral token reserve as ratios, accounting for the obligation elevation group if it is active
*/
public getLtvForReserve(market: KaminoMarket, reserveAddress: Address): { maxLtv: Decimal; liquidationLtv: Decimal } {
return KaminoObligation.getLtvForReserve(
market,
market.getExistingReserveByAddress(reserveAddress),
this.state.elevationGroup
);
}
/**
* @returns the potential elevation groups the obligation qualifies for
*/
getElevationGroups(kaminoMarket: KaminoMarket): Array {
const reserves = new Map();
for (const deposit of this.state.deposits.values()) {
if (isNotNullPubkey(deposit.depositReserve) && !reserves.has(deposit.depositReserve)) {
reserves.set(
deposit.depositReserve,
kaminoMarket.getExistingReserveByAddress(deposit.depositReserve, 'Obligation deposit')
);
}
}
for (const borrow of this.state.borrows.values()) {
if (isNotNullPubkey(borrow.borrowReserve) && !reserves.has(borrow.borrowReserve)) {
reserves.set(
borrow.borrowReserve,
kaminoMarket.getExistingReserveByAddress(borrow.borrowReserve, 'Obligation borrow')
);
}
}
return KaminoObligation.getElevationGroupsForReserves([...reserves.values()]);
}
static getElevationGroupsForReserves(reserves: Array): Array {
const elevationGroupsCounts = new Map();
for (const reserve of reserves) {
for (const elevationGroup of reserve.state.config.elevationGroups) {
if (elevationGroup !== 0) {
const count = elevationGroupsCounts.get(elevationGroup);
if (count) {
elevationGroupsCounts.set(elevationGroup, count + 1);
} else {
elevationGroupsCounts.set(elevationGroup, 1);
}
}
}
}
const activeElevationGroups = new Array();
for (const [group, count] of elevationGroupsCounts.entries()) {
if (count === reserves.length) {
activeElevationGroups.push(group);
}
}
return activeElevationGroups;
}
static simulateDepositChange(
obligationDeposits: ObligationCollateral[],
depositChange: PositionChange,
collateralExchangeRates: Map
): ObligationCollateral[] {
const newDeposits: ObligationCollateral[] = [];
const depositIndex = obligationDeposits.findIndex(
(deposit) => deposit.depositReserve === depositChange.reserveAddress
);
// Always copy the previous deposits and modify the changeReserve one if it exists
for (let i = 0; i < obligationDeposits.length; i++) {
if (obligationDeposits[i].depositReserve === depositChange.reserveAddress) {
const coll: ObligationCollateralFields = { ...obligationDeposits[i] };
const exchangeRate = collateralExchangeRates.get(depositChange.reserveAddress)!;
const changeInCollateral = new Decimal(depositChange.amountChangeLamports).mul(exchangeRate).toFixed(0);
const updatedDeposit = new Decimal(obligationDeposits[i].depositedAmount.toNumber()).add(changeInCollateral);
coll.depositedAmount = new BN(positiveOrZero(updatedDeposit).toString());
newDeposits.push(new ObligationCollateral(coll));
} else {
newDeposits.push(obligationDeposits[i]);
}
}
if (depositIndex === -1) {
// If the reserve is not in the obligation, we add it
const firstBorrowIndexAvailable = obligationDeposits.findIndex(
(deposit) => deposit.depositReserve === DEFAULT_PUBLIC_KEY
);
if (firstBorrowIndexAvailable === -1) {
throw new Error('No available borrows to modify');
}
const coll: ObligationCollateralFields = { ...obligationDeposits[firstBorrowIndexAvailable] };
const exchangeRate = collateralExchangeRates.get(depositChange.reserveAddress)!;
const changeInCollateral = new Decimal(depositChange.amountChangeLamports).mul(exchangeRate).toFixed(0);
coll.depositedAmount = new BN(positiveOrZero(new Decimal(changeInCollateral)).toString());
coll.depositReserve = depositChange.reserveAddress;
newDeposits[firstBorrowIndexAvailable] = new ObligationCollateral(coll);
}
return newDeposits;
}
static simulateBorrowChange(
obligationBorrows: ObligationLiquidity[],
borrowChange: PositionChange,
cumulativeBorrowRate: Decimal
): ObligationLiquidity[] {
const newBorrows: ObligationLiquidity[] = [];
const borrowIndex = obligationBorrows.findIndex((borrow) => borrow.borrowReserve === borrowChange.reserveAddress);
// Always copy the previous borrows and modify the changeReserve one if it exists
for (let i = 0; i < obligationBorrows.length; i++) {
if (obligationBorrows[i].borrowReserve === borrowChange.reserveAddress) {
const borrow: ObligationLiquidityFields = { ...obligationBorrows[borrowIndex] };
const newBorrowedAmount: Decimal = new Fraction(borrow.borrowedAmountSf)
.toDecimal()
.add(borrowChange.amountChangeLamports);
const newBorrowedAmountSf = Fraction.fromDecimal(positiveOrZero(newBorrowedAmount)).getValue();
borrow.borrowedAmountSf = newBorrowedAmountSf;
newBorrows.push(new ObligationLiquidity(borrow));
} else {
newBorrows.push(obligationBorrows[i]);
}
}
if (borrowIndex === -1) {
// If the reserve is not in the obligation, we add it
const firstBorrowIndexAvailable = obligationBorrows.findIndex(
(borrow) => borrow.borrowReserve === DEFAULT_PUBLIC_KEY
);
if (firstBorrowIndexAvailable === -1) {
throw new Error('No available borrows to modify');
}
const borrow: ObligationLiquidityFields = { ...obligationBorrows[firstBorrowIndexAvailable] };
borrow.borrowedAmountSf = Fraction.fromDecimal(new Decimal(borrowChange.amountChangeLamports)).getValue();
borrow.borrowReserve = borrowChange.reserveAddress;
borrow.cumulativeBorrowRateBsf = {
padding: [],
value: [Fraction.fromDecimal(cumulativeBorrowRate).getValue(), new BN(0), new BN(0), new BN(0)],
};
newBorrows[firstBorrowIndexAvailable] = new ObligationLiquidity(borrow);
}
return newBorrows;
}
/**
* Calculate the newly modified stats of the obligation
*/
// TODO: Shall we set up position limits?
getSimulatedObligationStats(params: {
amountCollateral?: Decimal;
amountDebt?: Decimal;
action: ActionType;
collateralReserveAddress?: Address;
debtReserveAddress?: Address;
market: KaminoMarket;
reserves: Map;
slot: Slot;
elevationGroupOverride?: number;
}): {
stats: ObligationStats;
deposits: Map;
borrows: Map;
} {
return KaminoObligation.simulateObligationStats({
...params,
baseDeposits: this.state.deposits,
baseBorrows: this.state.borrows,
elevationGroup: params.elevationGroupOverride ?? this.state.elevationGroup,
});
}
/**
* Core static helper: simulates an action on explicit obligation state arrays.
* All simulation methods delegate to this.
*/
static simulateObligationStats(params: {
baseDeposits: ObligationCollateral[];
baseBorrows: ObligationLiquidity[];
elevationGroup: number;
amountCollateral?: Decimal;
amountDebt?: Decimal;
action: ActionType;
collateralReserveAddress?: Address;
debtReserveAddress?: Address;
market: KaminoMarket;
slot: Slot;
}): {
stats: ObligationStats;
deposits: Map;
borrows: Map;
} {
const {
baseDeposits,
baseBorrows,
elevationGroup,
amountCollateral,
amountDebt,
action,
collateralReserveAddress,
debtReserveAddress,
market,
slot,
} = params;
const additionalReserves: Address[] = [];
if (collateralReserveAddress !== undefined) {
additionalReserves.push(collateralReserveAddress);
}
if (debtReserveAddress !== undefined) {
additionalReserves.push(debtReserveAddress);
}
const { collateralExchangeRates } = KaminoObligation.getRatesForObligation(
market,
baseDeposits,
baseBorrows,
slot,
additionalReserves
);
// Any action can impact both deposit stats and borrow stats if elevation group is changed
// so we have to recalculate the entire position, not just an updated deposit or borrow
// as both LTVs and borrow factors can change, affecting all calcs
const debtReserveCumulativeBorrowRate = debtReserveAddress
? market.getExistingReserveByAddress(debtReserveAddress).getCumulativeBorrowRate()
: undefined;
let newObligationDeposits = baseDeposits;
let newObligationBorrows = baseBorrows;
switch (action) {
case 'deposit': {
if (amountCollateral === undefined || collateralReserveAddress === undefined) {
throw Error('amountCollateral & collateralReserveAddress are required for deposit action');
}
newObligationDeposits = KaminoObligation.simulateDepositChange(
baseDeposits,
{
reserveAddress: collateralReserveAddress,
amountChangeLamports: amountCollateral,
},
collateralExchangeRates
);
break;
}
case 'borrow': {
if (amountDebt === undefined || debtReserveAddress === undefined) {
throw Error('amountDebt & debtReserveAddress are required for borrow action');
}
newObligationBorrows = KaminoObligation.simulateBorrowChange(
baseBorrows,
{
reserveAddress: debtReserveAddress,
amountChangeLamports: amountDebt,
},
debtReserveCumulativeBorrowRate!
);
break;
}
case 'repay': {
if (amountDebt === undefined || debtReserveAddress === undefined) {
throw Error('amountDebt & debtReserveAddress are required for repay action');
}
newObligationBorrows = KaminoObligation.simulateBorrowChange(
baseBorrows,
{
reserveAddress: debtReserveAddress,
amountChangeLamports: amountDebt.neg(),
},
debtReserveCumulativeBorrowRate!
);
break;
}
case 'withdraw': {
if (amountCollateral === undefined || collateralReserveAddress === undefined) {
throw Error('amountCollateral & collateralReserveAddress are required for withdraw action');
}
newObligationDeposits = KaminoObligation.simulateDepositChange(
baseDeposits,
{
reserveAddress: collateralReserveAddress,
amountChangeLamports: amountCollateral.neg(),
},
collateralExchangeRates
);
break;
}
case 'depositAndBorrow': {
if (
amountCollateral === undefined ||
amountDebt === undefined ||
collateralReserveAddress === undefined ||
debtReserveAddress === undefined
) {
throw Error(
'amountColl & amountDebt & collateralReserveAddress & debtReserveAddress are required for depositAndBorrow action'
);
}
newObligationDeposits = KaminoObligation.simulateDepositChange(
baseDeposits,
{
reserveAddress: collateralReserveAddress,
amountChangeLamports: amountCollateral,
},
collateralExchangeRates
);
newObligationBorrows = KaminoObligation.simulateBorrowChange(
baseBorrows,
{
reserveAddress: debtReserveAddress,
amountChangeLamports: amountDebt,
},
debtReserveCumulativeBorrowRate!
);
break;
}
case 'repayAndWithdraw': {
if (
amountCollateral === undefined ||
amountDebt === undefined ||
collateralReserveAddress === undefined ||
debtReserveAddress === undefined
) {
throw Error(
'amountColl & amountDebt & collateralReserveAddress & debtReserveAddress are required for repayAndWithdraw action'
);
}
newObligationDeposits = KaminoObligation.simulateDepositChange(
baseDeposits,
{
reserveAddress: collateralReserveAddress,
amountChangeLamports: amountCollateral.neg(),
},
collateralExchangeRates
);
newObligationBorrows = KaminoObligation.simulateBorrowChange(
baseBorrows,
{
reserveAddress: debtReserveAddress,
amountChangeLamports: amountDebt.neg(),
},
debtReserveCumulativeBorrowRate!
);
break;
}
default: {
throw Error(`Invalid action type ${action} for simulateObligationStats`);
}
}
const { borrows, deposits, refreshedStats } = KaminoObligation.calculatePositions(
market,
newObligationDeposits,
newObligationBorrows,
elevationGroup,
collateralExchangeRates,
null
);
refreshedStats.netAccountValue = refreshedStats.userTotalDeposit.minus(refreshedStats.userTotalBorrow);
refreshedStats.loanToValue = valueOrZero(
refreshedStats.userTotalBorrowBorrowFactorAdjusted.dividedBy(refreshedStats.userTotalCollateralDeposit)
);
refreshedStats.leverage = valueOrZero(refreshedStats.userTotalDeposit.dividedBy(refreshedStats.netAccountValue));
return {
stats: refreshedStats,
deposits,
borrows,
};
}
/**
* Simulate obligation stats for a deposit + borrow order fill when no obligation exists yet.
*
* Starts from empty obligation state, applies the deposit, then simulates the borrow order
* fill across all compatible reserves (same worst-case logic as getSimulatedObligationStatsForBorrowOrderFill).
*
* Useful in the UI when the user fills in a "deposit collateral + create borrow order" form
* and wants to see the projected LTV before submitting.
*/
static getSimulatedObligationStatsForDepositAndBorrowOrderFill(params: {
borrowOrder: KaminoBorrowOrder;
market: KaminoMarket;
slot: Slot;
currentTimestamp: number;
depositReserveAddress: Address;
depositAmountLamports: Decimal;
elevationGroupOverride?: number;
}): {
stats: ObligationStats;
deposits: Map;
borrows: Map;
} {
const { market, slot, depositReserveAddress, depositAmountLamports } = params;
const elevationGroup = params.elevationGroupOverride ?? 0;
// Start from empty state, apply the deposit
const emptyDeposits = KaminoObligation.emptyObligationDeposits();
const emptyBorrows = KaminoObligation.emptyObligationBorrows();
const { collateralExchangeRates } = KaminoObligation.getRatesForObligation(
market,
emptyDeposits,
emptyBorrows,
slot,
[depositReserveAddress]
);
const depositsAfterDeposit = KaminoObligation.simulateDepositChange(
emptyDeposits,
{ reserveAddress: depositReserveAddress, amountChangeLamports: depositAmountLamports },
collateralExchangeRates
);
return KaminoObligation.simulateBorrowOrderFillOnState({
baseDeposits: depositsAfterDeposit,
baseBorrows: emptyBorrows,
elevationGroup,
borrowOrder: params.borrowOrder,
market,
slot,
currentTimestamp: params.currentTimestamp,
});
}
/**
* Simulate obligation stats for a borrow order fill across all compatible reserves,
* returning the worst-case (highest LTV) result.
*
* This is useful when the exact fill reserve is unknown (e.g., for fixed-rate borrow orders
* where multiple reserves of the same mint exist and the filler bot picks one at fill time).
*/
getSimulatedObligationStatsForBorrowOrderFill(params: {
borrowOrder: KaminoBorrowOrder;
market: KaminoMarket;
slot: Slot;
currentTimestamp: number;
elevationGroupOverride?: number;
}): {
stats: ObligationStats;
deposits: Map;
borrows: Map;
} {
return KaminoObligation.simulateBorrowOrderFillOnState({
baseDeposits: this.state.deposits,
baseBorrows: this.state.borrows,
elevationGroup: params.elevationGroupOverride ?? this.state.elevationGroup,
...params,
});
}
/**
* Returns the reserves of the order's debt mint that can fill it, mirroring the on-chain
* `fill_borrow_order` term + rate gates:
* - rate gate: the reserve's peak borrow rate must be `<=` the order's max rate;
* - term gate (`is_term_satisfied`): an open-term order is fillable only by an open-term reserve, while a
* fixed-term order (min term M) is fillable by an open-term reserve or by a fixed/maturity-term reserve whose
* remaining term is `>= M`. The reserve's remaining term is the shortest active cap among its configured
* `debtTermSeconds` and/or the seconds until its `debtMaturityTimestamp` (a reserve whose maturity has already
* passed is excluded).
*
* @param currentTimestamp current unix time in seconds, used to compute the remaining term until maturity.
*/
static getCompatibleBorrowOrderFillReserves(
market: KaminoMarket,
borrowOrder: KaminoBorrowOrder,
currentTimestamp: number
): KaminoReserve[] {
const orderMinTerm = borrowOrder.minDebtTermSeconds.eqn(0) ? undefined : borrowOrder.minDebtTermSeconds;
return market.getReservesByMint(borrowOrder.debtLiquidityMint).filter((reserve) => {
// Rate gate: the reserve's peak curve rate must be <= the order's max rate.
if (reserve.getMaxBorrowRateBps() > borrowOrder.maxBorrowRateBps) {
return false;
}
// Term gate: is_term_satisfied(orderMinTerm, reserveRemainingTerm), where undefined means open-term.
const remainingTerm = reserve.getRemainingDebtTermSeconds(currentTimestamp);
if (orderMinTerm === undefined) {
// Open-term order: fillable only by an open-term reserve.
return remainingTerm === undefined;
}
// Fixed-term order: an open-term reserve is fine; otherwise the reserve's remaining term must cover it.
return remainingTerm === undefined || orderMinTerm.lte(remainingTerm);
});
}
/**
* Selects the reserve to fill a borrow order from, among those that can fill it on-chain
* (see {@link getCompatibleBorrowOrderFillReserves}), applying the lender-favorable policy used by the
* deposit-and-fill flow:
* - a fixed-term order is filled only from a fixed/maturity-term reserve, never an open-term (float) reserve,
* even though the on-chain term gate would accept one (a fixed-term order wants a fixed-rate loan);
* - among the eligible reserves, the highest peak borrow rate wins, tie-broken by the shortest remaining term
* (the shortest active cap among configured term and/or seconds until maturity).
*
* @param currentTimestamp current unix time in seconds, used to compute the remaining term until maturity.
* @returns the selected reserve, or `undefined` if no reserve can fill the order.
*/
static selectBorrowOrderFillReserve(
market: KaminoMarket,
borrowOrder: KaminoBorrowOrder,
currentTimestamp: number
): KaminoReserve | undefined {
const candidates = KaminoObligation.getCompatibleBorrowOrderFillReserves(market, borrowOrder, currentTimestamp)
.filter((reserve) => !(borrowOrder.isFixedTerm() && reserve.getKind().isFloatRate()))
.map((reserve) => ({
reserve,
rateBps: reserve.getMaxBorrowRateBps(),
remainingTermSeconds: reserve.getRemainingDebtTermSeconds(currentTimestamp),
}));
const isBetterCandidate = (candidate: (typeof candidates)[number], current: (typeof candidates)[number]) => {
if (candidate.rateBps !== current.rateBps) {
return candidate.rateBps > current.rateBps;
}
// An undefined remaining term means open-term (no fixed end), which we treat as the longest possible term so
// it never wins the shortest-term tie-break.
if (candidate.remainingTermSeconds === undefined) {
return false;
}
return (
current.remainingTermSeconds === undefined || candidate.remainingTermSeconds.lt(current.remainingTermSeconds)
);
};
return candidates.reduce<(typeof candidates)[number] | undefined>((selected, candidate) => {
return selected === undefined || isBetterCandidate(candidate, selected) ? candidate : selected;
}, undefined)?.reserve;
}
/**
* Core static helper for borrow order fill simulation.
* Filters compatible reserves, simulates the borrow on each, returns worst-case (highest LTV).
*/
private static simulateBorrowOrderFillOnState(params: {
baseDeposits: ObligationCollateral[];
baseBorrows: ObligationLiquidity[];
elevationGroup: number;
borrowOrder: KaminoBorrowOrder;
market: KaminoMarket;
slot: Slot;
currentTimestamp: number;
}): {
stats: ObligationStats;
deposits: Map;
borrows: Map;
} {
const { baseDeposits, baseBorrows, elevationGroup, borrowOrder, market, slot, currentTimestamp } = params;
const compatibleReserves = KaminoObligation.getCompatibleBorrowOrderFillReserves(
market,
borrowOrder,
currentTimestamp
);
if (compatibleReserves.length === 0) {
throw new Error('No compatible reserves found for borrow order fill simulation');
}
const amountDebt = new Decimal(borrowOrder.remainingDebtAmount.toString());
// Simulate for each compatible reserve and return worst-case (highest LTV)
let worstCase:
| {
stats: ObligationStats;
deposits: Map;
borrows: Map;
}
| undefined;
for (const reserve of compatibleReserves) {
const result = KaminoObligation.simulateObligationStats({
baseDeposits,
baseBorrows,
elevationGroup,
amountDebt,
action: 'borrow',
debtReserveAddress: reserve.address,
market,
slot,
});
if (worstCase === undefined || result.stats.loanToValue.gt(worstCase.stats.loanToValue)) {
worstCase = result;
}
}
return worstCase!;
}
private static emptyObligationState(): Obligation {
return Obligation.decode(Buffer.concat([Obligation.discriminator, Buffer.alloc(Obligation.layout.span)]));
}
private static emptyObligationDeposits(): ObligationCollateral[] {
return KaminoObligation.emptyObligationState().deposits;
}
private static emptyObligationBorrows(): ObligationLiquidity[] {
return KaminoObligation.emptyObligationState().borrows;
}
/**
* Calculates the stats of the obligation after a hypothetical collateral swap.
*/
getPostSwapCollObligationStats(params: {
withdrawAmountLamports: Decimal;
withdrawReserveAddress: Address;
depositAmountLamports: Decimal;
depositReserveAddress: Address;
borrowAmountLamports?: Decimal;
borrowReserveAddress?: Address;
newElevationGroup: number;
market: KaminoMarket;
slot: Slot;
}): ObligationStats {
const {
withdrawAmountLamports,
withdrawReserveAddress,
depositAmountLamports,
depositReserveAddress,
borrowAmountLamports,
borrowReserveAddress,
newElevationGroup,
market,
slot,
} = params;
const additionalReserves = [withdrawReserveAddress, depositReserveAddress, borrowReserveAddress]
.filter((reserveAddress): reserveAddress is Address => reserveAddress !== undefined)
.filter((reserveAddress) => !market.isReserveInObligation(this, reserveAddress));
const { collateralExchangeRates } = KaminoObligation.getRatesForObligation(
market,
this.state.deposits,
this.state.borrows,
slot,
additionalReserves
);
let newObligationDeposits = this.state.deposits;
newObligationDeposits = KaminoObligation.simulateDepositChange(
newObligationDeposits,
{
reserveAddress: withdrawReserveAddress,
amountChangeLamports: withdrawAmountLamports.neg(),
},
collateralExchangeRates
);
newObligationDeposits = KaminoObligation.simulateDepositChange(
newObligationDeposits,
{
reserveAddress: depositReserveAddress,
amountChangeLamports: depositAmountLamports,
},
collateralExchangeRates
);
let newObligationBorrows = this.state.borrows;
if (borrowAmountLamports && borrowReserveAddress && !borrowAmountLamports.isZero()) {
const borrowReserve = market.getReserveByAddress(borrowReserveAddress);
if (!borrowReserve) {
throw new Error(`Borrow reserve not found: ${borrowReserveAddress}`);
}
newObligationBorrows = KaminoObligation.simulateBorrowChange(
newObligationBorrows,
{
reserveAddress: borrowReserveAddress,
amountChangeLamports: borrowAmountLamports,
},
borrowReserve.getCumulativeBorrowRate()
);
}
const { refreshedStats } = KaminoObligation.calculatePositions(
market,
newObligationDeposits,
newObligationBorrows,
newElevationGroup,
collateralExchangeRates,
null
);
return finalizeSwapObligationStats(refreshedStats);
}
/**
* Calculates the stats of the obligation after a hypothetical debt swap.
*/
getPostSwapDebtObligationStats(params: {
repayAmountLamports: Decimal;
repayReserveAddress: Address;
borrowAmountLamports: Decimal;
borrowReserveAddress: Address;
newElevationGroup: number;
market: KaminoMarket;
slot: Slot;
}): ObligationStats {
const {
repayAmountLamports,
repayReserveAddress,
borrowAmountLamports,
borrowReserveAddress,
newElevationGroup,
market,
slot,
} = params;
const additionalReserves = [repayReserveAddress, borrowReserveAddress].filter(
(reserveAddress) => !market.isReserveInObligation(this, reserveAddress)
);
const { collateralExchangeRates } = KaminoObligation.getRatesForObligation(
market,
this.state.deposits,
this.state.borrows,
slot,
additionalReserves
);
const repayReserve = market.getReserveByAddress(repayReserveAddress);
if (!repayReserve) {
throw new Error(`Repay reserve not found: ${repayReserveAddress}`);
}
const borrowReserve = market.getReserveByAddress(borrowReserveAddress);
if (!borrowReserve) {
throw new Error(`Borrow reserve not found: ${borrowReserveAddress}`);
}
let newObligationBorrows = this.state.borrows;
newObligationBorrows = KaminoObligation.simulateBorrowChange(
newObligationBorrows,
{
reserveAddress: repayReserveAddress,
amountChangeLamports: repayAmountLamports.neg(),
},
repayReserve.getCumulativeBorrowRate()
);
newObligationBorrows = KaminoObligation.simulateBorrowChange(
newObligationBorrows,
{
reserveAddress: borrowReserveAddress,
amountChangeLamports: borrowAmountLamports,
},
borrowReserve.getCumulativeBorrowRate()
);
const { refreshedStats } = KaminoObligation.calculatePositions(
market,
this.state.deposits,
newObligationBorrows,
newElevationGroup,
collateralExchangeRates,
null
);
return finalizeSwapObligationStats(refreshedStats);
}
estimateObligationInterestRate = (
market: KaminoMarket,
reserve: KaminoReserve,
borrow: ObligationLiquidity,
currentSlot: Slot
): Decimal => {
const newCumulativeBorrowRate = reserve.getEstimatedCumulativeBorrowRate(currentSlot, market.state.referralFeeBps);
const formerCumulativeBorrowRate = KaminoObligation.getCumulativeBorrowRate(borrow);
if (newCumulativeBorrowRate.gt(formerCumulativeBorrowRate)) {
return newCumulativeBorrowRate.div(formerCumulativeBorrowRate);
}
return new Decimal(0);
};
static getOraclePx = (reserve: KaminoReserve) => {
return reserve.getOracleMarketPrice();
};
static calculatePositions(
market: KaminoMarket,
obligationDeposits: ObligationCollateral[],
obligationBorrows: ObligationLiquidity[],
elevationGroup: number,
collateralExchangeRates: Map,
cumulativeBorrowRates: Map | null,
getOraclePx: (reserve: KaminoReserve) => Decimal = KaminoObligation.getOraclePx
): {
borrows: Map;
deposits: Map;
refreshedStats: ObligationStats;
} {
const depositStatsOraclePrice = KaminoObligation.calculateObligationDeposits(
market,
obligationDeposits,
collateralExchangeRates,
elevationGroup,
getOraclePx
);
const borrowStatsOraclePrice = KaminoObligation.calculateObligationBorrows(
market,
obligationBorrows,
cumulativeBorrowRates,
elevationGroup,
getOraclePx
);
const netAccountValueScopeRefreshed = depositStatsOraclePrice.userTotalDeposit.minus(
borrowStatsOraclePrice.userTotalBorrow
);
// TODO: Fix this?
const potentialElevationGroupUpdate = 0;
return {
deposits: depositStatsOraclePrice.deposits,
borrows: borrowStatsOraclePrice.borrows,
refreshedStats: {
borrowLimit: depositStatsOraclePrice.borrowLimit,
borrowLiquidationLimit: depositStatsOraclePrice.borrowLiquidationLimit,
userTotalBorrow: borrowStatsOraclePrice.userTotalBorrow,
userTotalBorrowBorrowFactorAdjusted: borrowStatsOraclePrice.userTotalBorrowBorrowFactorAdjusted,
userTotalDeposit: depositStatsOraclePrice.userTotalDeposit,
userTotalCollateralDeposit: depositStatsOraclePrice.userTotalCollateralDeposit,
userTotalLiquidatableDeposit: depositStatsOraclePrice.userTotalLiquidatableDeposit,
liquidationLtv: depositStatsOraclePrice.liquidationLtv,
borrowUtilization: borrowStatsOraclePrice.userTotalBorrowBorrowFactorAdjusted.dividedBy(
depositStatsOraclePrice.borrowLimit
),
netAccountValue: netAccountValueScopeRefreshed,
leverage: depositStatsOraclePrice.userTotalDeposit.dividedBy(netAccountValueScopeRefreshed),
loanToValue: borrowStatsOraclePrice.userTotalBorrowBorrowFactorAdjusted.dividedBy(
depositStatsOraclePrice.userTotalCollateralDeposit
),
potentialElevationGroupUpdate,
},
};
}
public static calculateObligationDeposits(
market: KaminoMarket,
obligationDeposits: ObligationCollateral[],
collateralExchangeRates: Map | null,
elevationGroup: number,
getPx: (reserve: KaminoReserve) => Decimal
): DepositStats {
let userTotalDeposit = new Decimal(0);
let userTotalCollateralDeposit = new Decimal(0);
let userTotalLiquidatableDeposit = new Decimal(0);
let borrowLimit = new Decimal(0);
let borrowLiquidationLimit = new Decimal(0);
const deposits = new Map();
for (let i = 0; i < obligationDeposits.length; i++) {
if (!isNotNullPubkey(obligationDeposits[i].depositReserve)) {
continue;
}
const deposit = obligationDeposits[i];
const reserve = market.getReserveByAddress(deposit.depositReserve);
if (!reserve) {
throw new Error(
`Obligation contains a deposit belonging to reserve: ${deposit.depositReserve} but the reserve was not found on the market. Deposit amount: ${deposit.depositedAmount}`
);
}
const { maxLtv, liquidationLtv } = KaminoObligation.getLtvForReserve(market, reserve, elevationGroup);
let exchangeRate: Decimal;
if (collateralExchangeRates !== null) {
exchangeRate = collateralExchangeRates.get(reserve.address)!;
} else {
exchangeRate = reserve.getCollateralExchangeRate();
}
const supplyAmount = new Decimal(deposit.depositedAmount.toString()).div(exchangeRate);
const depositValueUsd = supplyAmount.mul(getPx(reserve)).div(reserve.getMintFactor());
userTotalDeposit = userTotalDeposit.add(depositValueUsd);
if (!maxLtv.eq('0')) {
userTotalCollateralDeposit = userTotalCollateralDeposit.add(depositValueUsd);
}
if (!liquidationLtv.eq('0')) {
userTotalLiquidatableDeposit = userTotalLiquidatableDeposit.add(depositValueUsd);
}
borrowLimit = borrowLimit.add(depositValueUsd.mul(maxLtv));
borrowLiquidationLimit = borrowLiquidationLimit.add(depositValueUsd.mul(liquidationLtv));
const position: Position = {
reserveAddress: reserve.address,
mintAddress: reserve.getLiquidityMint(),
mintFactor: reserve.getMintFactor(),
amount: supplyAmount,
marketValueRefreshed: depositValueUsd,
};
deposits.set(reserve.address, position);
}
return {
deposits,
userTotalDeposit,
userTotalCollateralDeposit,
userTotalLiquidatableDeposit,
borrowLimit,
liquidationLtv: valueOrZero(borrowLiquidationLimit.div(userTotalLiquidatableDeposit)),
borrowLiquidationLimit,
};
}
public static calculateObligationBorrows(
market: KaminoMarket,
obligationBorrows: ObligationLiquidity[],
cumulativeBorrowRates: Map | null,
elevationGroup: number,
getPx: (reserve: KaminoReserve) => Decimal
): BorrowStats {
let userTotalBorrow = new Decimal(0);
let userTotalBorrowBorrowFactorAdjusted = new Decimal(0);
let positions = 0;
const borrows = new Map();
for (let i = 0; i < obligationBorrows.length; i++) {
if (!isNotNullPubkey(obligationBorrows[i].borrowReserve)) {
continue;
}
const borrow = obligationBorrows[i];
const reserve = market.getReserveByAddress(borrow.borrowReserve);
if (!reserve) {
throw new Error(
`Obligation contains a borrow belonging to reserve: ${
borrow.borrowReserve
} but the reserve was not found on the market. Borrow amount: ${KaminoObligation.getBorrowAmount(borrow)}`
);
}
const obligationCumulativeBorrowRate = KaminoObligation.getCumulativeBorrowRate(borrow);
let cumulativeBorrowRate;
if (cumulativeBorrowRates !== null) {
cumulativeBorrowRate = cumulativeBorrowRates.get(reserve.address)!;
} else {
cumulativeBorrowRate = reserve.getCumulativeBorrowRate();
}
const borrowAmount = KaminoObligation.getBorrowAmount(borrow)
.mul(cumulativeBorrowRate)
.dividedBy(obligationCumulativeBorrowRate);
const borrowValueUsd = borrowAmount.mul(getPx(reserve)).dividedBy(reserve.getMintFactor());
const borrowFactor = KaminoObligation.getBorrowFactorForReserve(reserve, elevationGroup);
const borrowValueBorrowFactorAdjustedUsd = borrowValueUsd.mul(borrowFactor);
if (!borrowAmount.eq(new Decimal('0'))) {
positions += 1;
}
userTotalBorrow = userTotalBorrow.plus(borrowValueUsd);
userTotalBorrowBorrowFactorAdjusted = userTotalBorrowBorrowFactorAdjusted.plus(
borrowValueBorrowFactorAdjustedUsd
);
const position: Position = {
reserveAddress: reserve.address,
mintAddress: reserve.getLiquidityMint(),
mintFactor: reserve.getMintFactor(),
amount: borrowAmount,
marketValueRefreshed: borrowValueUsd,
};
borrows.set(reserve.address, position);
}
return {
borrows,
userTotalBorrow,
userTotalBorrowBorrowFactorAdjusted,
positions,
};
}
getMaxLoanLtvAndLiquidationLtvGivenElevationGroup(
market: KaminoMarket,
elevationGroup: number,
slot: Slot
): { maxLtv: Decimal; liquidationLtv: Decimal } {
const getOraclePx = (reserve: KaminoReserve) => reserve.getOracleMarketPrice();
const { collateralExchangeRates } = KaminoObligation.getRatesForObligation(
market,
this.state.deposits,
this.state.borrows,
slot
);
const { borrowLimit, userTotalCollateralDeposit, borrowLiquidationLimit } =
KaminoObligation.calculateObligationDeposits(
market,
this.state.deposits,
collateralExchangeRates,
elevationGroup,
getOraclePx
);
if (userTotalCollateralDeposit.eq(0)) {
return { maxLtv: new Decimal(0), liquidationLtv: new Decimal(0) };
}
return {
maxLtv: borrowLimit.div(userTotalCollateralDeposit),
liquidationLtv: borrowLiquidationLimit.div(userTotalCollateralDeposit),
};
}
/**
* Creates a new KaminoObligation with simulated position changes applied.
* This allows you to model what the obligation would look like with deposits/borrows
* without actually executing those transactions.
*
* @param market - The KaminoMarket instance
* @param slot - The slot number for rate calculations
* @param depositChanges - Optional array of deposit changes to apply
* @param borrowChanges - Optional array of borrow changes to apply
* @returns A new KaminoObligation instance with the changes applied
*/
withPositionChanges(
market: KaminoMarket,
slot: Slot,
depositChanges?: PositionChange[],
borrowChanges?: PositionChange[]
): KaminoObligation {
const reservesToRefresh: Address[] = [];
if (depositChanges) {
reservesToRefresh.push(...depositChanges.map((change) => change.reserveAddress));
}
if (borrowChanges) {
reservesToRefresh.push(...borrowChanges.map((change) => change.reserveAddress));
}
const { collateralExchangeRates, cumulativeBorrowRates } = KaminoObligation.getRatesForObligation(
market,
this.state.deposits,
this.state.borrows,
slot,
reservesToRefresh
);
let newDeposits: ObligationCollateral[] = this.state.deposits;
if (depositChanges) {
for (const depositChange of depositChanges) {
newDeposits = KaminoObligation.simulateDepositChange(newDeposits, depositChange, collateralExchangeRates);
}
}
let newBorrows: ObligationLiquidity[] = this.state.borrows;
if (borrowChanges) {
for (const borrowChange of borrowChanges) {
const reserve = market.getReserveByAddress(borrowChange.reserveAddress);
if (!reserve) {
throw new Error(`Reserve not found: ${borrowChange.reserveAddress}`);
}
newBorrows = KaminoObligation.simulateBorrowChange(newBorrows, borrowChange, reserve.getCumulativeBorrowRate());
}
}
// Create a deep copy of the obligation state and override deposits/borrows
const newObligationState = new Obligation({
...this.state,
deposits: newDeposits,
borrows: newBorrows,
});
return new KaminoObligation(
market,
this.obligationAddress,
newObligationState,
collateralExchangeRates,
cumulativeBorrowRates
);
}
/*
How much of a given token can a user borrow extra given an elevation group,
regardless of caps and liquidity or assuming infinite liquidity and infinite caps,
until it hits max LTV.
This is purely a function about the borrow power of an obligation,
not a reserve-specific, caps-specific, liquidity-specific function.
* @param market - The KaminoMarket instance.
* @param liquidityReserveAddress - The liquidity reserve Address.
* @param slot - The slot number.
* @param elevationGroup - The elevation group number (default: this.state.elevationGroup).
* @returns The borrow power as a Decimal.
* @throws Error if the reserve is not found.
*/
getBorrowPower(
market: KaminoMarket,
liquidityReserveAddress: Address,
slot: Slot,
elevationGroup: number = this.state.elevationGroup
): Decimal {
const reserve = market.getReserveByAddress(liquidityReserveAddress);
if (!reserve) {
throw new Error('Reserve not found');
}
const elevationGroupActivated =
reserve.state.config.elevationGroups.includes(elevationGroup) && elevationGroup !== 0;
const borrowFactor = KaminoObligation.getBorrowFactorForReserve(reserve, elevationGroup);
const getOraclePx = (reserve: KaminoReserve) => reserve.getOracleMarketPrice();
const { collateralExchangeRates, cumulativeBorrowRates } = KaminoObligation.getRatesForObligation(
market,
this.state.deposits,
this.state.borrows,
slot
);
const { borrowLimit } = KaminoObligation.calculateObligationDeposits(
market,
this.state.deposits,
collateralExchangeRates,
elevationGroup,
getOraclePx
);
const { userTotalBorrowBorrowFactorAdjusted } = KaminoObligation.calculateObligationBorrows(
market,
this.state.borrows,
cumulativeBorrowRates,
elevationGroup,
getOraclePx
);
const maxObligationBorrowPower = borrowLimit // adjusted available amount
.minus(userTotalBorrowBorrowFactorAdjusted)
.div(borrowFactor)
.div(reserve.getOracleMarketPrice())
.mul(reserve.getMintFactor());
// If it has any collateral outside emode, then return 0
for (const [_, value] of this.deposits.entries()) {
const depositReserve = market.getReserveByAddress(value.reserveAddress);
if (!depositReserve) {
throw new Error('Reserve not found');
}
if (depositReserve.state.config.disableUsageAsCollOutsideEmode && !elevationGroupActivated) {
return new Decimal(0);
}
}
// This is not amazing because it assumes max borrow, which is not true
let originationFeeRate = reserve.getBorrowFee();
// Inclusive fee rate
originationFeeRate = originationFeeRate.div(originationFeeRate.add(new Decimal(1)));
const borrowFee = maxObligationBorrowPower.mul(originationFeeRate);
const maxBorrowAmount = maxObligationBorrowPower.sub(borrowFee);
return Decimal.max(new Decimal(0), maxBorrowAmount);
}
/*
How much of a given token can a user borrow extra given an elevation group,
and a specific reserve, until it hits max LTV and given available liquidity and caps.
* @param market - The KaminoMarket instance.
* @param liquidityReserveAddress - The liquidity reserve Address.
* @param slot - The slot number.
* @param elevationGroup - The elevation group number (default: this.state.elevationGroup).
* @returns The maximum borrow amount as a Decimal.
* @throws Error if the reserve is not found.
*/
getMaxBorrowAmountV2(
market: KaminoMarket,
liquidityReserveAddress: Address,
slot: Slot,
elevationGroup: number = this.state.elevationGroup
): Decimal {
const reserve = market.getReserveByAddress(liquidityReserveAddress);
if (!reserve) {
throw new Error('Reserve not found');
}
const liquidityAvailable = reserve.getLiquidityAvailableForDebtReserveGivenCaps(
market,
[elevationGroup],
Array.from(this.deposits.keys())
)[0];
const maxBorrowAmount = this.getBorrowPower(market, liquidityReserveAddress, slot, elevationGroup);
if (elevationGroup === this.state.elevationGroup) {
return Decimal.min(maxBorrowAmount, liquidityAvailable);
} else {
// TODO: this is wrong, most liquidity caps are global, we should add up only the ones that are specific to this mode
const { amount: debtThisReserve } = this.borrows.get(reserve.address) || { amount: new Decimal(0) };
const liquidityAvailablePostMigration = Decimal.max(0, liquidityAvailable.minus(debtThisReserve));
return Decimal.min(maxBorrowAmount, liquidityAvailablePostMigration);
}
}
/*
Same as getMaxBorrowAmountV2 but assumes a deposit is made first, calculating
the new borrow power after the deposit, without overriding the obligation itself.
* @param market - The KaminoMarket instance.
* @param liquidityReserveAddress - The liquidity reserve Address.
* @param slot - The slot number.
* @param elevationGroup - The elevation group number (default: this.state.elevationGroup).
* @returns The maximum borrow amount as a Decimal.
* @throws Error if the reserve is not found.
*/
getMaxBorrowAmountV2WithDeposit(
market: KaminoMarket,
liquidityReserveAddress: Address,
slot: Slot,
elevationGroup: number = this.state.elevationGroup,
depositAmountLamports: Decimal,
depositReserveAddress: Address
): Decimal {
const depositChanges = [
{
reserveAddress: depositReserveAddress,
amountChangeLamports: depositAmountLamports,
},
];
const obligationWithDeposit = this.withPositionChanges(market, slot, depositChanges);
return obligationWithDeposit.getMaxBorrowAmountV2(market, liquidityReserveAddress, slot, elevationGroup);
}
/*
Returns true if the loan is eligible for the elevation group, including for the default one.
* @param market - The KaminoMarket object representing the market.
* @param slot - The slot number of the loan.
* @param elevationGroup - The elevation group number.
* @returns A boolean indicating whether the loan is eligible for elevation.
*/
isLoanEligibleForElevationGroup(market: KaminoMarket, slot: Slot, elevationGroup: number): boolean {
// - isLoanEligibleForEmode(obligation, emode: 0 | number):
// - essentially checks if a loan can be migrated or not
// - [x] due to collateral / debt reserves combination
// - [x] due to LTV, etc
const reserveDeposits: Address[] = Array.from(this.deposits.keys());
const reserveBorrows: Address[] = Array.from(this.borrows.keys());
if (reserveBorrows.length > 1) {
return false;
}
if (elevationGroup > 0) {
// Elevation group 0 doesn't need to do reserve checks, as all are included by default
const allElevationGroups = market.getMarketElevationGroupDescriptions();
const elevationGroupDescription = allElevationGroups[elevationGroup - 1];
// Has to be a subset
const allCollsIncluded = reserveDeposits.every((reserve) =>
elevationGroupDescription.collateralReserves.has(reserve)
);
const allDebtsIncluded =
reserveBorrows.length === 0 ||
(reserveBorrows.length === 1 && elevationGroupDescription.debtReserve === reserveBorrows[0]);
if (!allCollsIncluded || !allDebtsIncluded) {
return false;
}
}
// Check if the loan can be migrated based on LTV
const getOraclePx = (reserve: KaminoReserve) => reserve.getOracleMarketPrice();
const { collateralExchangeRates } = KaminoObligation.getRatesForObligation(
market,
this.state.deposits,
this.state.borrows,
slot
);
const { borrowLimit } = KaminoObligation.calculateObligationDeposits(
market,
this.state.deposits,
collateralExchangeRates,
elevationGroup,
getOraclePx
);
const isEligibleBasedOnLtv = this.refreshedStats.userTotalBorrowBorrowFactorAdjusted.lte(borrowLimit);
return isEligibleBasedOnLtv;
}
/*
Returns all elevation groups for a given obligation, except the default one
* @param market - The KaminoMarket instance.
* @returns An array of ElevationGroupDescription objects representing the elevation groups for the obligation.
*/
getElevationGroupsForObligation(market: KaminoMarket): ElevationGroupDescription[] {
if (this.borrows.size > 1) {
return [];
}
const collReserves = Array.from(this.deposits.keys());
if (this.borrows.size === 0) {
return market.getElevationGroupsForReservesCombination(collReserves);
} else {
const debtReserve = Array.from(this.borrows.keys())[0];
return market.getElevationGroupsForReservesCombination(collReserves, debtReserve);
}
}
/* Deprecated function, also broken */
getMaxBorrowAmount(
market: KaminoMarket,
liquidityReserveAddress: Address,
slot: Slot,
requestElevationGroup: boolean
): Decimal {
const reserve = market.getReserveByAddress(liquidityReserveAddress);
if (!reserve) {
throw new Error('Reserve not found');
}
const groups = market.state.elevationGroups;
const emodeGroupsDebtReserve = reserve.state.config.elevationGroups;
let commonElevationGroups = [...emodeGroupsDebtReserve].filter(
(item) => item !== 0 && groups[item - 1].debtReserve === reserve.address
);
for (const [_, value] of this.deposits.entries()) {
const depositReserve = market.getExistingReserveByAddress(value.reserveAddress);
const depositReserveEmodeGroups = depositReserve.state.config.elevationGroups;
commonElevationGroups = commonElevationGroups.filter((item) => depositReserveEmodeGroups.includes(item));
}
let elevationGroup = this.state.elevationGroup;
if (commonElevationGroups.length != 0) {
const eModeGroupWithMaxLtvAndDebtReserve = commonElevationGroups.reduce((prev, curr) => {
const prevGroup = groups.find((group) => group.id === prev);
const currGroup = groups.find((group) => group.id === curr);
return prevGroup!.ltvPct > currGroup!.ltvPct ? prev : curr;
});
if (requestElevationGroup) {
elevationGroup = eModeGroupWithMaxLtvAndDebtReserve;
}
}
const elevationGroupActivated =
reserve.state.config.elevationGroups.includes(elevationGroup) && elevationGroup !== 0;
const borrowFactor = KaminoObligation.getBorrowFactorForReserve(reserve, elevationGroup);
const maxObligationBorrowPower = this.refreshedStats.borrowLimit // adjusted available amount
.minus(this.refreshedStats.userTotalBorrowBorrowFactorAdjusted)
.div(borrowFactor)
.div(reserve.getOracleMarketPrice())
.mul(reserve.getMintFactor());
const reserveAvailableAmount = reserve.getLiquidityAvailableAmount();
let reserveBorrowCapRemained = reserve.stats.reserveBorrowLimit.sub(reserve.getBorrowedAmount());
this.deposits.forEach((deposit) => {
const depositReserve = market.getReserveByAddress(deposit.reserveAddress);
if (!depositReserve) {
throw new Error('Reserve not found');
}
if (depositReserve.state.config.disableUsageAsCollOutsideEmode && !elevationGroupActivated) {
reserveBorrowCapRemained = new Decimal(0);
}
});
let maxBorrowAmount = Decimal.min(maxObligationBorrowPower, reserveAvailableAmount, reserveBorrowCapRemained);
const currentUnixTimestamp = Math.floor(Date.now() / 1000);
const debtWithdrawalCap = reserve
.getDebtWithdrawalCapCapacity()
.sub(reserve.getDebtWithdrawalCapCurrent(currentUnixTimestamp));
maxBorrowAmount = reserve.getDebtWithdrawalCapCapacity().gt(0)
? Decimal.min(maxBorrowAmount, debtWithdrawalCap)
: maxBorrowAmount;
let originationFeeRate = reserve.getBorrowFee();
// Inclusive fee rate
originationFeeRate = originationFeeRate.div(originationFeeRate.add(new Decimal(1)));
const borrowFee = maxBorrowAmount.mul(originationFeeRate);
maxBorrowAmount = maxBorrowAmount.sub(borrowFee);
const utilizationRatioLimit = reserve.state.config.utilizationLimitBlockBorrowingAbovePct / 100;
const currentUtilizationRatio = reserve.calculateUtilizationRatio();
if (utilizationRatioLimit > 0 && currentUtilizationRatio > utilizationRatioLimit) {
return new Decimal(0);
} else if (utilizationRatioLimit > 0 && currentUtilizationRatio < utilizationRatioLimit) {
const maxBorrowBasedOnUtilization = new Decimal(utilizationRatioLimit - currentUtilizationRatio).mul(
reserve.getTotalSupply()
);
maxBorrowAmount = Decimal.min(maxBorrowAmount, maxBorrowBasedOnUtilization);
}
let borrowLimitDependentOnElevationGroup = new Decimal(U64_MAX);
if (!elevationGroupActivated) {
borrowLimitDependentOnElevationGroup = reserve
.getBorrowLimitOutsideElevationGroup()
.sub(reserve.getBorrowedAmountOutsideElevationGroup());
} else {
let maxDebtTakenAgainstCollaterals = new Decimal(U64_MAX);
for (const [_, value] of this.deposits.entries()) {
const depositReserve = market.getReserveByAddress(value.reserveAddress);
if (!depositReserve) {
throw new Error('Reserve not found');
}
const maxDebtAllowedAgainstCollateral = depositReserve
.getBorrowLimitAgainstCollateralInElevationGroup(elevationGroup - 1)
.sub(depositReserve.getBorrowedAmountAgainstCollateralInElevationGroup(elevationGroup - 1));
maxDebtTakenAgainstCollaterals = Decimal.max(
new Decimal(0),
Decimal.min(maxDebtAllowedAgainstCollateral, maxDebtTakenAgainstCollaterals)
);
}
borrowLimitDependentOnElevationGroup = maxDebtTakenAgainstCollaterals;
}
maxBorrowAmount = Decimal.min(maxBorrowAmount, borrowLimitDependentOnElevationGroup);
return Decimal.max(new Decimal(0), maxBorrowAmount);
}
getMaxWithdrawAmount(market: KaminoMarket, depositReserveAddress: Address, _slot: Slot): MaxWithdrawAmountResult {
const depositReserve = market.getReserveByAddress(depositReserveAddress);
if (!depositReserve) {
throw new Error('Reserve not found');
}
const reserveAvailableLiquidity = depositReserve.getLiquidityAvailableAmount();
const currentUnixTimestamp = Math.floor(Date.now() / 1000);
const depositWithdrawalCap = depositReserve
.getDepositWithdrawalCapCapacity()
.sub(depositReserve.getDepositWithdrawalCapCurrent(currentUnixTimestamp));
const reserveWithdrawalLimit = depositReserve.getDepositWithdrawalCapCapacity().gt(0)
? Decimal.min(depositWithdrawalCap, reserveAvailableLiquidity)
: reserveAvailableLiquidity;
const userDepositPosition = this.getDepositByReserve(depositReserve.address);
if (!userDepositPosition) {
throw new Error('Deposit reserve not found');
}
const userDepositPositionAmount = userDepositPosition.amount;
if (this.refreshedStats.userTotalBorrowBorrowFactorAdjusted.equals(new Decimal(0))) {
const maxWithdrawAmount = Decimal.max(0, Decimal.min(userDepositPositionAmount, reserveWithdrawalLimit));
const maxWithdrawAmountQueue = Decimal.max(0, userDepositPositionAmount);
return { maxWithdrawAmount, maxWithdrawAmountQueue };
}
const { maxLtv: reserveMaxLtv } = KaminoObligation.getLtvForReserve(
market,
depositReserve,
this.state.elevationGroup
);
// bf adjusted debt value > allowed_borrow_value
if (this.refreshedStats.userTotalBorrowBorrowFactorAdjusted.gte(this.refreshedStats.borrowLimit)) {
return { maxWithdrawAmount: new Decimal(0), maxWithdrawAmountQueue: new Decimal(0) };
}
let maxWithdrawValue: Decimal;
if (reserveMaxLtv.eq(0)) {
maxWithdrawValue = userDepositPositionAmount;
} else {
// borrowLimit / userTotalDeposit = maxLtv
// maxWithdrawValue = userTotalDeposit - userTotalBorrow / maxLtv
maxWithdrawValue = this.refreshedStats.borrowLimit
.sub(this.refreshedStats.userTotalBorrowBorrowFactorAdjusted)
.div(reserveMaxLtv)
.mul(0.999); // remove 0.1% to prevent going over max ltv
}
const maxWithdrawAmountBeforeLimit = maxWithdrawValue
.div(depositReserve.getOracleMarketPrice())
.mul(depositReserve.getMintFactor());
const maxWithdrawAmount = Decimal.max(
0,
Decimal.min(userDepositPositionAmount, maxWithdrawAmountBeforeLimit, reserveWithdrawalLimit)
);
const maxWithdrawAmountQueue = Decimal.max(0, Decimal.min(userDepositPositionAmount, maxWithdrawAmountBeforeLimit));
return { maxWithdrawAmount, maxWithdrawAmountQueue };
}
/**
* Same as getMaxWithdrawAmount but assumes a repay is made first, calculating
* the new withdraw power after the repay, without overriding the obligation itself.
*
* @param market - The KaminoMarket instance.
* @param depositReserveAddress - The liquidity (deposit) reserve Address.
* @param slot - The slot number.
* @param repayAmountLamports - The amount to repay in lamports (use U64_MAX for full repay).
* @param repayReserveAddress - The reserve address of the borrow being repaid.
* @returns The maximum withdraw amounts (both with and without withdrawal queues).
* @throws Error if the reserve is not found.
*/
getMaxWithdrawAmountWithRepay(
market: KaminoMarket,
depositReserveAddress: Address,
slot: Slot,
repayAmountLamports: Decimal,
repayReserveAddress: Address
): MaxWithdrawAmountResult {
const repayReserve = market.getReserveByAddress(repayReserveAddress);
if (!repayReserve) {
throw new Error('Reserve not found');
}
const repayAmount = repayAmountLamports.equals(U64_MAX)
? this.getBorrowAmountByReserve(repayReserve)
: repayAmountLamports;
const borrowChanges = [
{
reserveAddress: repayReserveAddress,
amountChangeLamports: repayAmount.neg(), // as it's a repay
},
];
const obligationWithRepay = this.withPositionChanges(market, slot, undefined, borrowChanges);
return obligationWithRepay.getMaxWithdrawAmount(market, depositReserveAddress, slot);
}
getObligationLiquidityByReserve(reserveAddress: Address): ObligationLiquidity {
const obligationLiquidity = this.state.borrows.find((borrow) => borrow.borrowReserve === reserveAddress);
if (!obligationLiquidity) {
throw new Error(`Obligation liquidity not found given reserve ${reserveAddress}`);
}
return obligationLiquidity;
}
/**
*
* @returns Total borrowed amount for the specified obligation liquidity/borrow asset
*/
static getBorrowAmount(borrow: ObligationLiquidity): Decimal {
return new Fraction(borrow.borrowedAmountSf).toDecimal();
}
/**
*
* @returns Cumulative borrow rate for the specified obligation liquidity/borrow asset
*/
static getCumulativeBorrowRate(borrow: ObligationLiquidity): Decimal {
return bfToDecimal(borrow.cumulativeBorrowRateBsf);
}
/**
* Mirrors on-chain `ObligationLiquidity::calculate_interest_for_period`.
* Calculates the interest that would accrue on `amount` over `timePeriodSecs` seconds.
*/
static calculateInterestForPeriod(
borrow: ObligationLiquidity,
reserve: KaminoReserve,
amount: Decimal,
timePeriodSecs: number,
currentSlot: Slot
): Decimal {
const reserveLastUpdateSlot = BigInt(reserve.state.lastUpdate.slot.toString()) as Slot;
if (currentSlot < reserveLastUpdateSlot) {
throw new Error(
`LedgerInstant slot ${currentSlot} is older than reserve ${reserve.address} last-update slot ${reserveLastUpdateSlot}; ` +
'fetch the ledger instant at the same commitment after loading the reserve'
);
}
const futureSlot = currentSlot + BigInt(timePeriodSecs) * BigInt(SLOTS_PER_SECOND);
const futureCumulativeBorrowRate = reserve.calculateFutureCumulativeBorrowRate(futureSlot);
const obligationCumulativeBorrowRate = KaminoObligation.getCumulativeBorrowRate(borrow);
const amountWithInterest = amount.mul(futureCumulativeBorrowRate).div(obligationCumulativeBorrowRate);
return amountWithInterest.sub(amount);
}
/**
* Mirrors on-chain `ObligationLiquidity::calculate_early_repay_penalty`.
*
* Returns the penalty (in lamports) for repaying `repayAmountLamports` early on a
* borrow position identified by `reserveAddress`.
*
* Returns 0 when:
* - The reserve is open-term (debtTermSeconds == 0)
* - lastBorrowedAtTimestamp == 0 (legacy/untracked borrow)
* - The debt has matured (elapsed >= debtTermSeconds)
*/
calculateEarlyRepayPenalty(
reserveAddress: Address,
repayAmountLamports: Decimal,
currentTimestamp: number,
currentSlot: Slot
): Decimal {
const reserve = this.market.getExistingReserveByAddress(reserveAddress);
const borrow = this.state.borrows.find((b) => b.borrowReserve === reserveAddress);
// The following are all normal, non-exceptional "no penalty" cases (mirroring the on-chain
// early-return branches). They are intentionally silent because callers invoke this helper
// unconditionally on every repay sizing — only fixed-term, tracked, non-expired borrows are charged.
if (!borrow) {
return new Decimal(0);
}
// Open-term reserve (debtTermSeconds == 0) → no early-repay penalty.
const debtTermSeconds = new BN(reserve.state.config.debtTermSeconds).toNumber();
if (debtTermSeconds === 0) {
return new Decimal(0);
}
// Legacy/untracked borrow (last_borrowed_at == 0) → on-chain ignores the penalty.
const lastBorrowedAt = new BN(borrow.lastBorrowedAtTimestamp).toNumber();
if (lastBorrowedAt === 0) {
return new Decimal(0);
}
if (new BN(borrow.borrowedAmountSf).isZero()) {
return new Decimal(0);
}
const secondsSinceLastBorrowed = Math.max(currentTimestamp - lastBorrowedAt, 0);
if (secondsSinceLastBorrowed >= debtTermSeconds) {
return new Decimal(0);
}
const remainingSecs = debtTermSeconds - secondsSinceLastBorrowed;
const remainingInterest = KaminoObligation.calculateInterestForPeriod(
borrow,
reserve,
repayAmountLamports,
remainingSecs,
currentSlot
);
const penaltyPct = new Decimal(reserve.state.config.earlyRepayRemainingInterestPct).div(100);
return remainingInterest.mul(penaltyPct).ceil();
}
/**
* Single source of truth for the fixed-term early-repay FUNDING invariant used by every debt-touching SDK op
* (swap-debt, repay-with-coll, swap-coll, leverage withdraw/adjust/close): repaying a fixed-term borrow before
* maturity debits `principal + penalty` on-chain, so the flash-borrow / coll→debt swap must make
* `principal + penalty` available while the repay instruction amount stays the bare principal.
*
* Returns `{ penaltyLamports, fundingLamports }`, both in the debt reserve's lamports. For open-term reserves,
* matured/untracked borrows, and variable-rate reserves the penalty is 0 and `fundingLamports == principal`.
*
* `currentLedgerInstant` must come from one ledger snapshot (for example {@link getCurrentLedgerInstant}) at the
* same commitment as the loaded reserve and obligation state, so the slot used for interest projection and the
* block time used for term decay cannot drift independently.
*
* NOTE on snapshot ordering: `calculateEarlyRepayPenalty` → `calculateInterestForPeriod` projects the remaining-term
* interest forward from `currentLedgerInstant.slot`, whereas the on-chain charge projects from the reserve's
* `last_update` slot. A ledger instant at or after that update can only over-estimate when the reserve is stale (the
* surplus stays as user dust / slightly more collateral withdrawn). An instant older than the reserve snapshot
* could under-estimate instead, so the calculation rejects that inconsistent ordering rather than silently sizing.
*/
calculateEarlyRepayFunding(
reserve: KaminoReserve,
repayPrincipalLamports: Decimal,
currentLedgerInstant: LedgerInstant
): { penaltyLamports: Decimal; fundingLamports: Decimal } {
const { slot, blockTime } = currentLedgerInstant;
const penaltyLamports = reserve.getKind().isFixedRate()
? this.calculateEarlyRepayPenalty(reserve.address, repayPrincipalLamports, Number(blockTime), slot)
: new Decimal(0);
return { penaltyLamports, fundingLamports: repayPrincipalLamports.add(penaltyLamports) };
}
public static getRatesForObligation(
kaminoMarket: KaminoMarket,
deposits: ObligationCollateral[],
borrows: ObligationLiquidity[],
slot: Slot,
additionalReserves: Address[] = []
): {
collateralExchangeRates: Map;
cumulativeBorrowRates: Map;
} {
const collateralExchangeRates = KaminoObligation.getCollateralExchangeRatesForObligation(
kaminoMarket,
deposits,
slot,
additionalReserves
);
const cumulativeBorrowRates = KaminoObligation.getCumulativeBorrowRatesForObligation(
kaminoMarket,
borrows,
slot,
additionalReserves
);
return {
collateralExchangeRates,
cumulativeBorrowRates,
};
}
public static addRatesForObligation(
kaminoMarket: KaminoMarket,
deposits: ObligationCollateral[],
borrows: ObligationLiquidity[],
collateralExchangeRates: Map,
cumulativeBorrowRates: Map,
slot: Slot
): void {
KaminoObligation.addCollateralExchangeRatesForObligation(kaminoMarket, collateralExchangeRates, deposits, slot);
KaminoObligation.addCumulativeBorrowRatesForObligation(kaminoMarket, cumulativeBorrowRates, borrows, slot);
}
static getCollateralExchangeRatesForObligation(
kaminoMarket: KaminoMarket,
deposits: ObligationCollateral[],
slot: Slot,
additionalReserves: Address[]
): Map {
const collateralExchangeRates = new Map();
// Create a set of all reserves coming from deposit plus additional reserves
const allReserves = new Set();
for (let i = 0; i < deposits.length; i++) {
const deposit = deposits[i];
if (isNotNullPubkey(deposit.depositReserve)) {
allReserves.add(deposit.depositReserve);
}
}
for (let i = 0; i < additionalReserves.length; i++) {
if (isNotNullPubkey(additionalReserves[i])) {
allReserves.add(additionalReserves[i]);
}
}
// Run through all reserves and get the exchange rate
for (const reserve of allReserves) {
const reserveInstance = kaminoMarket.getExistingReserveByAddress(reserve, 'Obligation');
const collateralExchangeRate = reserveInstance.getEstimatedCollateralExchangeRate(
slot,
kaminoMarket.state.referralFeeBps
);
collateralExchangeRates.set(reserve, collateralExchangeRate);
}
return collateralExchangeRates;
}
static addCollateralExchangeRatesForObligation(
kaminoMarket: KaminoMarket,
collateralExchangeRates: Map,
deposits: ObligationCollateral[],
slot: Slot
) {
for (let i = 0; i < deposits.length; i++) {
const deposit = deposits[i];
if (isNotNullPubkey(deposit.depositReserve) && !collateralExchangeRates.has(deposit.depositReserve)) {
const reserve = kaminoMarket.getExistingReserveByAddress(deposit.depositReserve, 'Obligation deposit');
const collateralExchangeRate = reserve.getEstimatedCollateralExchangeRate(
slot,
kaminoMarket.state.referralFeeBps
);
collateralExchangeRates.set(reserve.address, collateralExchangeRate);
}
}
}
static getCumulativeBorrowRatesForObligation(
kaminoMarket: KaminoMarket,
borrows: ObligationLiquidity[],
slot: Slot,
additionalReserves: Address[] = []
): Map {
const allReserves = new Set();
for (let i = 0; i < borrows.length; i++) {
const borrow = borrows[i];
if (isNotNullPubkey(borrow.borrowReserve)) {
allReserves.add(borrow.borrowReserve);
}
}
// Add additional reserves
for (let i = 0; i < additionalReserves.length; i++) {
if (isNotNullPubkey(additionalReserves[i])) {
allReserves.add(additionalReserves[i]);
}
}
const cumulativeBorrowRates = new Map();
// Run through all reserves and get the cumulative borrow rate
for (const reserve of allReserves) {
const reserveInstance = kaminoMarket.getExistingReserveByAddress(reserve, 'Obligation');
const cumulativeBorrowRate = reserveInstance.getEstimatedCumulativeBorrowRate(
slot,
kaminoMarket.state.referralFeeBps
);
cumulativeBorrowRates.set(reserve, cumulativeBorrowRate);
}
return cumulativeBorrowRates;
}
static addCumulativeBorrowRatesForObligation(
kaminoMarket: KaminoMarket,
cumulativeBorrowRates: Map,
borrows: ObligationLiquidity[],
slot: Slot
) {
for (let i = 0; i < borrows.length; i++) {
const borrow = borrows[i];
if (isNotNullPubkey(borrow.borrowReserve) && !cumulativeBorrowRates.has(borrow.borrowReserve)) {
const reserve = kaminoMarket.getExistingReserveByAddress(borrow.borrowReserve, 'Obligation borrow');
const cumulativeBorrowRate = reserve.getEstimatedCumulativeBorrowRate(slot, kaminoMarket.state.referralFeeBps);
cumulativeBorrowRates.set(reserve.address, cumulativeBorrowRate);
}
}
}
/**
* Get the borrow factor for a borrow reserve, accounting for the obligation elevation group if it is active
* @param reserve
* @param elevationGroup
*/
public static getBorrowFactorForReserve(reserve: KaminoReserve, elevationGroup: number): Decimal {
const elevationGroupActivated =
reserve.state.config.elevationGroups.includes(elevationGroup) && elevationGroup !== 0;
if (elevationGroupActivated) {
return new Decimal('1');
}
return new Decimal(reserve.stats.borrowFactor).div('100');
}
/**
* Get the loan to value and liquidation loan to value for a collateral reserve as ratios, accounting for the obligation elevation group if it is active
* @param market
* @param reserve
* @param elevationGroup
*/
public static getLtvForReserve(
market: KaminoMarket,
reserve: KaminoReserve,
elevationGroup: number
): { maxLtv: Decimal; liquidationLtv: Decimal } {
const elevationGroupActivated =
elevationGroup !== 0 && reserve.state.config.elevationGroups.includes(elevationGroup);
if (elevationGroupActivated) {
const { ltvPct, liquidationThresholdPct } = market.getElevationGroup(elevationGroup);
return {
maxLtv: new Decimal(ltvPct).div('100'),
liquidationLtv: new Decimal(liquidationThresholdPct).div('100'),
};
} else {
const { loanToValue, liquidationThreshold } = reserve.stats;
return {
maxLtv: new Decimal(loanToValue),
liquidationLtv: new Decimal(liquidationThreshold),
};
}
}
public getDepositReserves(): Address[] {
return this.state.deposits.map((deposit) => deposit.depositReserve).filter((reserve) => isNotNullPubkey(reserve));
}
public getBorrowReserves(): Address[] {
return this.state.borrows.map((borrow) => borrow.borrowReserve).filter((reserve) => isNotNullPubkey(reserve));
}
public getAllReserves(): Address[] {
return [...this.getDepositReserves(), ...this.getBorrowReserves()];
}
public async getRefreshObligationIx(opts?: {
extraDepositReserves?: Address[];
extraBorrowReserves?: Address[];
skipReserves?: Address[];
}): Promise {
const marketAddress = this.market.getAddress();
let refreshObligationIx = refreshObligation(
{
lendingMarket: marketAddress,
obligation: this.obligationAddress,
},
undefined,
this.market.programId
);
const skipReservesSet = new Set(opts?.skipReserves || []);
const depositReservesList =
opts?.extraDepositReserves && opts.extraDepositReserves.length > 0
? opts.extraDepositReserves
: this.getDepositReserves().filter((reserve) => !skipReservesSet.has(reserve));
const depositReserveAccountMetas: AccountMeta[] = depositReservesList.map((reserve) => {
return { address: reserve, role: AccountRole.WRITABLE };
});
const borrowReservesList =
opts?.extraBorrowReserves && opts.extraBorrowReserves.length > 0
? opts.extraBorrowReserves
: this.getBorrowReserves().filter((reserve) => !skipReservesSet.has(reserve));
const borrowReserveAccountMetas: AccountMeta[] = borrowReservesList.map((reserve) => {
return { address: reserve, role: AccountRole.WRITABLE };
});
// When the obligation has a referrer, refresh_obligation requires one referrer-token-state account per
// borrow reserve (it accrues referrer fees per borrow during the refresh); their absence fails the
// remaining-accounts check on-chain.
const referrerTokenStateAccountMetas: AccountMeta[] =
this.state.referrer === DEFAULT_PUBLIC_KEY
? []
: await Promise.all(
borrowReservesList.map(async (borrowReserve): Promise => {
return {
address: await referrerTokenStatePda(this.state.referrer, borrowReserve, this.market.programId),
role: AccountRole.WRITABLE,
};
})
);
refreshObligationIx = {
...refreshObligationIx,
accounts: refreshObligationIx.accounts!.concat([
...depositReserveAccountMetas,
...borrowReserveAccountMetas,
...referrerTokenStateAccountMetas,
]),
};
return refreshObligationIx;
}
/**
* Best-effort preflight check of whether this obligation's borrow from `sourceReserveAddress` can be rolled
* over into `targetReserveAddress` at `currentTimestamp` (unix seconds): it applies the program's rollover
* preconditions, resolves the rollover mode, and computes how much of the position can be rolled.
* Synchronous - it reads the passed market, reserves, and this obligation's state only.
*
* See {@link RolloverPossibility} for exactly what is and isn't covered (it is preflight, not an exact
* replica - in-transaction freshness, the reserve program version, token-2022 mint extensions, and
* exact fixed-point rounding remain on-chain concerns), and
* {@link KaminoAction.buildRolloverFixedTermBorrowTxns} to build the transaction.
*/
public checkRolloverPossible(
kaminoMarket: KaminoMarket,
sourceReserveAddress: Address,
targetReserveAddress: Address,
currentTimestamp: number
): RolloverPossibility {
const sourceReserve = kaminoMarket.getReserveByAddress(sourceReserveAddress);
if (!sourceReserve) {
throw new Error(`Source reserve ${sourceReserveAddress} not found in market ${kaminoMarket.getAddress()}`);
}
const targetReserve = kaminoMarket.getReserveByAddress(targetReserveAddress);
if (!targetReserve) {
throw new Error(`Target reserve ${targetReserveAddress} not found in market ${kaminoMarket.getAddress()}`);
}
const no = (reason: RolloverImpossibleReason): RolloverPossibility => ({ possible: false, mode: null, reason });
// The rollover instruction is gated by an emergency-mode access control on the lending market.
if (kaminoMarket.state.emergencyMode !== 0) {
return no('MarketInEmergencyMode');
}
const sourceBorrow = this.state.borrows.find((borrow) => borrow.borrowReserve === sourceReserveAddress);
if (!sourceBorrow) {
return no('SourceBorrowNotFound');
}
// RolloverAccounts constraint: the target must share the source reserve's liquidity mint.
if (sourceReserve.getLiquidityMint() !== targetReserve.getLiquidityMint()) {
return no('LiquidityMintMismatch');
}
// rollover_fixed_term_borrow_checks: both reserves must be active (not obsolete) and not in emergency mode.
if (KaminoObligation.isReserveInactiveForRollover(sourceReserve)) {
return no('SourceReserveNotActive');
}
if (KaminoObligation.isReserveInactiveForRollover(targetReserve)) {
return no('TargetReserveNotActive');
}
// check_rollover_possible: rollovers are not supported while in an elevation group.
if (this.state.elevationGroup !== 0) {
return no('ObligationInElevationGroup');
}
// check_borrow_possible (its non-freshness gates): market/obligation borrow flags, target debt maturity,
// deleveraging, and obsolete reserves.
if (kaminoMarket.state.borrowDisabled !== 0) {
return no('BorrowingDisabled');
}
const targetDebtMaturity = targetReserve.state.config.debtMaturityTimestamp;
if (!targetDebtMaturity.isZero() && targetDebtMaturity.lte(new BN(currentTimestamp))) {
return no('TargetReserveDebtMaturityReached');
}
if (!this.state.autodeleverageMarginCallStartedTimestamp.isZero()) {
return no('ObligationMarkedForDeleveraging');
}
if (this.state.numOfObsoleteDepositReserves > 0 || this.state.numOfObsoleteBorrowReserves > 0) {
return no('ObligationHasObsoleteReserves');
}
// Outside an elevation group, the obligation-level borrowing-disabled flag applies.
if (this.state.borrowingDisabled > 0) {
return no('ObligationBorrowingDisabled');
}
// The target reserve must keep the obligation's borrow factor (so the rollover does not affect LTV).
if (!sourceReserve.state.config.borrowFactorPct.eq(targetReserve.state.config.borrowFactorPct)) {
return no('TargetBorrowFactorMismatch');
}
// resolve_rollover_mode: the source borrow's config must opt into this rollover flavor.
const config = sourceBorrow.fixedTermBorrowRolloverConfig;
const sourceIsFixedTerm = !sourceReserve.state.config.debtTermSeconds.isZero();
const targetIsFixedTerm = !targetReserve.state.config.debtTermSeconds.isZero();
let mode: RolloverMode;
if (sourceIsFixedTerm) {
if (config.autoRolloverEnabled === 0) {
return no('AutoRolloverNotEnabled');
}
if (!targetIsFixedTerm) {
if (config.openTermAllowed === 0) {
return no('OpenTermTargetNotAllowed');
}
mode = 'fixedToOpen';
} else {
const reason = KaminoObligation.checkFixedTermRolloverTargetCriteria(config, targetReserve);
if (reason) {
return no(reason);
}
mode = 'fixedToFixed';
}
} else {
// The source borrow is open-term: the only rollover flavor is migrating it into a fixed-term reserve.
if (config.migrationToFixedEnabled === 0) {
return no('MigrationToFixedNotEnabled');
}
if (!targetIsFixedTerm) {
return no('MigrationTargetNotFixedTerm');
}
const reason = KaminoObligation.checkFixedTermRolloverTargetCriteria(config, targetReserve);
if (reason) {
return no(reason);
}
mode = 'openToFixed';
}
// resolve_allowed_rollover_time: the market must have this rollover flavor's execution enabled.
if (!KaminoObligation.isRolloverExecutionEnabled(mode, kaminoMarket)) {
return no('RolloverExecutionDisabled');
}
// check_within_rollover_window: a fixed-source rollover must fall within its window before term expiry.
const windowReason = KaminoObligation.checkWithinRolloverWindow(
mode,
kaminoMarket,
sourceBorrow,
sourceReserve,
currentTimestamp
);
if (windowReason) {
return no(windowReason);
}
// check_rollover_into_existing_slot_possible: merging into a target the obligation already borrows from.
if (sourceReserveAddress !== targetReserveAddress) {
const targetBorrow = this.state.borrows.find((borrow) => borrow.borrowReserve === targetReserveAddress);
if (targetBorrow) {
const mergeReason = KaminoObligation.checkExistingTargetBorrowSlot(
sourceBorrow,
targetBorrow,
sourceReserve,
targetReserve
);
if (mergeReason) {
return no(mergeReason);
}
}
}
const fullBorrowAmount = new Fraction(sourceBorrow.borrowedAmountSf);
// Same-reserve rollover re-borrows the same amount (no net new borrow), so it does not consume borrowable
// capacity (rollover_borrow_into_same_reserve): it only requires the reserve to not already be over its
// borrow / utilization / outside-elevation limits, and always rolls the full position.
if (sourceReserveAddress === targetReserveAddress) {
// check_same_reserve_rollover_liquidity_available requires
// rollover_amount <= freely_available_after_repay = (total_available + rollover_amount) - queued_liquidity.
// Subtracting rollover_amount from both sides reduces this to queued_liquidity <= total_available (the
// program's saturating_sub does not change it: when queued exceeds available the right-hand side drops
// below rollover_amount, so the check still fails).
const queuedExceedsAvailable = targetReserve
.getQueuedLiquidityAmountAtCurrentRate()
.gt(targetReserve.getLiquidityAvailableAmount());
if (queuedExceedsAvailable || targetReserve.isOverBorrowLimits()) {
return no('InsufficientTargetLiquidity');
}
return { possible: true, mode, isFullRollover: true, rollableAmount: fullBorrowAmount };
}
// Different-reserve rollover transfers min(target borrowable liquidity, ceil(full debt)). A shortfall makes
// it a partial rollover, which the market's minimum partial-rollover value must still clear.
const borrowableAmount = targetReserve.getBorrowableLiquidityAmountOutsideElevationGroup(currentTimestamp);
if (borrowableAmount.lten(0)) {
return no('InsufficientTargetLiquidity');
}
const rollableAmount = Fraction.fromInt(BN.min(borrowableAmount, fullBorrowAmount.ceilToBn()));
const isFullRollover = rollableAmount.gte(fullBorrowAmount);
if (!isFullRollover) {
const rolloverValue = targetReserve.getMarketValueFromLiquidityAmount(rollableAmount);
if (rolloverValue.lt(Fraction.fromInt(kaminoMarket.state.minPartialRolloverValue))) {
return no('PartialRolloverValueTooSmall');
}
} else {
// A full rollover borrows ceil(debt) but only repays the fractional debt, so the obligation's debt rises
// by a sub-lamport rounding amount; the obligation must have the borrow-value headroom to absorb it
// (check_below_obligation_allowed_borrow_value).
const borrowedAmountIncrease = rollableAmount.saturatingSub(fullBorrowAmount);
if (borrowedAmountIncrease.gt(ZERO_FRACTION)) {
const borrowFactorAdjustedValueIncrease = targetReserve
.getMarketValueFromLiquidityAmount(borrowedAmountIncrease)
.mul(targetReserve.getBorrowFactorFraction());
const remainingBorrowValue = new Fraction(this.state.allowedBorrowValueSf).saturatingSub(
new Fraction(this.state.borrowFactorAdjustedDebtValueSf)
);
if (remainingBorrowValue.lt(borrowFactorAdjustedValueIncrease)) {
return no('ObligationBorrowValueExceeded');
}
}
}
return { possible: true, mode, isFullRollover, rollableAmount };
}
/** A reserve cannot be used as a rollover source or target while it is obsolete or in emergency mode. */
private static isReserveInactiveForRollover(reserve: KaminoReserve): boolean {
return (
reserve.state.config.status === ReserveStatus.Obsolete.discriminator || reserve.state.config.emergencyMode !== 0
);
}
/**
* Mirrors the program's fixed-term rollover-target criteria: the target reserve's max borrow rate must be
* within the borrow config's max, and (since the config accepts a fixed-term target) its debt term must meet
* the config's minimum. Returns the failing reason, or `undefined` when the target satisfies them.
*/
private static checkFixedTermRolloverTargetCriteria(
config: FixedTermBorrowRolloverConfigFields,
targetReserve: KaminoReserve
): RolloverImpossibleReason | undefined {
if (targetReserve.getMaxBorrowRateBps() > config.maxBorrowRateBps) {
return 'TargetBorrowRateTooHigh';
}
// min_debt_term_seconds == 0 means the owner only accepts open-term targets, never a fixed-term one.
if (config.minDebtTermSeconds.isZero()) {
return 'TargetReserveOpenTermOnly';
}
if (targetReserve.state.config.debtTermSeconds.lt(config.minDebtTermSeconds)) {
return 'TargetDebtTermTooShort';
}
return undefined;
}
/**
* The market-config enablement side of resolve_allowed_rollover_time: a fixed-to-fixed or fixed-to-open
* rollover needs a non-zero window duration configured, and an open-to-fixed migration needs the market's
* migration-to-fixed execution flag. Whether the borrow is currently *within* a window is a separate,
* timing concern (see {@link checkWithinRolloverWindow}).
*/
private static isRolloverExecutionEnabled(mode: RolloverMode, kaminoMarket: KaminoMarket): boolean {
switch (mode) {
case 'fixedToFixed':
return !kaminoMarket.state.fixedTermRolloverWindowDurationSeconds.isZero();
case 'fixedToOpen':
return !kaminoMarket.state.openTermRolloverWindowDurationSeconds.isZero();
case 'openToFixed':
return kaminoMarket.state.obligationBorrowMigrationToFixedExecutionEnabled !== 0;
}
}
/**
* The timing side of resolve_allowed_rollover_time + check_within_rollover_window: a fixed-source rollover
* is only permitted within `window` seconds before the borrow's term ends. An open-to-fixed migration has
* no window (AllowedRolloverTime::Always), so it is always within timing.
*
* A fixed-to-fixed rollover may narrow or widen the market's window through the borrow's own
* `fixedTermRolloverWindowDurationDays` (zero meaning "use the market's"); the fixed-to-open window is
* market-level only.
*/
private static checkWithinRolloverWindow(
mode: RolloverMode,
kaminoMarket: KaminoMarket,
sourceBorrow: ObligationLiquidity,
sourceReserve: KaminoReserve,
currentTimestamp: number
): RolloverImpossibleReason | undefined {
if (mode === 'openToFixed') {
return undefined;
}
const windowSeconds =
mode === 'fixedToFixed'
? KaminoObligation.getEffectiveFixedTermRolloverWindowSeconds(kaminoMarket, sourceBorrow)
: kaminoMarket.state.openTermRolloverWindowDurationSeconds;
const termEndTimestamp = KaminoObligation.getDebtTermEndTimestamp(sourceBorrow, sourceReserve);
if (termEndTimestamp === null) {
return 'RolloverNotApplicable';
}
const secondsUntilTermEnd = Math.max(0, termEndTimestamp - currentTimestamp);
return windowSeconds.lt(new BN(secondsUntilTermEnd)) ? 'OutsideRolloverWindow' : undefined;
}
/**
* The rollover window in force for a fixed-term target: the borrow's own
* `fixedTermRolloverWindowDurationDays` override when set, otherwise the market's
* `fixedTermRolloverWindowDurationSeconds`.
*/
private static getEffectiveFixedTermRolloverWindowSeconds(
kaminoMarket: KaminoMarket,
sourceBorrow: ObligationLiquidity
): BN {
const overrideDays = sourceBorrow.fixedTermBorrowRolloverConfig.fixedTermRolloverWindowDurationDays;
if (overrideDays === 0) {
return kaminoMarket.state.fixedTermRolloverWindowDurationSeconds;
}
return new BN(overrideDays).muln(SECONDS_PER_DAY);
}
/**
* Mirrors ObligationLiquidity::get_debt_term_end_timestamp: the borrow's start timestamp plus the reserve's
* debt term, or null for an open-term reserve or a borrow that did not track its start timestamp.
*/
private static getDebtTermEndTimestamp(borrow: ObligationLiquidity, reserve: KaminoReserve): number | null {
const debtTermSeconds = reserve.state.config.debtTermSeconds;
if (debtTermSeconds.isZero() || borrow.lastBorrowedAtTimestamp.isZero()) {
return null;
}
return borrow.lastBorrowedAtTimestamp.add(debtTermSeconds).toNumber();
}
/**
* Mirrors check_rollover_into_existing_slot_possible: when the obligation already borrows from the target,
* the two borrows' rollover configs must match and merging must not shorten the remaining debt term.
*/
private static checkExistingTargetBorrowSlot(
sourceBorrow: ObligationLiquidity,
targetBorrow: ObligationLiquidity,
sourceReserve: KaminoReserve,
targetReserve: KaminoReserve
): RolloverImpossibleReason | undefined {
if (
!KaminoObligation.rolloverConfigsEqual(
sourceBorrow.fixedTermBorrowRolloverConfig,
targetBorrow.fixedTermBorrowRolloverConfig
)
) {
return 'ExistingTargetBorrowConfigMismatch';
}
const sourceTermEnd = KaminoObligation.getDebtTermEndTimestamp(sourceBorrow, sourceReserve);
const targetTermEnd = KaminoObligation.getDebtTermEndTimestamp(targetBorrow, targetReserve);
// When both borrows are fixed-term, the target must prolong the remaining duration; the mixed open/fixed
// cases are already gated by the rollover-config opt-in checked earlier.
if (sourceTermEnd !== null && targetTermEnd !== null && targetTermEnd <= sourceTermEnd) {
return 'ExistingTargetBorrowTermNotProlonged';
}
return undefined;
}
private static rolloverConfigsEqual(
a: FixedTermBorrowRolloverConfigFields,
b: FixedTermBorrowRolloverConfigFields
): boolean {
return (
a.autoRolloverEnabled === b.autoRolloverEnabled &&
a.openTermAllowed === b.openTermAllowed &&
a.migrationToFixedEnabled === b.migrationToFixedEnabled &&
a.maxBorrowRateBps === b.maxBorrowRateBps &&
a.minDebtTermSeconds.eq(b.minDebtTermSeconds)
);
}
}
// Create a function that checks if an obligation is of type obligation or obligationType
export function isKaminoObligation(obligation: KaminoObligation | ObligationType): obligation is KaminoObligation {
return 'obligationAddress' in obligation;
}
/**
* Computes the derived fields (`netAccountValue`, `loanToValue`, `leverage`) on `ObligationStats` produced by
* `calculatePositions` for hypothetical post-swap simulations.
*/
function finalizeSwapObligationStats(stats: ObligationStats): ObligationStats {
stats.netAccountValue = stats.userTotalDeposit.minus(stats.userTotalBorrow);
stats.loanToValue = valueOrZero(
stats.userTotalBorrowBorrowFactorAdjusted.dividedBy(stats.userTotalCollateralDeposit)
);
stats.leverage = valueOrZero(stats.userTotalDeposit.dividedBy(stats.netAccountValue));
return stats;
}