@kamino-finance/klend-sdk
Version:
Typescript SDK for interacting with the Kamino Lending (klend) protocol
625 lines (573 loc) • 24.5 kB
text/typescript
import {
ElevationGroupDescription,
FeeCalculation,
KaminoAction,
KaminoMarket,
KaminoObligation,
KaminoReserve,
} from '../classes';
import {
FlashLoanInfo,
getFlashLoanInstructions,
getScopeRefreshIx,
SwapIxsProvider,
SwapQuoteProvider,
} from '../leverage';
import {
createAtasIdempotent,
DEFAULT_MAX_COMPUTE_UNITS,
getAssociatedTokenAddress,
getComputeBudgetAndPriorityFeeIxs,
PublicKeySet,
ScopePriceRefreshConfig,
U64_MAX,
uniqueAccountsWithProgramIds,
} from '../utils';
import { AddressLookupTableAccount, PublicKey, TransactionInstruction } from '@solana/web3.js';
import Decimal from 'decimal.js';
import { createCloseAccountInstruction, NATIVE_MINT, TOKEN_PROGRAM_ID } from '@solana/spl-token';
/**
* Inputs to the `getSwapCollIxs()` operation.
*/
export interface SwapCollIxsInputs<QuoteResponse> {
/**
* The amount of source collateral to be swapped-in for the target collateral.
* This value will be treated exactly (i.e. slippage is not applied here) and thus must not exceed the collateral's
* total amount.
*/
sourceCollSwapAmount: Decimal;
/**
* If true, the source collateral will be closed - whatever amount is left after withdrawing `sourceCollSwapAmount`
* will be transferred to the user.
*/
isClosingSourceColl: boolean;
/**
* The mint of the source collateral token (i.e. the current one).
*/
sourceCollTokenMint: PublicKey;
/**
* The mint of the target collateral token (i.e. the new one).
*/
targetCollTokenMint: PublicKey;
/**
* An elevation group ID that the obligation should end up with after the collateral swap - it will be requested by
* this operation (if different from the obligation's current elevation group).
*/
newElevationGroup: number;
// Note: the undocumented inputs below all have their most usual meaning used across the SDK.
market: KaminoMarket;
obligation: KaminoObligation;
referrer: PublicKey;
currentSlot: number;
budgetAndPriorityFeeIxs?: TransactionInstruction[];
scopeRefreshConfig?: ScopePriceRefreshConfig;
useV2Ixs: boolean;
quoter: SwapQuoteProvider<QuoteResponse>;
swapper: SwapIxsProvider<QuoteResponse>;
logger?: (msg: string, ...extra: any[]) => void;
}
/**
* Outputs from the `getSwapCollIxs()` operation.
*/
export interface SwapCollIxsOutputs<QuoteResponse> {
/**
* Instructions for on-chain execution.
*/
ixs: TransactionInstruction[];
/**
* Required LUTs.
*/
lookupTables: AddressLookupTableAccount[];
/**
* Whether the swap is using V2 instructions.
*/
useV2Ixs: boolean;
/**
* Informational-only details of the token amounts/fees/rates that were used during construction of `ixs`.
*/
simulationDetails: {
/**
* Details related to the flash-loan operation needed during collateral swap.
*/
flashLoan: {
/**
* The amount of flash-borrowed target collateral.
* It is also *exactly the amount of target collateral that gets added to the obligation*.
*/
targetCollFlashBorrowedAmount: Decimal;
/**
* The flash-repaid amount.
* Simply a `targetCollFlashBorrowedAmount` + any flash-loan fees.
*/
targetCollFlashRepaidAmount: Decimal;
};
/**
* Details related to the external DEX's swap operation (i.e. `swapper` input) needed during collateral swap.
*/
externalSwap: {
/**
* The amount swapped-in to an external DEX.
* It is also *exactly the amount of source collateral that gets removed from the obligation* (i.e. echoed back
* `sourceCollSwapAmount` input).
*/
sourceCollSwapInAmount: Decimal;
/**
* The amount swapped-out from an external DEX.
* Please note that this field will be equal to the `flashBorrow.targetCollFlashRepaidAmount`, but an actual
* on-chain swap-out is subject to slippage.
*/
targetCollSwapOutAmount: Decimal;
/**
* The verbatim response coming from the input `quoter`.
*/
quoteResponse?: QuoteResponse;
};
};
}
/**
* Constructs instructions needed to partially/fully swap the given source collateral for some other collateral type.
*/
export async function getSwapCollIxs<QuoteResponse>(
inputs: SwapCollIxsInputs<QuoteResponse>
): Promise<Array<SwapCollIxsOutputs<QuoteResponse>>> {
const [args, context] = extractArgsAndContext(inputs);
// Conceptually, we need to construct the following ixs:
// 0. any set-up, like budgeting and ATAs
// 1. `flash-borrowed target coll = targetCollReserve.flashBorrow()`
// 2. `targetCollReserve.deposit(flash-borrowed target coll)`
// 3. `sourceCollReserve.withdraw(requested amount to be coll-swapped)`
// 4. `externally-swapped target coll = externalDex.swap(withdrawn current coll)`
// 5. `flashRepay(externally-swapped target coll)`
// However, there is a cyclic dependency:
// - To construct 4. (specifically, to query the external swap quote), we need to know all accounts used by Kamino's
// own ixs.
// - To construct 1. (i.e. flash-borrow), we need to know the target collateral swap-out from 4.
// Construct the Klend's own ixs with a fake swap-out (only to learn the klend accounts used):
const fakeKlendIxs = await getKlendIxs(args, FAKE_TARGET_COLL_SWAP_OUT_AMOUNT, context);
const klendAccounts = uniqueAccountsWithProgramIds(listIxs(fakeKlendIxs));
// Construct the external swap ixs (and learn the actual swap-out amount):
const externalSwapIxsArray = await getExternalSwapIxs(args, klendAccounts, context);
return Promise.all(
externalSwapIxsArray.map(async (externalSwapIxs) => {
// We now have the full information needed to simulate the end-state, so let's check that the operation is legal:
context.logger(
`Expected to swap ${args.sourceCollSwapAmount} ${context.sourceCollReserve.symbol} collateral into ${externalSwapIxs.swapOutAmount} ${context.targetCollReserve.symbol} collateral`
);
checkResultingObligationValid(args, externalSwapIxs.swapOutAmount, context);
// Construct the Klend's own ixs with an actual swap-out amount:
const klendIxs = await getKlendIxs(args, externalSwapIxs.swapOutAmount, context);
return {
ixs: listIxs(klendIxs, externalSwapIxs.ixs),
lookupTables: externalSwapIxs.luts,
useV2Ixs: context.useV2Ixs,
simulationDetails: {
flashLoan: {
targetCollFlashBorrowedAmount: klendIxs.simulationDetails.targetCollFlashBorrowedAmount,
targetCollFlashRepaidAmount: externalSwapIxs.swapOutAmount,
},
externalSwap: {
sourceCollSwapInAmount: args.sourceCollSwapAmount, // repeated `/inputs.sourceCollSwapAmount`, only for clarity
targetCollSwapOutAmount: externalSwapIxs.swapOutAmount, // repeated `../flashLoan.targetCollFlashRepaidAmount`, only for clarity
quoteResponse: externalSwapIxs.simulationDetails.quoteResponse,
},
},
};
})
);
}
type SwapCollArgs = {
sourceCollSwapAmount: Decimal;
isClosingSourceColl: boolean;
newElevationGroup: ElevationGroupDescription | null;
};
type SwapCollContext<QuoteResponse> = {
budgetAndPriorityFeeIxs: TransactionInstruction[];
market: KaminoMarket;
sourceCollReserve: KaminoReserve;
targetCollReserve: KaminoReserve;
obligation: KaminoObligation;
quoter: SwapQuoteProvider<QuoteResponse>;
swapper: SwapIxsProvider<QuoteResponse>;
referrer: PublicKey;
currentSlot: number;
useV2Ixs: boolean;
scopeRefreshConfig: ScopePriceRefreshConfig | undefined;
logger: (msg: string, ...extra: any[]) => void;
};
function extractArgsAndContext<QuoteResponse>(
inputs: SwapCollIxsInputs<QuoteResponse>
): [SwapCollArgs, SwapCollContext<QuoteResponse>] {
if (inputs.sourceCollTokenMint.equals(inputs.targetCollTokenMint)) {
throw new Error(`Cannot swap from/to the same collateral`);
}
if (inputs.sourceCollSwapAmount.lte(0)) {
throw new Error(`Cannot swap a negative amount`);
}
return [
{
sourceCollSwapAmount: inputs.sourceCollSwapAmount,
isClosingSourceColl: inputs.isClosingSourceColl,
newElevationGroup: inputs.market.getExistingElevationGroup(inputs.newElevationGroup, 'Newly-requested'),
},
{
budgetAndPriorityFeeIxs:
inputs.budgetAndPriorityFeeIxs || getComputeBudgetAndPriorityFeeIxs(DEFAULT_MAX_COMPUTE_UNITS),
sourceCollReserve: inputs.market.getExistingReserveByMint(inputs.sourceCollTokenMint, 'Current collateral'),
targetCollReserve: inputs.market.getExistingReserveByMint(inputs.targetCollTokenMint, 'Target collateral'),
logger: console.log,
market: inputs.market,
obligation: inputs.obligation,
quoter: inputs.quoter,
swapper: inputs.swapper,
referrer: inputs.referrer,
scopeRefreshConfig: inputs.scopeRefreshConfig,
currentSlot: inputs.currentSlot,
useV2Ixs: inputs.useV2Ixs,
},
];
}
const FAKE_TARGET_COLL_SWAP_OUT_AMOUNT = new Decimal(1); // see the lengthy `getSwapCollIxs()` impl comment
type SwapCollKlendIxs = {
setupIxs: TransactionInstruction[];
targetCollFlashBorrowIx: TransactionInstruction;
depositTargetCollIxs: TransactionInstruction[];
withdrawSourceCollIxs: TransactionInstruction[];
targetCollFlashRepayIx: TransactionInstruction;
cleanupIxs: TransactionInstruction[];
flashLoanInfo: FlashLoanInfo;
simulationDetails: {
targetCollFlashBorrowedAmount: Decimal;
};
};
async function getKlendIxs(
args: SwapCollArgs,
targetCollSwapOutAmount: Decimal,
context: SwapCollContext<any>
): Promise<SwapCollKlendIxs> {
const { ataCreationIxs, targetCollAta } = getAtaCreationIxs(context);
const setupIxs = [...context.budgetAndPriorityFeeIxs, ...ataCreationIxs];
const scopeRefreshIxn = await getScopeRefreshIx(
context.market,
context.sourceCollReserve,
context.targetCollReserve,
context.obligation,
context.scopeRefreshConfig
);
if (scopeRefreshIxn) {
setupIxs.unshift(...scopeRefreshIxn);
}
const targetCollFlashBorrowedAmount = calculateTargetCollFlashBorrowedAmount(targetCollSwapOutAmount, context);
const { targetCollFlashBorrowIx, targetCollFlashRepayIx } = getTargetCollFlashLoanIxs(
targetCollFlashBorrowedAmount,
setupIxs.length,
targetCollAta,
context
);
const depositTargetCollIxs = await getDepositTargetCollIxs(targetCollFlashBorrowedAmount, context);
const withdrawSourceCollIxs = await getWithdrawSourceCollIxs(
args,
depositTargetCollIxs.removesElevationGroup,
context
);
const cleanupIxs = getAtaCloseIxs(context);
return {
setupIxs,
flashLoanInfo: {
flashBorrowReserve: context.targetCollReserve.address,
flashLoanFee: context.targetCollReserve.getFlashLoanFee(),
},
targetCollFlashBorrowIx,
depositTargetCollIxs: depositTargetCollIxs.ixs,
withdrawSourceCollIxs,
targetCollFlashRepayIx,
cleanupIxs,
simulationDetails: {
targetCollFlashBorrowedAmount,
},
};
}
function calculateTargetCollFlashBorrowedAmount(
targetCollFlashRepaidAmount: Decimal,
context: SwapCollContext<any>
): Decimal {
const { protocolFees, referrerFees } = context.targetCollReserve.calculateFees(
targetCollFlashRepaidAmount.mul(context.targetCollReserve.getMintFactor()),
context.targetCollReserve.getFlashLoanFee(),
FeeCalculation.Inclusive, // denotes that the amount parameter above means "to be repaid" (not "borrowed")
context.market.state.referralFeeBps,
!context.referrer.equals(PublicKey.default)
);
const targetCollFlashLoanFee = protocolFees.add(referrerFees).div(context.targetCollReserve.getMintFactor());
return targetCollFlashRepaidAmount.sub(targetCollFlashLoanFee);
}
function getAtaCreationIxs(context: SwapCollContext<any>) {
const atasAndAtaCreationIxs = createAtasIdempotent(context.obligation.state.owner, [
{
mint: context.sourceCollReserve.getLiquidityMint(),
tokenProgram: context.sourceCollReserve.getLiquidityTokenProgram(),
},
{
mint: context.targetCollReserve.getLiquidityMint(),
tokenProgram: context.targetCollReserve.getLiquidityTokenProgram(),
},
]);
return {
ataCreationIxs: atasAndAtaCreationIxs.map((tuple) => tuple.createAtaIx),
targetCollAta: atasAndAtaCreationIxs[1].ata,
};
}
function getAtaCloseIxs(context: SwapCollContext<any>) {
const ataCloseIxs: TransactionInstruction[] = [];
if (
context.sourceCollReserve.getLiquidityMint().equals(NATIVE_MINT) ||
context.targetCollReserve.getLiquidityMint().equals(NATIVE_MINT)
) {
const owner = context.obligation.state.owner;
const wsolAta = getAssociatedTokenAddress(NATIVE_MINT, owner, false);
ataCloseIxs.push(createCloseAccountInstruction(wsolAta, owner, owner, [], TOKEN_PROGRAM_ID));
}
return ataCloseIxs;
}
function getTargetCollFlashLoanIxs(
targetCollAmount: Decimal,
flashBorrowIxIndex: number,
destinationAta: PublicKey,
context: SwapCollContext<any>
) {
const { flashBorrowIx: targetCollFlashBorrowIx, flashRepayIx: targetCollFlashRepayIx } = getFlashLoanInstructions({
borrowIxIndex: flashBorrowIxIndex,
walletPublicKey: context.obligation.state.owner,
lendingMarketAuthority: context.market.getLendingMarketAuthority(),
lendingMarketAddress: context.market.getAddress(),
reserve: context.targetCollReserve,
amountLamports: targetCollAmount.mul(context.targetCollReserve.getMintFactor()),
destinationAta,
// TODO(referrals): once we support referrals, we will have to replace the placeholder args below:
referrerAccount: context.market.programId,
referrerTokenState: context.market.programId,
programId: context.market.programId,
});
return { targetCollFlashBorrowIx, targetCollFlashRepayIx };
}
type DepositTargetCollIxs = {
removesElevationGroup: boolean;
ixs: TransactionInstruction[];
};
async function getDepositTargetCollIxs(
targetCollAmount: Decimal,
context: SwapCollContext<any>
): Promise<DepositTargetCollIxs> {
const removesElevationGroup = mustRemoveElevationGroupBeforeDeposit(context);
const depositCollAction = await KaminoAction.buildDepositTxns(
context.market,
targetCollAmount.mul(context.targetCollReserve.getMintFactor()).toString(), // in lamports
context.targetCollReserve.getLiquidityMint(),
context.obligation.state.owner,
context.obligation,
context.useV2Ixs,
undefined, // we create the scope refresh ix outside of KaminoAction
0, // no extra compute budget
false, // we do not need ATA ixs here (we construct and close them ourselves)
removesElevationGroup, // we may need to (temporarily) remove the elevation group; the same or a different one will be set on withdraw, if requested
{ skipInitialization: true, skipLutCreation: true }, // we are dealing with an existing obligation, no need to create user metadata
context.referrer,
context.currentSlot,
removesElevationGroup ? 0 : undefined // only applicable when removing the group
);
return {
ixs: KaminoAction.actionToIxs(depositCollAction),
removesElevationGroup,
};
}
function mustRemoveElevationGroupBeforeDeposit(context: SwapCollContext<any>): boolean {
if (context.obligation.deposits.has(context.targetCollReserve.address)) {
return false; // the target collateral already was a reserve in the obligation, so we do not affect any potential elevation group
}
const currentElevationGroupId = context.obligation.state.elevationGroup;
if (currentElevationGroupId == 0) {
return false; // simply nothing to remove
}
if (!context.targetCollReserve.state.config.elevationGroups.includes(currentElevationGroupId)) {
return true; // the target collateral reserve is NOT in the obligation's group - must remove the group
}
const currentElevationGroup = context.market.getElevationGroup(currentElevationGroupId);
if (context.obligation.deposits.size >= currentElevationGroup.maxReservesAsCollateral) {
return true; // the obligation is already at its elevation group's deposits count limit - must remove the group
}
return false; // the obligation has some elevation group and the new collateral can be added to it
}
async function getWithdrawSourceCollIxs(
args: SwapCollArgs,
depositRemovedElevationGroup: boolean,
context: SwapCollContext<any>
): Promise<TransactionInstruction[]> {
const withdrawnSourceCollLamports = args.isClosingSourceColl
? U64_MAX
: args.sourceCollSwapAmount.mul(context.sourceCollReserve.getMintFactor()).toString();
const requestedElevationGroup = elevationGroupIdToRequestAfterWithdraw(args, depositRemovedElevationGroup, context);
const withdrawCollAction = await KaminoAction.buildWithdrawTxns(
context.market,
withdrawnSourceCollLamports,
context.sourceCollReserve.getLiquidityMint(),
context.obligation.state.owner,
context.obligation,
context.useV2Ixs,
undefined, // we create the scope refresh ix outside of KaminoAction
0, // no extra compute budget
false, // we do not need ATA ixs here (we construct and close them ourselves)
requestedElevationGroup !== undefined, // the `elevationGroupIdToRequestAfterWithdraw()` has already decided on this
{ skipInitialization: true, skipLutCreation: true }, // we are dealing with an existing obligation, no need to create user metadata
context.referrer,
context.currentSlot,
requestedElevationGroup,
context.obligation.deposits.has(context.targetCollReserve.address) // if our obligation already had the target coll...
? undefined // ... then we need no customizations here, but otherwise...
: {
addedDepositReserves: [context.targetCollReserve.address], // ... we need to inform our infra that the obligation now has one more reserve that needs refreshing.
}
);
return KaminoAction.actionToIxs(withdrawCollAction);
}
function elevationGroupIdToRequestAfterWithdraw(
args: SwapCollArgs,
depositRemovedElevationGroup: boolean,
context: SwapCollContext<any>
): number | undefined {
const obligationInitialElevationGroup = context.obligation.state.elevationGroup;
const requestedElevationGroupId = args.newElevationGroup?.elevationGroup ?? 0;
if (requestedElevationGroupId === 0) {
// the user doesn't want any elevation group, and...
if (obligationInitialElevationGroup === 0) {
return undefined; // ... he already didn't have it - fine!
}
if (depositRemovedElevationGroup) {
return undefined; // ... our deposit already forced us to remove it - fine!
}
return 0; // ... but he *did have one*, and our deposit didn't need to remove it - so we remove it now, just to satisfy him
} else {
// the user wants some elevation group, and...
if (depositRemovedElevationGroup) {
return requestedElevationGroupId; // ...our deposit forced us to remove it - so we now request the new one, whatever it is
}
if (obligationInitialElevationGroup === requestedElevationGroupId) {
return undefined; // ...and he already had exactly this one - fine!
}
return requestedElevationGroupId; // ...and he had some different one - so we request the new one
}
}
type ExternalSwapIxs<QuoteResponse> = {
swapOutAmount: Decimal;
ixs: TransactionInstruction[];
luts: AddressLookupTableAccount[];
simulationDetails: {
quoteResponse?: QuoteResponse;
};
};
async function getExternalSwapIxs<QuoteResponse>(
args: SwapCollArgs,
klendAccounts: PublicKey[],
context: SwapCollContext<QuoteResponse>
): Promise<Array<ExternalSwapIxs<QuoteResponse>>> {
const externalSwapInputs = {
inputAmountLamports: args.sourceCollSwapAmount.mul(context.sourceCollReserve.getMintFactor()),
inputMint: context.sourceCollReserve.getLiquidityMint(),
outputMint: context.targetCollReserve.getLiquidityMint(),
amountDebtAtaBalance: undefined, // only used for kTokens
};
const externalSwapQuote = await context.quoter(externalSwapInputs, klendAccounts);
const externalSwapIxsAndLuts = await context.swapper(externalSwapInputs, klendAccounts, externalSwapQuote);
// Note: we can ignore the returned `preActionIxs` field - we do not request any of them from the swapper.
return externalSwapIxsAndLuts.map((externalSwapIxsAndLuts) => {
const swapOutAmount = externalSwapIxsAndLuts.quote.priceAInB.mul(args.sourceCollSwapAmount);
return {
swapOutAmount,
ixs: externalSwapIxsAndLuts.swapIxs,
luts: externalSwapIxsAndLuts.lookupTables,
simulationDetails: {
quoteResponse: externalSwapIxsAndLuts.quote.quoteResponse,
},
};
});
}
function checkResultingObligationValid(
args: SwapCollArgs,
targetCollAmount: Decimal,
context: SwapCollContext<any>
): void {
// The newly-requested elevation group must have its conditions satisfied:
if (args.newElevationGroup !== null) {
// Note: we cannot use the existing `isLoanEligibleForElevationGroup()`, since it operates on a `KaminoObligation`,
// and our instance is stale (we want to assert on the state *after* potential changes).
// Let's start with the (simpler) debt reserve - it cannot change during a coll-swap:
const debtReserveAddresses = [...context.obligation.borrows.keys()];
if (debtReserveAddresses.length > 1) {
throw new Error(
`The obligation with ${debtReserveAddresses.length} debt reserves cannot request any elevation group`
);
}
if (debtReserveAddresses.length == 1) {
const debtReserveAddress = debtReserveAddresses[0];
if (!args.newElevationGroup.debtReserve.equals(debtReserveAddress)) {
throw new Error(
`The obligation with debt reserve ${debtReserveAddress.toBase58()} cannot request elevation group ${
args.newElevationGroup.elevationGroup
}`
);
}
}
// Now the coll reserves: this requires first finding out the resulting set of deposits:
const collReserveAddresses = new PublicKeySet([
...context.obligation.deposits.keys(),
context.targetCollReserve.address,
]);
if (args.isClosingSourceColl) {
collReserveAddresses.remove(context.sourceCollReserve.address);
}
if (collReserveAddresses.size() > args.newElevationGroup.maxReservesAsCollateral) {
throw new Error(
`The obligation with ${collReserveAddresses.size()} collateral reserves cannot request elevation group ${
args.newElevationGroup.elevationGroup
}`
);
}
for (const collReserveAddress of collReserveAddresses.toArray()) {
if (!args.newElevationGroup.collateralReserves.contains(collReserveAddress)) {
throw new Error(
`The obligation with collateral reserve ${collReserveAddress.toBase58()} cannot request elevation group ${
args.newElevationGroup.elevationGroup
}`
);
}
}
}
// The LTV cannot be exceeded:
const effectiveWithdrawAmount = args.isClosingSourceColl
? context.obligation.getDepositAmountByReserve(context.sourceCollReserve)
: args.sourceCollSwapAmount;
const resultingStats = context.obligation.getPostSwapCollObligationStats({
withdrawAmountLamports: effectiveWithdrawAmount.mul(context.sourceCollReserve.getMintFactor()),
withdrawReserveAddress: context.sourceCollReserve.address,
depositAmountLamports: targetCollAmount.mul(context.targetCollReserve.getMintFactor()),
depositReserveAddress: context.targetCollReserve.address,
market: context.market,
newElevationGroup: args.newElevationGroup?.elevationGroup ?? 0,
slot: context.currentSlot,
});
const maxLtv = resultingStats.borrowLimit.div(resultingStats.userTotalCollateralDeposit);
if (resultingStats.loanToValue > maxLtv) {
throw new Error(
`Swapping collateral ${effectiveWithdrawAmount} ${context.sourceCollReserve.symbol} into ${targetCollAmount} ${context.targetCollReserve.symbol} would result in the obligation's LTV ${resultingStats.loanToValue} exceeding its max LTV ${maxLtv}`
);
}
}
function listIxs(klendIxs: SwapCollKlendIxs, externalSwapIxs?: TransactionInstruction[]): TransactionInstruction[] {
return [
...klendIxs.setupIxs,
klendIxs.targetCollFlashBorrowIx,
...klendIxs.depositTargetCollIxs,
...klendIxs.withdrawSourceCollIxs,
...(externalSwapIxs || []),
klendIxs.targetCollFlashRepayIx,
...klendIxs.cleanupIxs,
];
}