Scanning for Unhealthy Positions
The first step is loading every obligation in a market and checking which ones have breached their health limit.- TypeScript
- Rust
1
Import Dependencies
import {
createSolanaRpc,
createSolanaRpcSubscriptions,
createKeyPairSignerFromBytes,
sendAndConfirmTransactionFactory,
address,
} from '@solana/kit';
import {
KaminoMarket,
KaminoObligation,
DEFAULT_RECENT_SLOT_DURATION_MS,
} from '@kamino-finance/klend-sdk';
2
Set Up RPC and Load Market
const RPC_ENDPOINT = 'YOUR_RPC_URL';
const WS_ENDPOINT = RPC_ENDPOINT.replace('https://', 'wss://');
const SOL_BTC_MARKET = '7u3HeHxYDLhnCoErrtycNokbQYbWGzLs6JSDqGAv5PfF';
const rpc = createSolanaRpc(RPC_ENDPOINT);
const rpcSubscriptions = createSolanaRpcSubscriptions(WS_ENDPOINT);
const sendAndConfirm = sendAndConfirmTransactionFactory({ rpc, rpcSubscriptions });
const keypairBytes = new Uint8Array(JSON.parse(process.env.LENDING_USER_SECRET_KEY!));
const liquidator = await createKeyPairSignerFromBytes(keypairBytes);
const market = await KaminoMarket.load(
rpc, address(SOL_BTC_MARKET), DEFAULT_RECENT_SLOT_DURATION_MS,
);
Kamino operates multiple markets (SOL/BTC, JLP, Altcoins, Prime). A production bot should scan all of them. See Markets for the full list.
3
Scan Obligations in Batches
Scan obligations in batches of 100 to avoid out-of-memory on large markets. Use the on-chain LTV methods, notrefreshedStats.loanToValue, to match the program’s liquidation check.// Scanning 100k+ obligations in batches takes several minutes.
const currentSlot = await rpc.getSlot().send();
const unhealthy: KaminoObligation[] = [];
let totalScanned = 0;
for await (const batch of market!.batchGetAllObligationsForMarket(currentSlot)) {
totalScanned += batch.length;
batch.forEach((ob) => {
const ltv = ob.loanToValue();
const liqLtv = ob.liquidationLtv();
if (ltv.gte(liqLtv) && ob.refreshedStats.userTotalBorrow.gt(0)) {
unhealthy.push(ob);
}
});
}
console.log(`Found ${unhealthy.length} liquidatable out of ${totalScanned} total`);
1
Add Dependencies
[dependencies]
klend-interface = { version = "0.6.0", features = ["solana-account"] }
solana-pubkey = "2.1"
solana-instruction = "2.1"
solana-sdk = "~2.3"
solana-client = "~2.3"
solana-account = "2.1"
solana-account-decoder-client-types = "~2.3"
spl-token = "7"
spl-associated-token-account = "6"
reqwest = { version = "~0.12", features = ["blocking", "json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
bincode = "1"
base64 = "0.22"
klend-interface is a lightweight Rust instruction builder that creates Vec<Instruction> with required refresh instructions prepended automatically.2
Set Up RPC Client
use solana_client::rpc_client::RpcClient;
use solana_pubkey::Pubkey;
use std::str::FromStr;
let rpc_client = RpcClient::new("YOUR_RPC_URL");
let market = Pubkey::from_str("7u3HeHxYDLhnCoErrtycNokbQYbWGzLs6JSDqGAv5PfF")?;
Scanning obligations requires
getProgramAccounts, which returns thousands of accounts per market. The public Solana RPC will reject these calls. Use a private RPC provider (Helius, Triton, QuickNode, etc.).3
Fetch All Obligations
Fetch every obligation in the market usingget_program_accounts with filters for the discriminator, lending market, and account size.use klend_interface::{
state::{Obligation, SplDiscriminate},
KLEND_PROGRAM_ID,
};
use solana_account::ReadableAccount;
use solana_account_decoder_client_types::UiAccountEncoding;
use solana_client::{
rpc_config::{RpcAccountInfoConfig, RpcProgramAccountsConfig},
rpc_filter::{Memcmp, RpcFilterType},
};
const OBLIGATION_ACCOUNT_SIZE: u64 = 8 + std::mem::size_of::<Obligation>() as u64;
let filters = vec![
RpcFilterType::Memcmp(Memcmp::new_raw_bytes(
0,
Obligation::SPL_DISCRIMINATOR_SLICE.to_vec(),
)),
RpcFilterType::Memcmp(Memcmp::new_raw_bytes(32, market.to_bytes().to_vec())),
RpcFilterType::DataSize(OBLIGATION_ACCOUNT_SIZE),
];
let config = RpcProgramAccountsConfig {
filters: Some(filters),
account_config: RpcAccountInfoConfig {
encoding: Some(UiAccountEncoding::Base64Zstd),
..Default::default()
},
..Default::default()
};
let accounts = rpc_client.get_program_accounts_with_config(&KLEND_PROGRAM_ID, config)?;
Obligation accounts exceed the 128-byte limit for base58 responses. Use
Base64Zstd encoding or the RPC will silently return zero results.4
Filter to Liquidatable Positions
Parse each obligation and checkis_liquidatable(), which returns true when borrow-factor-adjusted debt exceeds the unhealthy borrow value.use klend_interface::Fraction;
let mut liquidatable = Vec::new();
for (pubkey, account) in &accounts {
let obligation = klend_interface::from_account_data::<Obligation>(account.data())?;
if obligation.has_debt == 0 {
continue;
}
if obligation.is_liquidatable() {
let debt: f64 = Fraction::from_bits(
obligation.borrowed_assets_market_value()
).to_num();
println!("Liquidatable: {pubkey} — debt: ${debt:.2}");
liquidatable.push((*pubkey, obligation));
}
}
println!("Initial candidates (stale prices): {}", liquidatable.len());
5
Verify with Fresh Prices
On-chain obligation values are only updated whenRefreshObligation is called. Fetch fresh oracle prices from the Scope REST API and re-verify each candidate to avoid wasting transaction fees on false positives.use klend_interface::state::{Reserve, from_account_data};
use serde::Deserialize;
use std::collections::{HashMap, HashSet};
#[derive(Deserialize)]
struct OraclePrice {
mint: String,
price: String,
}
// Collect all reserve pubkeys from candidates
let reserve_pubkeys: Vec<Pubkey> = liquidatable
.iter()
.flat_map(|(_, obl)| {
obl.deposits.iter().map(|d| d.deposit_reserve)
.chain(obl.borrows.iter().map(|b| b.borrow_reserve))
.filter(|pk| *pk != Pubkey::default())
})
.collect::<HashSet<_>>()
.into_iter()
.collect();
// Fetch reserves to map mints to prices and get borrow factors
let reserve_accounts = rpc_client.get_multiple_accounts(&reserve_pubkeys)?;
let reserve_map: HashMap<Pubkey, Reserve> = reserve_pubkeys
.iter()
.zip(reserve_accounts.iter())
.filter_map(|(pk, acc)| {
acc.as_ref().and_then(|a| {
from_account_data::<Reserve>(&a.data).ok().map(|r| (*pk, *r))
})
})
.collect();
// Fetch fresh prices from the Scope REST API
let fresh_prices: HashMap<String, f64> = reqwest::blocking::get(
"https://api.kamino.finance/oracles/prices",
)
.ok()
.and_then(|r| r.json::<Vec<OraclePrice>>().ok())
.map(|prices| prices.into_iter()
.filter_map(|p| p.price.parse::<f64>().ok().map(|v| (p.mint, v)))
.collect())
.unwrap_or_default();
let mut verified = Vec::new();
for (pubkey, obligation) in &liquidatable {
// Re-price deposits
let mut fresh_deposited = 0.0f64;
let mut fresh_unhealthy = 0.0f64;
for deposit in &obligation.deposits {
if deposit.deposit_reserve == Pubkey::default() { continue; }
let reserve = match reserve_map.get(&deposit.deposit_reserve) {
Some(r) => r,
None => continue,
};
let mint = reserve.liquidity.mint_pubkey;
let stale_price: f64 = Fraction::from_bits(
u128::from(reserve.liquidity.market_price_sf)
).to_num();
let price = fresh_prices.get(&mint.to_string()).copied().unwrap_or(stale_price);
let amount: f64 = Fraction::from_bits(deposit.deposited_amount.into()).to_num();
let value = amount / 10f64.powi(reserve.liquidity.mint_decimals as i32) * price;
fresh_deposited += value;
fresh_unhealthy += value * reserve.liquidation_threshold_pct() as f64 / 100.0;
}
// Re-price borrows
let mut fresh_adjusted_debt = 0.0f64;
for borrow in &obligation.borrows {
if borrow.borrow_reserve == Pubkey::default() { continue; }
let reserve = match reserve_map.get(&borrow.borrow_reserve) {
Some(r) => r,
None => continue,
};
let mint = reserve.liquidity.mint_pubkey;
let stale_price: f64 = Fraction::from_bits(
u128::from(reserve.liquidity.market_price_sf)
).to_num();
let price = fresh_prices.get(&mint.to_string()).copied().unwrap_or(stale_price);
let amount: f64 = Fraction::from_bits(borrow.borrowed_amount()).to_num();
let value = amount / 10f64.powi(reserve.liquidity.mint_decimals as i32) * price;
fresh_adjusted_debt += value * reserve.borrow_factor_pct() as f64 / 100.0;
}
// Still liquidatable at fresh prices?
if fresh_adjusted_debt > fresh_unhealthy {
verified.push((*pubkey, *obligation));
}
}
println!("Verified {} out of {} candidates with fresh prices", verified.len(), liquidatable.len());
Picking the Most Profitable Pair
An obligation can have multiple collateral deposits and multiple borrows. The liquidator must choose one debt token to repay and one collateral token to seize. The program enforces priority rules: target the lowest-threshold collateral and the highest-borrow-factor debt first.- TypeScript
- Rust
1
Load the Obligation
import { KaminoReserve } from '@kamino-finance/klend-sdk';
import { Address } from '@solana/kit';
const obligation = (await market!.getObligationByAddress(
address('<OBLIGATION_PUBKEY>')
))!;
const resolveReserve = (market: KaminoMarket, reserveAddr: Address) =>
market.getReserveByAddress(reserveAddr) as KaminoReserve | undefined;
2
Sort by Priority
Sort deposits byliquidationThresholdPct ascending (weakest collateral first) and borrows by borrowFactorPct descending (riskiest debt first).const deposits = obligation.getDeposits();
const borrows = obligation.getBorrows();
const sortedDeposits = deposits.sort((a, b) => {
const reserveA = resolveReserve(market!, a.reserveAddress)!;
const reserveB = resolveReserve(market!, b.reserveAddress)!;
return reserveA.state.config.liquidationThresholdPct
- reserveB.state.config.liquidationThresholdPct;
});
const sortedBorrows = borrows.sort((a, b) => {
const reserveA = resolveReserve(market!, a.reserveAddress)!;
const reserveB = resolveReserve(market!, b.reserveAddress)!;
return Number(reserveB.state.config.borrowFactorPct)
- Number(reserveA.state.config.borrowFactorPct);
});
3
Select the Target Pair
The first entry in each sorted list is the priority target. Reserves withloanToValuePct == 0 are deposit-only and cannot be seized.const targetCollateral = sortedDeposits.find((d) => {
const reserve = resolveReserve(market!, d.reserveAddress);
return reserve && reserve.state.config.loanToValuePct > 0;
});
const targetDebt = sortedBorrows[0];
if (!targetCollateral || !targetDebt) {
console.log('No valid liquidation pair found');
}
1
Fetch Reserve Data
Collect all unique reserve pubkeys from the obligation’s deposits and borrows, then fetch and parse them in one RPC call.use klend_interface::state::{Reserve, from_account_data};
use std::collections::HashSet;
let active_deposits: Vec<_> = obligation
.deposits
.iter()
.filter(|d| d.deposit_reserve != Pubkey::default())
.collect();
let active_borrows: Vec<_> = obligation
.borrows
.iter()
.filter(|b| b.borrow_reserve != Pubkey::default())
.collect();
let reserve_pubkeys: Vec<Pubkey> = active_deposits
.iter()
.map(|d| d.deposit_reserve)
.chain(active_borrows.iter().map(|b| b.borrow_reserve))
.collect::<HashSet<_>>()
.into_iter()
.collect();
let reserve_accounts = rpc_client.get_multiple_accounts(&reserve_pubkeys)?;
let parsed_reserves: Vec<(Pubkey, Reserve)> = reserve_pubkeys
.iter()
.zip(reserve_accounts.iter())
.filter_map(|(pk, acc_opt)| {
let acc = acc_opt.as_ref()?;
let reserve = from_account_data::<Reserve>(&acc.data).ok()?;
Some((*pk, *reserve))
})
.collect();
2
Sort by Priority
Sort deposits byliquidation_threshold_pct ascending (weakest collateral first) and borrows by borrow_factor_pct descending (riskiest debt first).let mut deposit_pairs: Vec<(Pubkey, &Reserve)> = active_deposits
.iter()
.filter_map(|d| {
parsed_reserves
.iter()
.find(|(pk, _)| *pk == d.deposit_reserve)
.map(|(pk, r)| (*pk, r))
})
.collect();
deposit_pairs.sort_by_key(|(_, r)| r.liquidation_threshold_pct());
let mut borrow_pairs: Vec<(Pubkey, &Reserve)> = active_borrows
.iter()
.filter_map(|b| {
parsed_reserves
.iter()
.find(|(pk, _)| *pk == b.borrow_reserve)
.map(|(pk, r)| (*pk, r))
})
.collect();
borrow_pairs.sort_by(|(_, a), (_, b)| b.borrow_factor_pct().cmp(&a.borrow_factor_pct()));
3
Select the Target Pair
The first entry in each sorted list is the priority target. Collateral reserves withloan_to_value_pct == 0 are deposit-only and cannot be seized.let withdraw_pair = deposit_pairs
.iter()
.find(|(_, r)| r.loan_to_value_pct() > 0);
let repay_pair = borrow_pairs.first();
match (withdraw_pair, repay_pair) {
(Some((coll_pk, coll_reserve)), Some((debt_pk, debt_reserve))) => {
println!("Seize collateral: {coll_pk} (mint: {})", coll_reserve.liquidity.mint_pubkey);
println!("Repay debt: {debt_pk} (mint: {})", debt_reserve.liquidity.mint_pubkey);
}
_ => println!("No valid liquidation pair found"),
}
Executing the Liquidation
Once an unhealthy obligation and target pair have been identified, build and send the liquidation transaction.- TypeScript
- Rust
1
Import Transaction Utilities
import {
pipe,
createTransactionMessage,
setTransactionMessageFeePayerSigner,
setTransactionMessageLifetimeUsingBlockhash,
appendTransactionMessageInstructions,
signTransactionMessageWithSigners,
getSignatureFromTransaction,
getTransactionDecoder,
getCompiledTransactionMessageDecoder,
decompileTransactionMessageFetchingLookupTables,
addSignersToTransactionMessage,
} from '@solana/kit';
import { KaminoAction } from '@kamino-finance/klend-sdk';
import BN from 'bn.js';
import Decimal from 'decimal.js';
2
Calculate Repay Amount
Calculate the maximum repayable amount from the obligation’s debt position and the market’s close factor.const closeFactorPct = market!.state.liquidationMaxDebtCloseFactorPct;
const debtReserve = resolveReserve(market!, targetDebt!.reserveAddress)!;
const debtMintFactor = debtReserve.getMintFactor();
const maxRepayLamports = targetDebt!.amount
.mul(new Decimal(closeFactorPct))
.div(new Decimal(100))
.floor();
const repayAmount = new BN(maxRepayLamports.toFixed(0));
3
Swap into Debt Token via KSwap
If the liquidator does not hold the debt token, swap into it using the KSwap REST API. The swap amount is converted from debt USD value to SOL lamports.const SOL_MINT = 'So11111111111111111111111111111111111111112';
const KSWAP_API = 'https://api.kamino.finance';
const GAS_RESERVE_LAMPORTS = 50_000_000; // 0.05 SOL reserved for tx fees
const debtPrice = debtReserve.getOracleMarketPrice();
const repayUsd = new Decimal(repayAmount.toString()).mul(debtPrice).div(debtMintFactor);
// Fetch SOL price from oracle API (price is returned as a string)
const solPriceRes = await fetch(`${KSWAP_API}/oracles/prices?mints=${SOL_MINT}`);
if (!solPriceRes.ok) {
throw new Error(`Oracle API error ${solPriceRes.status}: ${await solPriceRes.text()}`);
}
const solPriceData = await solPriceRes.json();
const solPrice = parseFloat(solPriceData[0]?.price);
if (!Number.isFinite(solPrice) || solPrice <= 0) {
console.log('Invalid SOL price from oracle API, skipping swap');
}
// Convert debt USD to SOL lamports, cap by wallet balance minus gas reserve
const solLamports = Math.ceil(repayUsd.div(solPrice).mul(1e9).toNumber());
const walletBalance = await rpc.getBalance(liquidator.address).send();
const availableLamports = Number(walletBalance.value) - GAS_RESERVE_LAMPORTS;
const amountIn = Math.min(solLamports, Math.max(0, availableLamports));
const swapParams = new URLSearchParams({
tokenIn: SOL_MINT,
tokenOut: targetDebt!.mintAddress.toString(),
amountIn: amountIn.toString(),
maxSlippageBps: '50',
wallet: liquidator.address,
wrapAndUnwrapSol: 'true',
});
const swapRes = await fetch(`${KSWAP_API}/kswap/swap/?${swapParams}`);
if (!swapRes.ok) {
const body = await swapRes.text();
console.log(`KSwap API error ${swapRes.status}: ${body}`);
}
const swapData = swapRes.ok ? (await swapRes.json()).data : undefined;
if (swapData?.transaction) {
const swapTxBuffer = Buffer.from(swapData.transaction, 'base64');
const swapMsgBytes = getTransactionDecoder().decode(swapTxBuffer).messageBytes;
const swapCompiled = getCompiledTransactionMessageDecoder().decode(swapMsgBytes);
const { value: swapBlockhash } = await rpc
.getLatestBlockhash({ commitment: 'finalized' }).send();
const signedSwap = await pipe(
await decompileTransactionMessageFetchingLookupTables(swapCompiled, rpc),
(tx) => setTransactionMessageLifetimeUsingBlockhash(swapBlockhash, tx),
(tx) => setTransactionMessageFeePayerSigner(liquidator, tx),
(tx) => addSignersToTransactionMessage([liquidator], tx),
(tx) => signTransactionMessageWithSigners(tx),
);
await sendAndConfirm(signedSwap, { commitment: 'confirmed', skipPreflight: true });
console.log('Swapped into debt token:', getSignatureFromTransaction(signedSwap));
}
KSwap is also available as the
@kamino-finance/kswap-sdk TypeScript package for building swap instructions on the client.4
Calculate Slippage Protection
Calculate the minimum collateral to receive based on the repay value, expected liquidation bonus, protocol fee, collateral price, and a 2% haircut.const MIN_RECEIVED_SLIPPAGE_BPS = 200; // 2%
const collateralReserve = resolveReserve(market!, targetCollateral!.reserveAddress)!;
const collateralPrice = collateralReserve.getOracleMarketPrice();
const collateralMintFactor = collateralReserve.getMintFactor();
const bonusBps = collateralReserve.state.config.maxLiquidationBonusBps;
const protocolFeePct = collateralReserve.state.config.protocolLiquidationFeePct;
const bonusMultiplier = new Decimal(1).plus(new Decimal(bonusBps).div(10_000));
const afterProtocolFee = new Decimal(1).minus(new Decimal(protocolFeePct).div(100));
const slippageMultiplier = new Decimal(1).minus(new Decimal(MIN_RECEIVED_SLIPPAGE_BPS).div(10_000));
const expectedCollateral = repayUsd
.mul(bonusMultiplier)
.mul(afterProtocolFee)
.mul(slippageMultiplier)
.div(collateralPrice)
.mul(collateralMintFactor)
.floor();
const minCollateral = new BN(expectedCollateral.toFixed(0));
5
Build the Liquidation Transaction
buildLiquidateTxns constructs the full transaction including reserve refreshes, obligation refresh, ATA creation, compute budget, and the liquidation instruction.const action = await KaminoAction.buildLiquidateTxns({
kaminoMarket: market!,
amount: repayAmount,
minCollateralReceiveAmount: minCollateral,
repayReserveAddress: targetDebt!.reserveAddress,
withdrawReserveAddress: targetCollateral!.reserveAddress,
liquidator,
obligationOwner: obligation.state.owner,
obligation,
useV2Ixs: true,
extraComputeBudget: 1_400_000,
includeAtaIxs: true,
requestElevationGroup: false,
initUserMetadata: { skipInitialization: true, skipLutCreation: true },
maxAllowedLtvOverridePercent: 0,
currentSlot,
});
6
Assemble Instructions
const instructions = [
...action.computeBudgetIxs,
...action.setupIxs,
...action.lendingIxs,
...action.cleanupIxs,
];
7
Sign and Send
const { value: blockhash } = await rpc
.getLatestBlockhash({ commitment: 'finalized' })
.send();
const signed = await pipe(
createTransactionMessage({ version: 0 }),
(tx) => setTransactionMessageFeePayerSigner(liquidator, tx),
(tx) => setTransactionMessageLifetimeUsingBlockhash(blockhash, tx),
(tx) => appendTransactionMessageInstructions(instructions, tx),
(tx) => signTransactionMessageWithSigners(tx),
);
await sendAndConfirm(signed, { commitment: 'confirmed', skipPreflight: true });
console.log('Liquidation executed:', getSignatureFromTransaction(signed));
1
Fetch Obligation and Reserves
UseObligationContext to discover all reserves referenced by the obligation, then fetch them in one RPC call.use klend_interface::{
helpers::refresh,
instructions::liquidate::{
liquidate_obligation_and_redeem_reserve_collateral_v2,
LiquidateObligationAndRedeemReserveCollateralV2Accounts,
},
pda,
state::{LendingMarket, Obligation, Reserve, from_account_data},
Fraction, ObligationContext, ObligationInfo, ReserveInfo, KLEND_PROGRAM_ID,
};
use solana_instruction::AccountMeta;
use solana_sdk::signer::{keypair::read_keypair_file, Signer};
let signer = read_keypair_file("/path/to/liquidator-keypair.json")
.expect("Failed to read keypair file");
let owner = signer.pubkey();
let obligation_account = rpc_client.get_account(&obligation_pubkey)?;
let obligation = from_account_data::<Obligation>(&obligation_account.data)?;
let reserve_addrs = ObligationContext::reserve_addresses_for_obligation(
&obligation_account.data,
)?;
let reserve_accounts = rpc_client.get_multiple_accounts(&reserve_addrs)?;
let parsed_reserves: Vec<(Pubkey, Reserve)> = reserve_addrs
.iter()
.zip(reserve_accounts.iter())
.filter_map(|(pk, acc)| {
acc.as_ref().and_then(|a| {
from_account_data::<Reserve>(&a.data).ok().map(|r| (*pk, *r))
})
})
.collect();
let repay_reserve = parsed_reserves.iter()
.find(|(pk, _)| *pk == repay_reserve_pubkey)
.map(|(_, r)| r)
.expect("Repay reserve not found");
let withdraw_reserve = parsed_reserves.iter()
.find(|(pk, _)| *pk == withdraw_reserve_pubkey)
.map(|(_, r)| r)
.expect("Withdraw reserve not found");
2
Calculate Repay Amount
Fetch theLendingMarket account to read the close factor, then calculate the maximum repayable amount from the borrow position.let borrow_position = obligation.borrows.iter()
.find(|b| b.borrow_reserve == repay_reserve_pubkey)
.expect("Borrow position not found");
let borrowed_amount: u64 = Fraction::from_bits(
borrow_position.borrowed_amount()
).to_num();
let market_account = rpc_client.get_account(&obligation.lending_market)?;
let lending_market = from_account_data::<LendingMarket>(&market_account.data)?;
let close_factor = lending_market.liquidation_max_debt_close_factor_pct;
let repay_amount = (borrowed_amount as f64 * close_factor as f64 / 100.0) as u64;
3
Swap into Debt Token via KSwap
If the liquidator does not hold the debt token, swap into it using the KSwap REST API before liquidating.use serde::Deserialize;
use solana_sdk::transaction::VersionedTransaction;
#[derive(Deserialize)]
struct KswapResponse { data: KswapData }
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct KswapData {
transaction: String,
expected_amount_out: String,
min_amount_out: String,
}
let liquidator_debt_ata =
spl_associated_token_account::get_associated_token_address_with_program_id(
&owner,
&repay_reserve.liquidity.mint_pubkey,
&repay_reserve.liquidity.token_program,
);
use spl_token::state::Account as TokenAccount;
use solana_sdk::program_pack::Pack;
let wallet_balance = rpc_client.get_account(&liquidator_debt_ata)
.ok()
.and_then(|a| TokenAccount::unpack(&a.data).ok())
.map(|acct| acct.amount)
.unwrap_or(0);
if wallet_balance < repay_amount {
let wsol = Pubkey::from_str("So11111111111111111111111111111111111111112")?;
// Calculate how much SOL to swap based on debt USD value
let debt_price: f64 = Fraction::from_bits(
u128::from(repay_reserve.liquidity.market_price_sf)
).to_num();
let repay_usd = repay_amount as f64
/ 10f64.powi(repay_reserve.liquidity.mint_decimals as i32) * debt_price;
let sol_price = parsed_reserves.iter()
.find(|(_, r)| r.liquidity.mint_pubkey == wsol)
.map(|(_, r)| Fraction::from_bits(
u128::from(r.liquidity.market_price_sf)
).to_num::<f64>())
.unwrap_or(100.0);
let sol_balance = rpc_client.get_balance(&owner)?;
let swap_amount = (repay_usd / sol_price * 1e9) as u64;
let swap_amount = swap_amount.min(sol_balance.saturating_sub(5_000_000));
let client = reqwest::blocking::Client::new();
let resp = client
.get("https://api.kamino.finance/kswap/swap/")
.query(&[
("tokenIn", wsol.to_string()),
("tokenOut", repay_reserve.liquidity.mint_pubkey.to_string()),
("amountIn", swap_amount.to_string()),
("maxSlippageBps", "50".to_string()),
("wallet", owner.to_string()),
("wrapAndUnwrapSol", "true".to_string()),
])
.send()?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().unwrap_or_default();
return Err(format!("KSwap API error {status}: {body}").into());
}
let kswap: KswapResponse = resp.json()?;
// Decode, re-sign, and send the swap transaction
use base64::Engine;
let tx_bytes = base64::engine::general_purpose::STANDARD
.decode(&kswap.data.transaction)?;
let swap_tx: VersionedTransaction = bincode::deserialize(&tx_bytes)?;
let blockhash = rpc_client.get_latest_blockhash()?;
let mut msg = swap_tx.message;
msg.set_recent_blockhash(blockhash);
let signed = VersionedTransaction::try_new(msg, &[&signer])?;
rpc_client.send_and_confirm_transaction_with_spinner(&signed)?;
}
4
Derive Token Accounts
Derive ATAs using the actual token programs from the reserve data. The collateral cToken mint address is read directly from theReserve struct.let user_source_liquidity =
spl_associated_token_account::get_associated_token_address_with_program_id(
&owner,
&repay_reserve.liquidity.mint_pubkey,
&repay_reserve.liquidity.token_program,
);
let ctoken_mint = withdraw_reserve.collateral.mint_pubkey;
let ctoken_program = rpc_client.get_account(&ctoken_mint)
.map(|a| a.owner)
.unwrap_or(spl_token::ID);
let user_destination_collateral =
spl_associated_token_account::get_associated_token_address_with_program_id(
&owner, &ctoken_mint, &ctoken_program,
);
let user_destination_liquidity =
spl_associated_token_account::get_associated_token_address_with_program_id(
&owner,
&withdraw_reserve.liquidity.mint_pubkey,
&withdraw_reserve.liquidity.token_program,
);
5
Create Token Accounts
Create the associated token accounts if they do not already exist. Usescreate_associated_token_account_idempotent which is a no-op if the account is already initialized.let ata_ixs = vec![
spl_associated_token_account::instruction::create_associated_token_account_idempotent(
&owner, &owner, &repay_reserve.liquidity.mint_pubkey, &repay_reserve.liquidity.token_program,
),
spl_associated_token_account::instruction::create_associated_token_account_idempotent(
&owner, &owner, &ctoken_mint, &ctoken_program,
),
spl_associated_token_account::instruction::create_associated_token_account_idempotent(
&owner, &owner, &withdraw_reserve.liquidity.mint_pubkey, &withdraw_reserve.liquidity.token_program,
),
];
let blockhash = rpc_client.get_latest_blockhash()?;
let ata_tx = Transaction::new(&[&signer], Message::new(&ata_ixs, Some(&owner)), blockhash);
rpc_client.send_and_confirm_transaction(&ata_tx)?;
6
Build Liquidation Instructions
Build refresh instructions for each reserve and the obligation, then the liquidation instruction using actual addresses from theReserve struct.use solana_sdk::transaction::Transaction;
use solana_sdk::message::Message;
let (lma, _) = pda::lending_market_authority(
&KLEND_PROGRAM_ID, &repay_reserve.lending_market,
);
let obligation_info = ObligationInfo::from_obligation(
obligation_pubkey, obligation,
);
let all_reserve_infos: Vec<ReserveInfo> = parsed_reserves.iter()
.map(|(pk, r)| ReserveInfo::from_reserve(*pk, r))
.collect();
// Refresh reserves and obligation
let mut instructions = Vec::new();
for info in &all_reserve_infos {
if obligation_info.deposit_reserves.contains(&info.address)
|| obligation_info.borrow_reserves.contains(&info.address)
{
instructions.push(refresh::refresh_reserve(info));
}
}
instructions.push(refresh::refresh_obligation(
&repay_reserve.lending_market, &obligation_info,
));
// Remaining accounts: deposit reserves + borrow reserves
let mut remaining: Vec<AccountMeta> = Vec::new();
for r in &obligation_info.deposit_reserves {
remaining.push(AccountMeta::new(*r, false));
}
for r in &obligation_info.borrow_reserves {
remaining.push(AccountMeta::new(*r, false));
}
// Calculate slippage protection to guard against sandwich attacks.
// Without this, a malicious validator could reorder transactions
// to extract the full liquidation bonus.
const MIN_RECEIVED_SLIPPAGE_BPS: u16 = 200; // 2%
let collateral_price: f64 = Fraction::from_bits(
u128::from(withdraw_reserve.liquidity.market_price_sf)
).to_num();
let collateral_decimals = withdraw_reserve.liquidity.mint_decimals;
let bonus_bps = withdraw_reserve.config.min_liquidation_bonus_bps as f64;
let protocol_fee_pct = withdraw_reserve.config.protocol_liquidation_fee_pct as f64;
let debt_price: f64 = Fraction::from_bits(
u128::from(repay_reserve.liquidity.market_price_sf)
).to_num();
let repay_usd = repay_amount as f64
/ 10f64.powi(repay_reserve.liquidity.mint_decimals as i32) * debt_price;
let min_received = if collateral_price > 0.0 {
let collateral_value = repay_usd * (1.0 + bonus_bps / 10_000.0);
let after_fee = collateral_value * (1.0 - protocol_fee_pct / 100.0);
let expected_tokens = after_fee / collateral_price
* 10f64.powi(collateral_decimals as i32);
let with_slippage = expected_tokens
* (10_000.0 - MIN_RECEIVED_SLIPPAGE_BPS as f64) / 10_000.0;
(with_slippage as u64).max(1)
} else {
1
};
// Liquidation instruction with actual reserve struct addresses
instructions.push(liquidate_obligation_and_redeem_reserve_collateral_v2(
LiquidateObligationAndRedeemReserveCollateralV2Accounts {
liquidator: owner,
obligation: obligation_pubkey,
lending_market: repay_reserve.lending_market,
lending_market_authority: lma,
repay_reserve: repay_reserve_pubkey,
repay_reserve_liquidity_mint: repay_reserve.liquidity.mint_pubkey,
repay_reserve_liquidity_supply: repay_reserve.liquidity.supply_vault,
withdraw_reserve: withdraw_reserve_pubkey,
withdraw_reserve_liquidity_mint: withdraw_reserve.liquidity.mint_pubkey,
withdraw_reserve_collateral_mint: withdraw_reserve.collateral.mint_pubkey,
withdraw_reserve_collateral_supply: withdraw_reserve.collateral.supply_vault,
withdraw_reserve_liquidity_supply: withdraw_reserve.liquidity.supply_vault,
withdraw_reserve_liquidity_fee_receiver: withdraw_reserve.liquidity.fee_vault,
user_source_liquidity,
user_destination_collateral,
user_destination_liquidity,
repay_liquidity_token_program: repay_reserve.liquidity.token_program,
withdraw_liquidity_token_program: withdraw_reserve.liquidity.token_program,
collateral_obligation_farm_user_state: None,
collateral_reserve_farm_state: None,
debt_obligation_farm_user_state: None,
debt_reserve_farm_state: None,
},
repay_amount,
min_received,
0, // max_allowed_ltv_override_percent (must be 0 on mainnet)
remaining,
));
The
helpers::liquidate::liquidate() helper uses ReservePdas::derive() which does not match reserves created by older program versions. Use the actual addresses from the Reserve struct as shown above.7
Simulate and Send
let recent_blockhash = rpc_client.get_latest_blockhash()?;
let message = Message::new(&instructions, Some(&owner));
let tx = Transaction::new(&[&signer], message, recent_blockhash);
// Simulate first
let sim = rpc_client.simulate_transaction(&tx)?;
if let Some(err) = sim.value.err {
println!("Simulation failed: {err}");
return Ok(());
}
// Send with fresh blockhash
let fresh_blockhash = rpc_client.get_latest_blockhash()?;
let fresh_msg = Message::new(&instructions, Some(&owner));
let fresh_tx = Transaction::new(&[&signer], fresh_msg, fresh_blockhash);
let signature = rpc_client.send_and_confirm_transaction(&fresh_tx)?;
println!("Liquidation executed: {signature}");