Skip to main content

Repay with collateral reduces or eliminates debt in a single, atomic transaction by converting collateral into the debt token without requiring additional funds.

When a repay with collateral operation is initiated, the protocol temporarily borrows the debt token (for example, CASH) through a flash loan and uses it to repay the existing debt. As the debt is reduced, a portion of the collateral token (for example, SOL) becomes withdrawable and is withdrawn from Kamino Lend. The withdrawn collateral is then swapped into the debt token using KSwap (Kamino’s token swap SDK), and the proceeds are used to repay the flash loan before the transaction completes. The result is a position with reduced debt and reduced collateral, without requiring additional funds from the user.

Repay with Collateral using KSwap

Reduce debt in a single transaction by converting collateral using a flash loan and KSwap.
1

Import Dependencies

Import the required packages for Solana RPC communication, Kamino SDK operations, KSwap routing, and transaction building.
import {
  createSolanaRpc,
  createSolanaRpcSubscriptions,
  address,
  pipe,
  createTransactionMessage,
  setTransactionMessageFeePayerSigner,
  setTransactionMessageLifetimeUsingBlockhash,
  appendTransactionMessageInstructions,
  signTransactionMessageWithSigners,
  sendAndConfirmTransactionFactory,
  getSignatureFromTransaction,
  none,
  compressTransactionMessageUsingAddressLookupTables,
} from '@solana/kit';
import type { Address, Instruction } from '@solana/kit';
import {
  KaminoMarket,
  VanillaObligation,
  PROGRAM_ID,
  parseKeypairFile,
  getRepayWithCollIxs,
  getUserLutAddressAndSetupIxs,
  getComputeBudgetAndPriorityFeeIxs,
  DEFAULT_RECENT_SLOT_DURATION_MS,
} from '@kamino-finance/klend-sdk';
import { KswapSdk } from '@kamino-finance/kswap-sdk';
import { Decimal } from 'decimal.js';
import { getKswapQuoter, getKswapSwapper } from './kswap_utils.js';
import { fetchAllAddressLookupTable } from '@solana-program/address-lookup-table';
2

Load Configuration and Initialize SDKs

Load the keypair and initialize RPC connections, the market, and KSwap for routing.
const KEYPAIR_FILE = '/path/to/your/keypair.json';
const CDN_ENDPOINT = 'https://cdn.kamino.finance';

const signer = await parseKeypairFile(KEYPAIR_FILE);

const rpc = createSolanaRpc('https://api.mainnet-beta.solana.com');
const rpcSubscriptions = createSolanaRpcSubscriptions('wss://api.mainnet-beta.solana.com');

const marketPubkey = address('7u3HeHxYDLhnCoErrtycNokbQYbWGzLs6JSDqGAv5PfF');
const collTokenMint = address('So11111111111111111111111111111111111111112');
const debtTokenMint = address('CASHx9KJUStyftLFWGvEVf59SGeG9sh5FfcnZMVPCASH');
const MAIN_MARKET_LUT = address('GprZNyWk67655JhX6Rq9KoebQ6WkQYRhATWzkx2P2LNc');

const market = await KaminoMarket.load(rpc, marketPubkey, DEFAULT_RECENT_SLOT_DURATION_MS);
const kswapSdk = new KswapSdk('https://api.kamino.finance/kswap', rpc, rpcSubscriptions);
Replace /path/to/your/keypair.json with the desired keypair path. The KSwap SDK enables multi-DEX routing for optimal swap execution.
3

Fetch Repay with Collateral Lookup Tables

Fetch repay-specific Address Lookup Tables (LUTs) from Kamino’s CDN to compress transaction size.
const kaminoResourcesResponse = await fetch(`${CDN_ENDPOINT}/resources.json`);
const kaminoResourcesData = await kaminoResourcesResponse.json();
const kaminoResources = kaminoResourcesData['mainnet-beta'];
const repayWithCollLUTsKey = `${collTokenMint}-${debtTokenMint}`;
const repayWithCollLut: string[] = kaminoResources.repayWithCollLUTs[repayWithCollLUTsKey] || [];
const repayWithCollLutKeys = repayWithCollLut.map((lut) => address(lut));
Lookup Tables store frequently-used addresses, allowing transactions to reference 1-byte indexes instead of 32-byte addresses. This reduces transaction size and is critical for complex repay operations.
4

Configure Repay Parameters

Set the repay amount and slippage tolerance.
const repayAmount = new Decimal(3);
const slippageBps = 50;
5

Check for Existing Obligation

Retrieve the obligation for this wallet. The obligation must exist to perform repay with collateral.
const obligation = await market!.getObligationByWallet(signer.address, new VanillaObligation(PROGRAM_ID));

if (!obligation) {
  throw new Error(`No obligation found for wallet: ${signer.address}`);
}
Unlike multiply deposits which can create new obligations, repay with collateral requires an existing position with both collateral and debt.
6

Setup User Lookup Table

Generate setup instructions for creating or extending a user-specific lookup table.
const [userLookupTable, setupTxsIxs] = await getUserLutAddressAndSetupIxs(market!, signer, none(), false);
Execute the setup transactions if any are needed. The loop builds, signs, and sends each setup transaction, waiting 2 seconds between each to ensure proper ordering.
for (const setupIxs of setupTxsIxs) {
  const { value: setupBlockhash } = await rpc.getLatestBlockhash({ commitment: 'finalized' }).send();

  const setupTx = pipe(
    createTransactionMessage({ version: 0 }),
    (tx) => setTransactionMessageFeePayerSigner(signer, tx),
    (tx) => setTransactionMessageLifetimeUsingBlockhash(setupBlockhash, tx),
    (tx) => appendTransactionMessageInstructions(setupIxs, tx)
  );

  const signedSetupTx = await signTransactionMessageWithSigners(setupTx);

  await sendAndConfirmTransactionFactory({ rpc, rpcSubscriptions })(signedSetupTx, {
    commitment: 'confirmed',
    skipPreflight: true,
  });

  await new Promise((resolve) => setTimeout(resolve, 2000));
}
Setup transactions extend the lookup table with addresses needed for the operation. This is typically a one-time operation - if the LUT already exists, setupTxsIxs will be empty and the loop won’t execute.
7

Build Multi-Route Swap Instructions

Set the transaction compute budget and priority fee for reliable execution. Then use KSwap to generate multiple swap route options for converting collateral to debt token.
const currentSlot = await rpc.getSlot().send();
const collTokenReserve = market!.getReserveByMint(collTokenMint)!;
const debtTokenReserve = market!.getReserveByMint(debtTokenMint)!;

const computeIxs = getComputeBudgetAndPriorityFeeIxs(1_400_000, new Decimal(500000));

const repayWithCollRoutes = await getRepayWithCollIxs({
  kaminoMarket: market!,
  debtTokenMint: debtTokenMint,
  collTokenMint: collTokenMint,
  owner: signer,
  obligation: obligation,
  referrer: none(),
  currentSlot,
  repayAmount: repayAmount,
  isClosingPosition: false,
  budgetAndPriorityFeeIxs: computeIxs,
  scopeRefreshIx: [],
  useV2Ixs: true,
  quoter: getKswapQuoter(kswapSdk, signer.address, slippageBps, collTokenReserve, debtTokenReserve),
  swapper: getKswapSwapper(kswapSdk, signer.address, slippageBps, collTokenReserve, debtTokenReserve),
});
KSwap SDK v2 simulates routes server-side, providing multiple route options. The quoter and swapper helper functions find optimal paths. See the Helper Functions section below for implementation.
8

Select Best Route

Prepare the Klend lookup tables for transaction compression.
const klendLookupTableKeys: Address[] = [];
klendLookupTableKeys.push(userLookupTable);
klendLookupTableKeys.push(...repayWithCollLutKeys);
klendLookupTableKeys.push(MAIN_MARKET_LUT);

const klendLutAccounts = await fetchAllAddressLookupTable(rpc, klendLookupTableKeys);
Check that KSwap returned routes.
console.log(`Got ${repayWithCollRoutes.length} route(s) from KSwap`);

if (repayWithCollRoutes.length === 0) {
  throw new Error('No routes found from KSwap');
}
Select the route with the smallest transaction size to maximize the chance of fitting within Solana’s transaction size limit.
const bestRouteData = repayWithCollRoutes.reduce((best, current) => {
  const bestTotalIxsSize = best.ixs.reduce((total: number, ix: Instruction) => {
    if (!ix?.data || !ix?.accounts) {
      console.warn(`Skipping invalid instruction in best route`);
      return total;
    }
    return total + ix.accounts.length * 32 + ix.data.byteLength + 1;
  }, 0);

  const currentTotalIxsSize = current.ixs.reduce((total: number, ix: Instruction) => {
    if (!ix?.data || !ix?.accounts) {
      return total;
    }
    return total + ix.accounts.length * 32 + ix.data.byteLength + 1;
  }, 0);

  return bestTotalIxsSize <= currentTotalIxsSize ? best : current;
});
Combine the route’s lookup tables with Klend’s lookup tables.
const lookupTables = bestRouteData.lookupTables;
lookupTables.push(...klendLutAccounts);

const bestRoute = {
  ixs: bestRouteData.ixs,
  luts: lookupTables.map((l) => l.address),
};

console.log(`Selected route: ${bestRouteData.quote!.routerType}`);
The best route is selected based on transaction size rather than price, as fitting within the 1232-byte limit is critical for transaction success.
9

Fetch Blockhash and Load Lookup Tables

Fetch a fresh blockhash for the transaction.
await new Promise((resolve) => setTimeout(resolve, 2000));

const { value: latestBlockhash } = await rpc.getLatestBlockhash({ commitment: 'finalized' }).send();
Fetch the lookup table accounts and extract all addresses they contain. Build a mapping of LUT address to the account addresses stored within.
const lutsByAddress: Record<Address, Address[]> = {};
const bestRouteLutAccounts = await fetchAllAddressLookupTable(rpc, bestRoute.luts || []);

const allLutAddresses = new Set<Address>();
for (const acc of bestRouteLutAccounts) {
  lutsByAddress[acc.address] = acc.data.addresses;
  acc.data.addresses.forEach((addr) => allLutAddresses.add(addr));
}
The blockhash sets the transaction’s validity window (~150 blocks). The LUT mapping allows the transaction builder to replace 32-byte addresses with 1-byte indexes.
10

Find and Fetch Missing Lookup Tables

Extract all account addresses used in the transaction instructions.
const instructionAccounts = new Set<Address>();
bestRoute.ixs.forEach((ix: Instruction) => {
  if (ix?.accounts) {
    ix.accounts.forEach((acc) => {
      if (acc?.address) {
        instructionAccounts.add(acc.address);
      }
    });
  }
});
Compare instruction accounts against existing LUT addresses to identify accounts not covered by any lookup table.
const accountsNotInLuts = Array.from(instructionAccounts).filter((addr) => !allLutAddresses.has(addr));
If missing accounts are found, call the Kamino API to find the minimal set of additional LUTs that contain those addresses.
if (accountsNotInLuts.length > 0) {
  console.log(`Finding minimal LUTs for ${accountsNotInLuts.length} missing accounts...`);

  const lutFinderResponse = await fetch('https://api.kamino.finance/luts/find-minimal', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      addresses: accountsNotInLuts,
      verify: false,
    }),
  });

  const lutFinderResult = await lutFinderResponse.json();
  const additionalLuts: string[] = lutFinderResult.lutAddresses || [];

  console.log(`Found ${additionalLuts.length} additional LUT(s)`);
Fetch the additional LUTs and add them to the transaction’s lookup table collection.
  if (additionalLuts.length > 0) {
    const additionalLutAddresses = additionalLuts.map((lut) => address(lut));
    const additionalLutAccounts = await fetchAllAddressLookupTable(rpc, additionalLutAddresses);

    for (const acc of additionalLutAccounts) {
      lutsByAddress[acc.address] = acc.data.addresses;
      acc.data.addresses.forEach((addr) => allLutAddresses.add(addr));
    }

    lookupTables.push(...additionalLutAccounts);
  }
}
This step ensures all instruction accounts are covered by lookup tables, preventing transaction size overflow. The API returns the minimal set of LUTs needed to cover the gaps.
11

Build, Sign, and Send Transaction

Build the transaction message with all instructions and compress it using the lookup tables.
const transactionMessage = pipe(
  createTransactionMessage({ version: 0 }),
  (tx) => appendTransactionMessageInstructions(bestRoute.ixs, tx),
  (tx) => setTransactionMessageFeePayerSigner(signer, tx),
  (tx) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx),
  (tx) => compressTransactionMessageUsingAddressLookupTables(tx, lutsByAddress)
);

const signedTransaction = await signTransactionMessageWithSigners(transactionMessage);
const signature = getSignatureFromTransaction(signedTransaction);

await sendAndConfirmTransactionFactory({ rpc, rpcSubscriptions })(signedTransaction, {
  commitment: 'processed',
  preflightCommitment: 'processed',
  skipPreflight: false,
});

console.log(`Repay with collateral successful! Signature: ${signature}`);
The repay with collateral transaction is complete, resulting in reduced debt and reduced collateral, all executed atomically in a single transaction.

Helper Functions

The tutorial requires KSwap helper functions for route building.
import BN from 'bn.js';
import { KaminoReserve } from '@kamino-finance/klend-sdk';
import type { SwapInputs, SwapQuote, SwapIxs, SwapIxsProvider, SwapQuoteProvider } from '@kamino-finance/klend-sdk';
import { KswapSdk, RouterContext } from '@kamino-finance/kswap-sdk';
import type { RouteOutput, RouteParams, RouterType, MintInfo } from '@kamino-finance/kswap-sdk';
import Decimal from 'decimal.js';
import type { Address } from '@solana/kit';

const ALLOWED_ROUTERS: RouterType[] = ['metis', 'titan', 'dflow', 'openOcean', 'jupiterLite'];

export function getKswapQuoter(
  kswapSdk: KswapSdk,
  executor: Address,
  slippageBps: number,
  inputMintReserve: KaminoReserve,
  outputMintReserve: KaminoReserve
): SwapQuoteProvider<RouteOutput> {
  const quoter: SwapQuoteProvider<RouteOutput> = async (inputs: SwapInputs): Promise<SwapQuote<RouteOutput>> => {
    const inMintInfo: MintInfo = {
      tokenProgramId: inputMintReserve.getLiquidityTokenProgram(),
      decimals: inputMintReserve.stats.decimals,
    };
    const outMintInfo: MintInfo = {
      tokenProgramId: outputMintReserve.getLiquidityTokenProgram(),
      decimals: outputMintReserve.stats.decimals,
    };
    const routerContext = new RouterContext(inMintInfo, outMintInfo);

    const routeParams: RouteParams = {
      executor: executor,
      tokenIn: inputs.inputMint,
      tokenOut: inputs.outputMint,
      amount: new BN(inputs.inputAmountLamports.toDP(0).toString()),
      maxSlippageBps: slippageBps,
      wrapAndUnwrapSol: false,
      swapType: 'exactIn',
      routerTypes: ALLOWED_ROUTERS,
      includeRfq: false,
      includeLimoLogs: false,
      withSimulation: true,
      filterFailedSimulations: false,
      timeoutMs: 30000,
      atLeastOneNoMoreThanTimeoutMS: 10000,
      preferredMaxAccounts: 10,
    };

    const routeOutputs = await kswapSdk.getAllRoutes(routeParams, routerContext);

    if (routeOutputs.routes.length === 0) {
      throw new Error('No routes found from KSwap. Try increasing preferredMaxAccount.');
    }

    const bestRoute = routeOutputs.routes.reduce((best, current) => {
      const inAmountBest = new Decimal(best.amountsExactIn.amountIn.toString()).div(inputMintReserve.getMintFactor());
      const minAmountOutBest = new Decimal(best.amountsExactIn.amountOutGuaranteed.toString()).div(
        outputMintReserve.getMintFactor()
      );
      const priceAInBBest = minAmountOutBest.div(inAmountBest);
      const inAmountCurrent = new Decimal(current.amountsExactIn.amountIn.toString()).div(
        inputMintReserve.getMintFactor()
      );
      const minAmountOutCurrent = new Decimal(current.amountsExactIn.amountOutGuaranteed.toString()).div(
        outputMintReserve.getMintFactor()
      );
      const priceAInBCurrent = minAmountOutCurrent.div(inAmountCurrent);
      return priceAInBBest.greaterThan(priceAInBCurrent) ? best : current;
    });

    const inAmountBest = new Decimal(bestRoute.amountsExactIn.amountIn.toString()).div(
      inputMintReserve.getMintFactor()
    );
    const minAmountOutBest = new Decimal(bestRoute.amountsExactIn.amountOutGuaranteed.toString()).div(
      outputMintReserve.getMintFactor()
    );
    const priceAInBBest = minAmountOutBest.div(inAmountBest);

    return {
      priceAInB: priceAInBBest,
      quoteResponse: bestRoute,
    };
  };

  return quoter;
}

export function getKswapSwapper(
  kswapSdk: KswapSdk,
  executor: Address,
  slippageBps: number,
  inputMintReserve: KaminoReserve,
  outputMintReserve: KaminoReserve
): SwapIxsProvider<RouteOutput> {
  const swapper: SwapIxsProvider<RouteOutput> = async (inputs: SwapInputs): Promise<Array<SwapIxs<RouteOutput>>> => {
    const inMintInfo: MintInfo = {
      tokenProgramId: inputMintReserve.getLiquidityTokenProgram(),
      decimals: inputMintReserve.stats.decimals,
    };
    const outMintInfo: MintInfo = {
      tokenProgramId: outputMintReserve.getLiquidityTokenProgram(),
      decimals: outputMintReserve.stats.decimals,
    };
    const routerContext = new RouterContext(inMintInfo, outMintInfo);

    const routeParams: RouteParams = {
      executor: executor,
      tokenIn: inputs.inputMint,
      tokenOut: inputs.outputMint,
      amount: new BN(inputs.inputAmountLamports.toString()),
      maxSlippageBps: slippageBps,
      wrapAndUnwrapSol: false,
      swapType: 'exactIn',
      routerTypes: ALLOWED_ROUTERS,
      includeRfq: false,
      includeLimoLogs: false,
      withSimulation: true,
      filterFailedSimulations: false,
      timeoutMs: 30000,
      atLeastOneNoMoreThanTimeoutMS: 10000,
      preferredMaxAccounts: 10,
    };

    const routeOutputs = await kswapSdk.getAllRoutes(routeParams, routerContext);

    if (routeOutputs.routes.length === 0) {
      throw new Error('No routes found from KSwap in swapper.');
    }

    return routeOutputs.routes.map((routeOutput) => {
      const inAmount = new Decimal(routeOutput.amountsExactIn.amountIn.toString()).div(
        routeOutput.inputTokenDecimals! || inputMintReserve.getMintFactor()
      );
      const minAmountOut = new Decimal(routeOutput.amountsExactIn.amountOutGuaranteed.toString()).div(
        routeOutput.outputTokenDecimals! || outputMintReserve.getMintFactor()
      );
      const priceAInB = minAmountOut.div(inAmount);

      const swapIxs = routeOutput.instructions?.swapIxs || [];

      return {
        preActionIxs: [],
        swapIxs: swapIxs,
        lookupTables: routeOutput.lookupTableAccounts || [],
        quote: {
          priceAInB: priceAInB,
          quoteResponse: routeOutput,
          simulationResult: routeOutput.simulationResult,
          routerType: routeOutput.routerType,
        },
      };
    });
  };

  return swapper;
}

Transaction Flow

Asset Flow

1

Initial State

  • Collateral: 0.1 SOL ($10 value)
  • Debt: 5 CASH
2

Flash Borrow

  • Flash loan: 3 CASH (temporary)
  • Available: 3 CASH to repay
3

Repay & Withdraw

  • Repay 3 CASH debt
  • Debt reduced from 5 CASH to 2 CASH
  • Withdraw freed 0.03 SOL collateral
4

Swap & Repay Flash Loan

  • Swap 0.03 SOL to ~3 CASH via KSwap
  • Repay 3 CASH flash loan
5

Final Position

  • Collateral: 0.07 SOL ($7 value)
  • Debt: 2 CASH
  • Net Result: Reduced leverage without additional capital

Key Concepts

Flash loans are uncollateralized loans that must be borrowed and repaid within the same transaction. They enable:
  • Zero upfront capital: Reduce debt without providing additional funds
  • Atomic execution: All operations succeed or fail together
  • Debt reduction: Convert collateral to debt token in one transaction
  • Risk-free: Transaction reverts if flash loan cannot be repaid
The VanillaObligation is a standard obligation type that:
  • Standard positions: General-purpose collateral and debt tracking
  • Required for repay: Must exist before performing repay with collateral
  • Retrieved by wallet: Automatically finds your obligation address
Address Lookup Tables reduce transaction size by:
  • Storing common addresses: Frequently-used program accounts, reserves, oracles
  • Index-based references: 1-byte index instead of 32-byte address
  • Transaction compression: Enables complex transactions to fit within the 1232-byte limit
  • Repay-specific LUTs: Fetched from Kamino CDN per collateral/debt pair
  • Critical for complex txns: Repay operations wouldn’t fit without LUTs
KSwap provides optimal swap execution through:
  • Route simulation: Tests each route for success server-side
  • Transaction size selection: Prioritizes routes that fit within transaction limits
  • Manual filtering: Provides simulation data for custom route selection logic
  • Slippage protection: Guarantees minimum output amount
Solana commitment levels affect confirmation speed:
  • processed: Fastest, used for sending (no false timeout errors)
  • confirmed: Medium speed, confirmed by majority of cluster stake
  • finalized: Slowest, used for blockhash (maximum security, ~32 blocks)
  • Recommended: Use processed for repay transactions to avoid false errors