Liquidate with a minimum payout
Kamino
Status: Tested locally in LiteSVM against Kamino's program and accounts copied from mainnet; not yet run on devnet or mainnet.
Cost: Ballista's own work took 5,776 of the tested transaction's 185,271compute units; Kamino took the rest. Ballista charges no fee; see what it costs.
What it does
Liquidates a Kamino loan and reverts unless you receive at least a set amount of collateral.
A Kamino loan lives in an obligation, the borrower's account of deposits and debts. Once the debt passes a set share of the collateral's value, the obligation is unhealthy, and anyone may repay part of the debt for collateral worth more.
The template:
- requires the liquidator to own both accounts Kamino pays,
userDestinationLiquidityanduserDestinationCollateral, since Kamino checks only their mints (bountyGoesToTheLiquidator,seizedCollateralGoesToTheLiquidator); - records the balance of
userDestinationLiquidity, where Kamino pays the seized collateral once redeemed: SOL, for SOL collateral; - liquidates with Kamino's
liquidate_obligation_and_redeem_reserve_collateral_v2; - requires that balance to have grown by at least
minimumBounty, or the whole run reverts (liquidationPaidTheBounty).
You supply three inputs:
liquidityAmount: the most debt to repay, in the repaid token's base units, taken fromuserSourceLiquidity. Kamino repays less if one liquidation may not take that much of the debt; in the tested market, one takes at most10%.minAcceptableReceived: Kamino's own floor,min_acceptable_received_liquidity_amount. Kamino compares it with its own figure for the collateral it pays, net of its fee, not with what arrives.0turns it off, as in the tests.minimumBounty: the leastuserDestinationLiquiditymust grow by, in the collateral's base units (lamports, for SOL). It is what you receive, not your profit: what you repaid isn't subtracted. To require a profit, set it above the repaid debt's value in the collateral at the oracle price. Collateral Kamino couldn't redeem stays inuserDestinationCollateralas cTokens (Kamino's deposit receipts) and doesn't count.
Template
import {
TOKEN_PROGRAM_ADDRESS_BYTES,
account,
compileTemplate,
data,
defineTemplate,
expression,
step,
} from '@jac0xb/ballista';
import {
KAMINO_LEND,
KAMINO_LIQUIDATE,
SYSVAR_INSTRUCTIONS,
TOKEN_ACCOUNT_AMOUNT_OFFSET,
TOKEN_ACCOUNT_LENGTH,
TOKEN_ACCOUNT_OWNER_OFFSET,
addressBytes,
} from './shared.js';
export const kaminoLiquidateWithProof = defineTemplate({
inputs: {
/**
* The most debt to repay, in the repaid token's base units. Kamino repays less if one
* liquidation may not take that much of the debt.
*/
liquidityAmount: { type: 'u64' },
/** Kamino's own floor on the collateral it pays, net of its fee, by its own count; 0 for none. */
minAcceptableReceived: { type: 'u64' },
/**
* The least `userDestinationLiquidity` must grow by, in the collateral's base units: what the
* liquidator receives, with the repayment not subtracted.
*/
minimumBounty: { type: 'u64' },
},
accounts: {
kamino: { executable: true, address: addressBytes(KAMINO_LEND) },
tokenProgram: { executable: true, address: TOKEN_PROGRAM_ADDRESS_BYTES },
instructionsSysvar: { address: addressBytes(SYSVAR_INSTRUCTIONS) },
/** Kamino declares it a bare signer, so it is read-only. */
liquidator: { signer: true },
obligation: { writable: true },
lendingMarket: {},
lendingMarketAuthority: {},
repayReserve: { writable: true },
repayReserveLiquidityMint: {},
repayReserveLiquiditySupply: { writable: true },
withdrawReserve: { writable: true },
withdrawReserveLiquidityMint: {},
withdrawReserveCollateralMint: { writable: true },
withdrawReserveCollateralSupply: { writable: true },
withdrawReserveLiquiditySupply: { writable: true },
/** Where Kamino's protocol fee on the seized collateral goes: the withdrawn reserve's fee vault. */
withdrawReserveFeeReceiver: { writable: true },
/** Pays the repayment. */
userSourceLiquidity: { writable: true },
/** Receives the seized cTokens, which Kamino redeems in the same instruction. */
userDestinationCollateral: {
writable: true,
owner: TOKEN_PROGRAM_ADDRESS_BYTES,
minDataLength: TOKEN_ACCOUNT_LENGTH,
},
/** Receives the redeemed collateral: the account the bounty is measured on. */
userDestinationLiquidity: {
writable: true,
owner: TOKEN_PROGRAM_ADDRESS_BYTES,
minDataLength: TOKEN_ACCOUNT_LENGTH,
},
},
/**
* The end of Kamino's v2 liquidation, its own writable flags kept: the withdrawn reserve's
* collateral farm pair, the repaid reserve's debt farm pair, and the Farms program.
*/
accountGroups: ['farmAccounts'],
steps: [
// Kamino checks the mints of the accounts it pays, not whose they are.
step.require(
expression.equal(
expression.accountData(account.fixed('userDestinationLiquidity'), TOKEN_ACCOUNT_OWNER_OFFSET, 'pubkey'),
expression.accountField(account.fixed('liquidator'), 'key'),
),
'bountyGoesToTheLiquidator',
),
step.require(
expression.equal(
expression.accountData(account.fixed('userDestinationCollateral'), TOKEN_ACCOUNT_OWNER_OFFSET, 'pubkey'),
expression.accountField(account.fixed('liquidator'), 'key'),
),
'seizedCollateralGoesToTheLiquidator',
),
// Kamino redeems the seized cTokens in the same instruction and pays the underlying here, less
// its fee. What it can't redeem stays in `userDestinationCollateral` and doesn't count.
step.snapshot(
'payoutBefore',
expression.accountData(account.fixed('userDestinationLiquidity'), TOKEN_ACCOUNT_AMOUNT_OFFSET, 'u64'),
'readPayoutBefore',
),
// v2: the v1 handler refuses every caller but Kamino itself and a short whitelist.
step.invoke({
program: account.fixed('kamino'),
accounts: [
{ account: account.fixed('liquidator'), signer: true, writable: false },
{ account: account.fixed('obligation'), signer: false, writable: true },
{ account: account.fixed('lendingMarket'), signer: false, writable: false },
{ account: account.fixed('lendingMarketAuthority'), signer: false, writable: false },
{ account: account.fixed('repayReserve'), signer: false, writable: true },
{ account: account.fixed('repayReserveLiquidityMint'), signer: false, writable: false },
{ account: account.fixed('repayReserveLiquiditySupply'), signer: false, writable: true },
{ account: account.fixed('withdrawReserve'), signer: false, writable: true },
{ account: account.fixed('withdrawReserveLiquidityMint'), signer: false, writable: false },
{ account: account.fixed('withdrawReserveCollateralMint'), signer: false, writable: true },
{ account: account.fixed('withdrawReserveCollateralSupply'), signer: false, writable: true },
{ account: account.fixed('withdrawReserveLiquiditySupply'), signer: false, writable: true },
{ account: account.fixed('withdrawReserveFeeReceiver'), signer: false, writable: true },
{ account: account.fixed('userSourceLiquidity'), signer: false, writable: true },
{ account: account.fixed('userDestinationCollateral'), signer: false, writable: true },
{ account: account.fixed('userDestinationLiquidity'), signer: false, writable: true },
// `collateral_token_program`, `repay_liquidity_token_program`, `withdraw_liquidity_token_program`.
{ account: account.fixed('tokenProgram'), signer: false, writable: false },
{ account: account.fixed('tokenProgram'), signer: false, writable: false },
{ account: account.fixed('tokenProgram'), signer: false, writable: false },
{ account: account.fixed('instructionsSysvar'), signer: false, writable: false },
],
accountGroup: 'farmAccounts',
data: [
data.literal(KAMINO_LIQUIDATE),
data.encode('u64', expression.input('liquidityAmount')),
data.encode('u64', expression.input('minAcceptableReceived')),
// No LTV override: liquidate on the protocol's own terms.
data.encode('u64', expression.u64(0)),
],
label: 'liquidate',
}),
step.require(
expression.greaterThanOrEqual(
expression.subtract(
expression.accountData(account.fixed('userDestinationLiquidity'), TOKEN_ACCOUNT_AMOUNT_OFFSET, 'u64'),
expression.snapshot('payoutBefore'),
),
expression.input('minimumBounty'),
),
'liquidationPaidTheBounty',
),
],
});
export const compiled = compileTemplate(kaminoLiquidateWithProof);/// Liquidate a Kamino obligation only if the liquidator walks away with the bounty.
pub fn kamino_liquidate_with_proof() -> Template {
Template::new()
.input("liquidityAmount", Type::U64)
.input("minAcceptableReceived", Type::U64)
.input("minimumBounty", Type::U64)
.account("kamino", account::program(KAMINO_LEND))
.account("tokenProgram", account::program(TOKEN_PROGRAM_ID))
.account(
"instructionsSysvar",
account::readonly().address(INSTRUCTIONS_SYSVAR_ID),
)
.account("liquidator", account::signer())
.account("obligation", account::writable())
.account("lendingMarket", account::readonly())
.account("lendingMarketAuthority", account::readonly())
.account("repayReserve", account::writable())
.account("repayReserveLiquidityMint", account::readonly())
.account("repayReserveLiquiditySupply", account::writable())
.account("withdrawReserve", account::writable())
.account("withdrawReserveLiquidityMint", account::readonly())
.account("withdrawReserveCollateralMint", account::writable())
.account("withdrawReserveCollateralSupply", account::writable())
.account("withdrawReserveLiquiditySupply", account::writable())
.account("withdrawReserveFeeReceiver", account::writable())
.account("userSourceLiquidity", account::writable())
.account("userDestinationCollateral", token_account())
.account("userDestinationLiquidity", token_account())
.account_group("farmAccounts")
// Kamino checks the mints of the accounts it pays, not whose they are.
.step(
step::require(
account_data(
"userDestinationLiquidity",
TOKEN_ACCOUNT_OWNER_OFFSET,
ReadType::Pubkey,
)
.eq(key("liquidator")),
)
.label("bountyGoesToTheLiquidator"),
)
.step(
step::require(
account_data(
"userDestinationCollateral",
TOKEN_ACCOUNT_OWNER_OFFSET,
ReadType::Pubkey,
)
.eq(key("liquidator")),
)
.label("seizedCollateralGoesToTheLiquidator"),
)
// Kamino redeems the seized cTokens in the same instruction and pays the underlying here.
.step(
step::snapshot("payoutBefore", balance_of("userDestinationLiquidity"))
.label("readPayoutBefore"),
)
// v2: the v1 handler refuses every caller but Kamino itself and a short whitelist.
.step(
step::invoke("kamino")
.signer("liquidator")
.writable("obligation")
.readonly("lendingMarket")
.readonly("lendingMarketAuthority")
.writable("repayReserve")
.readonly("repayReserveLiquidityMint")
.writable("repayReserveLiquiditySupply")
.writable("withdrawReserve")
.readonly("withdrawReserveLiquidityMint")
.writable("withdrawReserveCollateralMint")
.writable("withdrawReserveCollateralSupply")
.writable("withdrawReserveLiquiditySupply")
.writable("withdrawReserveFeeReceiver")
.writable("userSourceLiquidity")
.writable("userDestinationCollateral")
.writable("userDestinationLiquidity")
// The collateral, repay and withdraw token programs.
.readonly("tokenProgram")
.readonly("tokenProgram")
.readonly("tokenProgram")
.readonly("instructionsSysvar")
.account_group("farmAccounts")
.data(data::literal(kamino_liquidate()))
.data(data::u64(input("liquidityAmount")))
.data(data::u64(input("minAcceptableReceived")))
// No LTV override: liquidate on the protocol's own terms.
.data(data::u64(u64(0)))
.label("liquidate"),
)
.step(
step::require(
(balance_of("userDestinationLiquidity") - snapshot("payoutBefore"))
.gte(input("minimumBounty")),
)
.label("liquidationPaidTheBounty"),
)
}import type { Address, Instruction } from '@solana/kit';
import { buildKitRunInstruction } from '@jac0xb/ballista/kit';
import { compiled } from '../kamino-liquidate-with-proof.js';
import { KAMINO_LEND, SYSVAR_INSTRUCTIONS } from '../shared.js';
import { KAMINO_FARMS_PROGRAM, kaminoFarmPair, type KaminoFarm } from './kamino.js';
import { TOKEN_PROGRAM, at, pinned } from './programs.js';
/** Send it behind `buildKaminoRefreshes`, in the same transaction. */
export function buildKaminoLiquidateRun(input: {
templateAddress: Address;
liquidator: Address;
obligation: Address;
lendingMarket: Address;
lendingMarketAuthority: Address;
repayReserve: Address;
repayReserveLiquidityMint: Address;
repayReserveLiquiditySupply: Address;
withdrawReserve: Address;
withdrawReserveLiquidityMint: Address;
withdrawReserveCollateralMint: Address;
withdrawReserveCollateralSupply: Address;
withdrawReserveLiquiditySupply: Address;
/** The withdrawn reserve's fee vault, which takes Kamino's fee on the seized collateral. */
withdrawReserveFeeReceiver: Address;
/** Pays the repayment. */
userSourceLiquidity: Address;
/** The liquidator's own accounts for the seized cTokens and for what they redeem to. */
userDestinationCollateral: Address;
userDestinationLiquidity: Address;
/** The withdrawn reserve's collateral farm and the repaid reserve's debt farm, if they exist. */
collateralFarm?: KaminoFarm;
debtFarm?: KaminoFarm;
/** The most debt to repay, in the repaid token's base units. */
liquidityAmount: bigint;
/** Kamino's own floor on its figure for the payout, net of its fee; 0 for none. */
minAcceptableReceived: bigint;
/** What `userDestinationLiquidity` must gain, in the collateral's base units: not profit. */
minimumBounty: bigint;
}): Instruction {
return buildKitRunInstruction({
compiled,
templateAddress: input.templateAddress,
inputs: {
liquidityAmount: input.liquidityAmount,
minAcceptableReceived: input.minAcceptableReceived,
minimumBounty: input.minimumBounty,
},
accounts: {
kamino: pinned(KAMINO_LEND),
tokenProgram: pinned(TOKEN_PROGRAM),
instructionsSysvar: pinned(SYSVAR_INSTRUCTIONS),
liquidator: at(input.liquidator),
obligation: at(input.obligation),
lendingMarket: at(input.lendingMarket),
lendingMarketAuthority: at(input.lendingMarketAuthority),
repayReserve: at(input.repayReserve),
repayReserveLiquidityMint: at(input.repayReserveLiquidityMint),
repayReserveLiquiditySupply: at(input.repayReserveLiquiditySupply),
withdrawReserve: at(input.withdrawReserve),
withdrawReserveLiquidityMint: at(input.withdrawReserveLiquidityMint),
withdrawReserveCollateralMint: at(input.withdrawReserveCollateralMint),
withdrawReserveCollateralSupply: at(input.withdrawReserveCollateralSupply),
withdrawReserveLiquiditySupply: at(input.withdrawReserveLiquiditySupply),
withdrawReserveFeeReceiver: at(input.withdrawReserveFeeReceiver),
userSourceLiquidity: at(input.userSourceLiquidity),
userDestinationCollateral: at(input.userDestinationCollateral),
userDestinationLiquidity: at(input.userDestinationLiquidity),
},
accountGroups: {
// Kamino's v2 liquidation ends in both farm pairs and Farms.
farmAccounts: [
...kaminoFarmPair(input.collateralFarm),
...kaminoFarmPair(input.debtFarm),
KAMINO_FARMS_PROGRAM,
],
},
});
}pub struct KaminoLiquidateAccounts {
pub liquidator: Pubkey,
pub obligation: Pubkey,
pub lending_market: Pubkey,
pub lending_market_authority: Pubkey,
pub repay_reserve: Pubkey,
pub repay_reserve_liquidity_mint: Pubkey,
pub repay_reserve_liquidity_supply: Pubkey,
pub withdraw_reserve: Pubkey,
pub withdraw_reserve_liquidity_mint: Pubkey,
pub withdraw_reserve_collateral_mint: Pubkey,
pub withdraw_reserve_collateral_supply: Pubkey,
pub withdraw_reserve_liquidity_supply: Pubkey,
/// The withdrawn reserve's fee vault, which takes Kamino's fee on the seized collateral.
pub withdraw_reserve_fee_receiver: Pubkey,
/// Pays the repayment.
pub user_source_liquidity: Pubkey,
/// The liquidator's own accounts for the seized cTokens and for what they redeem to, where
/// the bounty is measured.
pub user_destination_collateral: Pubkey,
pub user_destination_liquidity: Pubkey,
/// The withdrawn reserve's collateral farm and the repaid reserve's debt farm, if they exist;
/// see [`kamino_farm_pair`].
pub collateral_farm: Option<(Pubkey, Pubkey)>,
pub debt_farm: Option<(Pubkey, Pubkey)>,
}
/// `liquidity_amount` is the most debt to repay, in the repaid token's base units.
/// `min_acceptable_received` is Kamino's own floor on its figure for the payout, net of its fee; 0
/// for none. `minimum_bounty` is what `user_destination_liquidity` must gain, in the collateral's
/// base units (lamports for SOL): what the liquidator receives, not its profit.
///
/// Send it behind [`kamino_refreshes`], in the same transaction.
pub fn run_kamino_liquidate(
template: Pubkey,
a: &KaminoLiquidateAccounts,
liquidity_amount: u64,
min_acceptable_received: u64,
minimum_bounty: u64,
) -> Result<Instruction, Box<dyn Error>> {
// Kamino's v2 liquidation ends in both farm pairs and Farms.
let mut farm_accounts = kamino_farm_pair(a.collateral_farm).to_vec();
farm_accounts.extend(kamino_farm_pair(a.debt_farm));
farm_accounts.push(AccountMeta::new_readonly(KAMINO_FARMS, false));
let instruction = templates::kamino_liquidate_with_proof()
.compile()?
.run(template)
.input("liquidityAmount", liquidity_amount)
.input("minAcceptableReceived", min_acceptable_received)
.input("minimumBounty", minimum_bounty)
.account("kamino", KAMINO_LEND)
.account("tokenProgram", TOKEN_PROGRAM_ID)
.account("instructionsSysvar", INSTRUCTIONS_SYSVAR_ID)
.account("liquidator", a.liquidator)
.account("obligation", a.obligation)
.account("lendingMarket", a.lending_market)
.account("lendingMarketAuthority", a.lending_market_authority)
.account("repayReserve", a.repay_reserve)
.account("repayReserveLiquidityMint", a.repay_reserve_liquidity_mint)
.account(
"repayReserveLiquiditySupply",
a.repay_reserve_liquidity_supply,
)
.account("withdrawReserve", a.withdraw_reserve)
.account(
"withdrawReserveLiquidityMint",
a.withdraw_reserve_liquidity_mint,
)
.account(
"withdrawReserveCollateralMint",
a.withdraw_reserve_collateral_mint,
)
.account(
"withdrawReserveCollateralSupply",
a.withdraw_reserve_collateral_supply,
)
.account(
"withdrawReserveLiquiditySupply",
a.withdraw_reserve_liquidity_supply,
)
.account(
"withdrawReserveFeeReceiver",
a.withdraw_reserve_fee_receiver,
)
.account("userSourceLiquidity", a.user_source_liquidity)
.account("userDestinationCollateral", a.user_destination_collateral)
.account("userDestinationLiquidity", a.user_destination_liquidity)
.group("farmAccounts", farm_accounts)
.instruction()?;
Ok(instruction)
}The Rust template takes its program addresses, discriminators, token_account() and balance_of() from the shared helpers.
If the obligation is healthy again when the run lands, say because another liquidator got there first, Kamino refuses and the run reverts. To skip instead, make both the liquidation and the bounty check depend on two u128 fields of the obligation, as the refresh leaves them: it can be liquidated when borrow_factor_adjusted_debt_value_sf (byte 2208, counting the discriminator) is at least unhealthy_borrow_value_sf (byte 2256). No template reads them yet, so this is untested.
Run it
Kamino's v2 liquidation takes 25 accounts: 20 the template passes, then the farmAccountsaccount group of five. A reserve is Kamino's pool for one token, and a farm is a Kamino Farms rewards pool attached to one. The group holds:
- the obligation's user state in the withdrawn reserve's collateral farm, and that farm;
- its user state in the repaid reserve's debt farm, and that farm;
- Kamino's Farms program.
For a farm a reserve doesn't have, both of its slots hold the Kamino program, read-only.
The Run tabs pass the 19 declared accounts in order (kamino, tokenProgram, instructionsSysvar, liquidator, Kamino's twelve from obligation to withdrawReserveFeeReceiver, then userSourceLiquidity, userDestinationCollateral and userDestinationLiquidity), then the inputs liquidityAmount, minAcceptableReceived and minimumBounty, then farmAccounts.
Before the run, refresh Kamino in the same transaction. Kamino liquidates only against reserves and an obligation refreshed in the same slot, and the template doesn't refresh. Refreshing Kamino has the order and the helpers that build it.
What has been tested
- In LiteSVM.
tests/protocols/tests/kamino_liquidate_with_proof.rsliquidates an obligation that deposited1 SOLand borrowed70%of its value in USDC, after the test cut SOL's price12%, behind Kamino's refreshes. The run repaid10%of the debt, the market's limit per liquidation, and received more SOL than that USDC was worth at the oracle price, with no cTokens left over. The whole transaction took185,271compute units and1,045 bytes. - Failures. A
minimumBountyone lamport above the payout fails atliquidationPaidTheBounty, with nothing moved. An attacker's account, approved for the liquidator, asuserDestinationLiquidityoruserDestinationCollateralfails at the matching owner check, before Kamino is called. - Not tested. Devnet and mainnet, a repaid reserve with a debt farm, the health gate above, and Token-2022 tokens: the template accepts SPL Token accounts only. The inputs are the run builder's choice.