@kamino-finance/klend-sdk
Version:
Typescript SDK for interacting with the Kamino Lending (klend) protocol
407 lines • 19.6 kB
JavaScript
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.ObligationOrderAtIndex = exports.KaminoObligationOrder = exports.DeleverageAllDebt = exports.DeleverageDebtAmount = exports.DebtCollPriceRatioBelow = exports.DebtCollPriceRatioAbove = exports.UserLtvBelow = exports.UserLtvAbove = void 0;
/* eslint-disable max-classes-per-file */
const fraction_1 = require("./fraction");
const types_1 = require("../idl_codegen/types");
const utils_1 = require("./utils");
const decimal_js_1 = __importDefault(require("decimal.js"));
const bn_js_1 = __importDefault(require("bn.js"));
const utils_2 = require("../utils");
const validations_1 = require("../utils/validations");
// All condition types:
/**
* A condition met when obligation's overall "User LTV" is strictly higher than the given threshold.
*/
class UserLtvAbove {
minUserLtvExclusive;
constructor(minUserLtvExclusive) {
this.minUserLtvExclusive = new decimal_js_1.default(minUserLtvExclusive);
}
threshold() {
return this.minUserLtvExclusive;
}
evaluate(obligation) {
// Note: below we deliberately use the LTV-related methods of `KaminoObligation` (instead of the precomputed fields
// of the `ObligationStats`), since we care about using the same LTV computation as the KLend smart contract.
// Please see their docs for details.
return evaluateStopLoss(obligation.loanToValue(), this.minUserLtvExclusive, obligation.liquidationLtv());
}
}
exports.UserLtvAbove = UserLtvAbove;
/**
* A condition met when obligation's overall "User LTV" is strictly lower than the given threshold.
*/
class UserLtvBelow {
maxUserLtvExclusive;
constructor(maxUserLtvExclusive) {
this.maxUserLtvExclusive = new decimal_js_1.default(maxUserLtvExclusive);
}
threshold() {
return this.maxUserLtvExclusive;
}
evaluate(obligation) {
// Note: below we deliberately use the `KaminoObligation.loanToValue()` method (instead of the precomputed field
// `ObligationStats.loanToValue`), since we care about using the same LTV computation as the KLend smart contract.
// Please see the method's docs for details.
return evaluateTakeProfit(obligation.loanToValue(), this.maxUserLtvExclusive);
}
}
exports.UserLtvBelow = UserLtvBelow;
/**
* A condition met when the obligation's collateral token price (expressed in the debt token) is strictly higher than
* the given threshold.
*
* May only be applied to single-collateral, single-debt obligations.
*/
class DebtCollPriceRatioAbove {
minDebtCollPriceRatioExclusive;
constructor(minDebtCollPriceRatioExclusive) {
this.minDebtCollPriceRatioExclusive = new decimal_js_1.default(minDebtCollPriceRatioExclusive);
}
threshold() {
return this.minDebtCollPriceRatioExclusive;
}
evaluate(obligation) {
const priceRatio = calculateDebtCollPriceRatio(obligation);
return evaluateStopLoss(priceRatio, this.minDebtCollPriceRatioExclusive,
// For single-debt-single-coll obligations, the price ratio is directly proportional
// to LTV - so we can calculate the "liquidation price ratio" simply by scaling the
// current value by the ratio of unhealthy/current borrow value:
priceRatio
.mul(obligation.refreshedStats.borrowLiquidationLimit)
.div(obligation.refreshedStats.userTotalBorrowBorrowFactorAdjusted));
}
}
exports.DebtCollPriceRatioAbove = DebtCollPriceRatioAbove;
/**
* A condition met when the obligation's collateral token price (expressed in the debt token) is strictly higher than
* the given threshold.
*
* May only be applied to single-collateral, single-debt obligations.
*/
class DebtCollPriceRatioBelow {
maxDebtCollPriceRatioExclusive;
constructor(maxDebtCollPriceRatioExclusive) {
this.maxDebtCollPriceRatioExclusive = new decimal_js_1.default(maxDebtCollPriceRatioExclusive);
}
threshold() {
return this.maxDebtCollPriceRatioExclusive;
}
evaluate(obligation) {
return evaluateTakeProfit(calculateDebtCollPriceRatio(obligation), this.maxDebtCollPriceRatioExclusive);
}
}
exports.DebtCollPriceRatioBelow = DebtCollPriceRatioBelow;
// All opportunity types:
/**
* An opportunity for repaying the given amount of the obligation's debt token.
*
* May only be applied to single-debt obligations.
*/
class DeleverageDebtAmount {
amount;
constructor(amount) {
this.amount = new decimal_js_1.default(amount);
}
parameter() {
return this.amount;
}
getMaxRepay(borrows) {
const singleBorrow = (0, validations_1.getSingleElement)(borrows, 'borrow');
return {
mint: singleBorrow.mintAddress,
amount: decimal_js_1.default.min(singleBorrow.amount, this.amount),
};
}
}
exports.DeleverageDebtAmount = DeleverageDebtAmount;
/**
* An opportunity for repaying all debt(s) of an obligation.
*/
class DeleverageAllDebt {
/**
* The only legal value for the {@link parameter()} of this opportunity type.
*/
static FRACTION_MAX = new fraction_1.Fraction(fraction_1.Fraction.MAX_F_BN).toDecimal();
constructor(fixed_parameter) {
if (fixed_parameter !== undefined && !new decimal_js_1.default(fixed_parameter).eq(DeleverageAllDebt.FRACTION_MAX)) {
throw new Error(`invalid DeleverageAllDebt parameter: ${fixed_parameter} (if given, must be FRACTION_MAX = ${DeleverageAllDebt.FRACTION_MAX})`);
}
}
parameter() {
return DeleverageAllDebt.FRACTION_MAX;
}
getMaxRepay(borrows) {
if (borrows.length === 0) {
throw new Error(`Opportunity type not valid on obligation with no borrows`);
}
const highestValueBorrow = borrows
.sort((left, right) => left.marketValueRefreshed.comparedTo(right.marketValueRefreshed))
.at(-1);
return {
mint: highestValueBorrow.mintAddress,
amount: highestValueBorrow.amount,
};
}
}
exports.DeleverageAllDebt = DeleverageAllDebt;
// Internal type ID mappings:
const CONDITION_TO_TYPE_ID = new Map([
// Note: the special value of 0 (Never) is represented in the SDK by `KaminoObligationOrder === null`.
[UserLtvAbove, 1],
[UserLtvBelow, 2],
[DebtCollPriceRatioAbove, 3],
[DebtCollPriceRatioBelow, 4],
]);
const OPPORTUNITY_TO_TYPE_ID = new Map([
[DeleverageDebtAmount, 0],
[DeleverageAllDebt, 1],
]);
const TYPE_ID_TO_CONDITION = new Map([...CONDITION_TO_TYPE_ID].map(([type, id]) => [id, type]));
const TYPE_ID_TO_OPPORTUNITY = new Map([...OPPORTUNITY_TO_TYPE_ID].map(([type, id]) => [id, type]));
// Core types:
/**
* A business wrapper around the on-chain {@link ObligationOrder} account data.
*/
class KaminoObligationOrder {
/**
* An on-chain data representing a `null` order.
*/
static NULL_STATE = new types_1.ObligationOrder({
conditionType: 0,
conditionThresholdSf: new bn_js_1.default(0),
opportunityType: 0,
opportunityParameterSf: new bn_js_1.default(0),
minExecutionBonusBps: 0,
maxExecutionBonusBps: 0,
padding1: Array(10).fill(0),
padding2: Array(5).fill(new bn_js_1.default(0)),
});
/**
* The order's condition.
*/
condition;
/**
* The order's opportunity.
*/
opportunity;
/**
* The minimum bonus rate (e.g. `0.01` meaning "1%") offered to a liquidator executing this order when its condition
* threshold has been barely crossed.
*/
minExecutionBonusRate;
/**
* The maximum bonus rate (e.g. `0.04` meaning "4%") offered to a liquidator executing this order when its condition
* threshold has already been exceeded by a very large margin (to be specific: maximum possible margin - e.g. for
* LTV-based stop-loss order, that would be when the obligation's LTV is approaching its liquidation LTV).
*/
maxExecutionBonusRate;
/**
* Direct constructor.
*
* Please see {@link fromState()} if you are constructing an instance representing existing on-chain data.
*/
constructor(condition, opportunity, minExecutionBonusRate, maxExecutionBonusRate = minExecutionBonusRate) {
this.condition = condition;
this.opportunity = opportunity;
this.minExecutionBonusRate = minExecutionBonusRate;
this.maxExecutionBonusRate = maxExecutionBonusRate;
}
/**
* Returns the highest-valued {@link AvailableOrderExecution} currently offered by this order.
*
* May return `undefined` when the order's condition is not met.
*/
findMaxAvailableExecution(kaminoMarket, obligation) {
const conditionHit = this.condition.evaluate(obligation);
if (conditionHit === null) {
return undefined; // condition not met - cannot execute
}
const maxRepay = this.opportunity.getMaxRepay(obligation.getBorrows());
const repayBorrow = obligation.getBorrowByMint(maxRepay.mint);
const maxRepayValue = tokenAmountToValue(maxRepay, repayBorrow);
const executionBonusRate = this.calculateExecutionBonusRate(conditionHit, obligation);
const executionBonusFactor = new decimal_js_1.default(1).add(executionBonusRate);
const maxWithdrawValue = maxRepayValue.mul(executionBonusFactor);
// The order execution only allows us to pick the lowest-liquidation-LTV deposit for withdrawal (excluding 0-LTV
// assets, which are never liquidatable), hence we pre-filter the candidate deposits:
const liquidationLtvsOfDeposits = obligation
.getDeposits()
.map((deposit) => [
obligation.getLtvForReserve(kaminoMarket, deposit.reserveAddress).liquidationLtv,
deposit,
]);
const liquidatableDeposits = liquidationLtvsOfDeposits.filter(([liquidationLtv, _deposit]) => liquidationLtv.gt(0));
// Note: in theory, we could use the Obligation's `lowestReserveDepositLiquidationLtv` (cached by SC) here, but it
// is equally easy to just find the minimum (and avoid any issues related to stale `KaminoObligation` state or
// `Decimal` rounding/comparison).
const minLiquidationLtv = decimal_js_1.default.min(...liquidatableDeposits.map(([liquidationLtv, _deposit]) => liquidationLtv));
const [actualWithdrawValue, withdrawDeposit] = liquidatableDeposits
.filter(([liquidationLtv, _deposit]) => liquidationLtv.eq(minLiquidationLtv))
.map(([_liquidationLtv, deposit]) => {
const availableWithdrawValue = decimal_js_1.default.min(deposit.marketValueRefreshed, maxWithdrawValue);
return [availableWithdrawValue, deposit];
})
.sort(([leftValue, leftDeposit], [rightValue, rightDeposit]) => {
const valueComparison = leftValue.comparedTo(rightValue);
if (valueComparison !== 0) {
return valueComparison;
}
// Just for deterministic selection in case of multiple equally-good deposits: pick the one with lower mint pubkey (mostly for test stability purposes)
return leftDeposit.mintAddress.toBase58().localeCompare(rightDeposit.mintAddress.toBase58());
})
.at(-1);
const actualRepayValue = actualWithdrawValue.div(executionBonusFactor);
return {
repay: valueToTokenAmount(actualRepayValue, repayBorrow),
withdraw: valueToTokenAmount(actualWithdrawValue, withdrawDeposit),
bonusRate: executionBonusRate,
};
}
/**
* Constructs an instance based on the given on-chain data.
*
* Returns `null` if the input represents just an empty slot in the orders' array.
*/
static fromState(state) {
if (state.conditionType === KaminoObligationOrder.NULL_STATE.conditionType) {
return null; // In practice an entire null order is zeroed, but technically the "condition == never" is enough to consider the order "not active"
}
const conditionConstructor = TYPE_ID_TO_CONDITION.get(state.conditionType) ?? (0, utils_1.orThrow)(`Unknown condition type ${state.conditionType}`);
const condition = new conditionConstructor(new fraction_1.Fraction(state.conditionThresholdSf).toDecimal());
const opportunityConstructor = TYPE_ID_TO_OPPORTUNITY.get(state.opportunityType) ?? (0, utils_1.orThrow)(`Unknown opportunity type ${state.opportunityType}`);
const opportunity = new opportunityConstructor(new fraction_1.Fraction(state.opportunityParameterSf).toDecimal());
const minExecutionBonusRate = fraction_1.Fraction.fromBps(state.minExecutionBonusBps).toDecimal();
const maxExecutionBonusRate = fraction_1.Fraction.fromBps(state.maxExecutionBonusBps).toDecimal();
return new KaminoObligationOrder(condition, opportunity, minExecutionBonusRate, maxExecutionBonusRate);
}
/**
* Returns the on-chain state represented by this instance.
*
* See {@link NULL_STATE} for the state of a `null` order.
*/
toState() {
return new types_1.ObligationOrder({
...KaminoObligationOrder.NULL_STATE.toEncodable(),
conditionType: CONDITION_TO_TYPE_ID.get(Object.getPrototypeOf(this.condition).constructor) ??
(0, utils_1.orThrow)(`Unknown condition ${this.condition.constructor}`),
conditionThresholdSf: fraction_1.Fraction.fromDecimal(this.condition.threshold()).getValue(),
opportunityType: OPPORTUNITY_TO_TYPE_ID.get(Object.getPrototypeOf(this.opportunity).constructor) ??
(0, utils_1.orThrow)(`Unknown opportunity ${this.opportunity.constructor}`),
opportunityParameterSf: fraction_1.Fraction.fromDecimal(this.opportunity.parameter()).getValue(),
minExecutionBonusBps: (0, utils_1.roundNearest)(this.minExecutionBonusRate.mul(utils_2.ONE_HUNDRED_PCT_IN_BPS)).toNumber(),
maxExecutionBonusBps: (0, utils_1.roundNearest)(this.maxExecutionBonusRate.mul(utils_2.ONE_HUNDRED_PCT_IN_BPS)).toNumber(),
});
}
/**
* Binds this order to the given slot.
*
* This is just a convenience method for easier interaction with {@link KaminoAction#buildSetObligationOrderIxn()}.
*/
atIndex(index) {
return new ObligationOrderAtIndex(index, this);
}
/**
* Calculates the given order's actual execution bonus rate.
*
* The min-max bonus range is configured by the user on a per-order basis, and the actual value is interpolated based
* on the given obligation's state at the moment of order execution.
* In short: the minimum bonus applies if the order is executed precisely at the point when the condition is met.
* Then, as the distance from condition's threshold grows, the bonus approaches the configured maximum.
*
* On top of that, similar to regular liquidation, the bonus cannot exceed the ceiled limit of `1.0 - user_no_bf_ltv`
* (which ensures that order execution improves LTV).
*/
calculateExecutionBonusRate(conditionHit, obligation) {
const interpolatedBonusRate = interpolateBonusRate(conditionHit.normalizedDistanceFromThreshold, this.minExecutionBonusRate, this.maxExecutionBonusRate);
// In order to ensure that LTV improves on order execution, we apply the same heuristic formula as for the regular
// liquidations. Please note that we deliberately use the `obligation.noBfLoanToValue()`, which is consistent with
// the smart contract's calculation:
const diffToBadDebt = new decimal_js_1.default(1).sub(obligation.noBfLoanToValue());
return decimal_js_1.default.min(interpolatedBonusRate, diffToBadDebt);
}
}
exports.KaminoObligationOrder = KaminoObligationOrder;
/**
* A single slot within {@link Obligation.orders} (which may contain an order or not).
*
* This is used as an argument to {@link KaminoAction.buildSetObligationOrderIxn()} to easily set or cancel an order.
*/
class ObligationOrderAtIndex {
index;
order;
constructor(index, order) {
this.index = index;
this.order = order;
}
/**
* Creates an empty slot representation (suitable for cancelling an order).
*/
static empty(index) {
return new ObligationOrderAtIndex(index, null);
}
/**
* Returns the on-chain state of the order (potentially a zeroed account data, if the order is not set).
*/
orderState() {
return this.order !== null ? this.order.toState() : KaminoObligationOrder.NULL_STATE;
}
}
exports.ObligationOrderAtIndex = ObligationOrderAtIndex;
// Internal calculation functions:
function tokenAmountToValue(tokenAmount, position) {
if (!tokenAmount.mint.equals(position.mintAddress)) {
throw new Error(`Value of token amount ${tokenAmount} cannot be computed using data from ${position}`);
}
return tokenAmount.amount.mul(position.marketValueRefreshed).div(position.amount);
}
function valueToTokenAmount(value, position) {
const fractionalAmount = value.mul(position.amount).div(position.marketValueRefreshed);
return {
amount: (0, utils_1.roundNearest)(fractionalAmount),
mint: position.mintAddress,
};
}
function evaluateStopLoss(current_value, conditionThreshold, liquidationThreshold) {
if (current_value.lte(conditionThreshold)) {
return null; // SL not hit
}
let normalizedDistanceFromThreshold;
if (conditionThreshold.gt(liquidationThreshold)) {
// A theoretically-impossible case (the user may of course set his order's condition
// threshold that high, but then the current value is above liquidation threshold, so
// liquidation logic should kick in and never reach this function). Anyway, let's
// interpret it as maximum distance from threshold:
normalizedDistanceFromThreshold = new decimal_js_1.default(1);
}
else {
// By now we know they are both > 0:
const currentDistance = current_value.sub(conditionThreshold);
const maximumDistance = liquidationThreshold.sub(conditionThreshold);
normalizedDistanceFromThreshold = currentDistance.div(maximumDistance);
}
return { normalizedDistanceFromThreshold };
}
function evaluateTakeProfit(currentValue, conditionThreshold) {
if (currentValue.gte(conditionThreshold)) {
return null; // TP not hit
}
const distanceTowards0 = conditionThreshold.sub(currentValue); // by now we know it is > 0
return { normalizedDistanceFromThreshold: distanceTowards0.div(conditionThreshold) };
}
function calculateDebtCollPriceRatio(obligation) {
const singleBorrow = (0, validations_1.getSingleElement)(obligation.getBorrows(), 'borrow');
const singleDeposit = (0, validations_1.getSingleElement)(obligation.getDeposits(), 'deposit');
return calculateTokenPrice(singleBorrow).div(calculateTokenPrice(singleDeposit));
}
function calculateTokenPrice(position) {
return position.marketValueRefreshed.mul(position.mintFactor).div(position.amount);
}
function interpolateBonusRate(normalizedDistanceFromThreshold, minBonusRate, maxBonusRate) {
return minBonusRate.add(normalizedDistanceFromThreshold.mul(maxBonusRate.sub(minBonusRate)));
}
//# sourceMappingURL=obligationOrder.js.map
;