UNPKG

@kamino-finance/klend-sdk

Version:

Typescript SDK for interacting with the Kamino Lending (klend) protocol

407 lines 19.6 kB
"use strict"; 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