Repay what a swap produced
Kamino · Jupiter
Status: Tested locally in LiteSVM against Jupiter, Meteora and Kamino programs and accounts copied from mainnet; not yet run on devnet or mainnet.
Cost: Ballista's own work took 8,345 of the tested transaction's 141,830compute units; the protocols took the rest. Ballista charges no fee; see what it costs.
What it does
Sells collateral through Jupiter and repays a Kamino loan with what the sale produced, in one transaction.
Deleveraging means selling collateral and repaying a loan with the proceeds. Two numbers are unknown when you sign: how much the swap returns, and how much you owe by the time the transaction runs. Interest keeps accruing, so a debt figure fetched by the client is already out of date when the transaction lands.
The template handles the first number. It:
- requires
borrowedAssetAta, where the swap pays out, to belong to the borrower (swapPaysTheBorrower). Kamino repays from any account the borrower may spend, so without this a run could leave the rest of the swap in someone else's account; - requires the route's
platformFeeBpsto be at mostMAX_PLATFORM_FEE_BPS, a constant that is0(platformFeeWithinCap); - swaps, measures how much of the borrowed token arrived, and requires at least
minimumRepayment(swapWorthRepaying); - repays exactly that amount against the borrower's obligation, Kamino's account of their deposits and debts, with
repay_obligation_liquidity_v2.
It does not read the debt. Kamino repays at most what is owed, and the rest stays in borrowedAssetAta.
Template
import {
TOKEN_PROGRAM_ADDRESS_BYTES,
account,
compileTemplate,
data,
defineTemplate,
expression,
step,
} from '@jac0xb/ballista';
import {
JUPITER_ROUTE,
JUPITER_V6,
KAMINO_LEND,
KAMINO_REPAY,
SYSVAR_INSTRUCTIONS,
TOKEN_ACCOUNT_AMOUNT_OFFSET,
TOKEN_ACCOUNT_LENGTH,
TOKEN_ACCOUNT_OWNER_OFFSET,
addressBytes,
} from './shared.js';
/** The route's platform fee account and rate are chosen by whoever builds the run: cap the rate. */
export const MAX_PLATFORM_FEE_BPS = 0n;
export const kaminoRepaySwapOutput = defineTemplate({
inputs: {
/** `route_plan` as the Swap API encoded it: the bytes between the discriminator and `in_amount`. */
routePlan: { type: 'bytes', maxLength: 512 },
/** The route's `in_amount`. */
inAmount: { type: 'u64' },
/** The quote's `quoted_out_amount`. */
quotedOutAmount: { type: 'u64' },
/** The quote's `slippage_bps`. */
slippageBps: { type: 'u64' },
/** The quote's `platform_fee_bps`, at most `MAX_PLATFORM_FEE_BPS`. */
platformFeeBps: { type: 'u64' },
/** Repaying dust costs more in fees than it saves in interest. */
minimumRepayment: { type: 'u64' },
},
accounts: {
jupiter: { executable: true, address: addressBytes(JUPITER_V6) },
kamino: { executable: true, address: addressBytes(KAMINO_LEND) },
tokenProgram: { executable: true, address: TOKEN_PROGRAM_ADDRESS_BYTES },
instructionsSysvar: { address: addressBytes(SYSVAR_INSTRUCTIONS) },
/** Signs the swap and the repayment; Kamino declares it a bare signer, so it is read-only. */
borrower: { signer: true },
/** The collateral the route sells. */
collateralAta: { writable: true },
/** Receives the swap output and funds the repayment. */
borrowedAssetAta: {
writable: true,
owner: TOKEN_PROGRAM_ADDRESS_BYTES,
minDataLength: TOKEN_ACCOUNT_LENGTH,
},
obligation: { writable: true },
lendingMarket: {},
repayReserve: { writable: true },
reserveLiquidityMint: {},
reserveLiquiditySupply: { writable: true },
},
/**
* `routeAccounts`: Jupiter's own list, whose length depends on the route. `farmAccounts`: the end
* of Kamino's v2 repayment, its own writable flags kept: the debt farm pair, the lending market
* authority and the Farms program.
*/
accountGroups: ['routeAccounts', 'farmAccounts'],
steps: [
// The swap pays into `borrowedAssetAta` and Kamino repays from it. Kamino accepts any account
// the borrower may spend, even another owner's, and what the debt doesn't take stays there.
step.require(
expression.equal(
expression.accountData(account.fixed('borrowedAssetAta'), TOKEN_ACCOUNT_OWNER_OFFSET, 'pubkey'),
expression.accountField(account.fixed('borrower'), 'key'),
),
'swapPaysTheBorrower',
),
step.snapshot(
'balanceBefore',
expression.accountData(account.fixed('borrowedAssetAta'), TOKEN_ACCOUNT_AMOUNT_OFFSET, 'u64'),
'readBalanceBeforeSwap',
),
// The fee account sits in the route's own accounts: any nonzero rate pays whoever chose it.
step.require(
expression.lessThanOrEqual(expression.input('platformFeeBps'), expression.u64(MAX_PLATFORM_FEE_BPS)),
'platformFeeWithinCap',
),
step.invoke({
program: account.fixed('jupiter'),
// `route` takes the token program, the signer, and the user's source and destination token
// accounts first; the route's own accounts follow as the group.
accounts: [
{ account: account.fixed('tokenProgram'), signer: false, writable: false },
{ account: account.fixed('borrower'), signer: true, writable: false },
{ account: account.fixed('collateralAta'), signer: false, writable: true },
{ account: account.fixed('borrowedAssetAta'), signer: false, writable: true },
],
accountGroup: 'routeAccounts',
data: [
data.literal(JUPITER_ROUTE),
data.encode('bytes', expression.input('routePlan')),
data.encode('u64', expression.input('inAmount')),
data.encode('u64', expression.input('quotedOutAmount')),
data.encode('u16', expression.input('slippageBps')),
data.encode('u8', expression.input('platformFeeBps')),
],
label: 'swapCollateralIntoDebtAsset',
}),
step.let(
'swapped',
expression.subtract(
expression.accountData(account.fixed('borrowedAssetAta'), TOKEN_ACCOUNT_AMOUNT_OFFSET, 'u64'),
expression.snapshot('balanceBefore'),
),
'measureSwapOutput',
),
step.require(
expression.greaterThanOrEqual(expression.variable('swapped'), expression.input('minimumRepayment')),
'swapWorthRepaying',
),
// v2: the v1 handler refuses every caller but Kamino itself and a short whitelist.
step.invoke({
program: account.fixed('kamino'),
accounts: [
{ account: account.fixed('borrower'), signer: true, writable: false },
{ account: account.fixed('obligation'), signer: false, writable: true },
{ account: account.fixed('lendingMarket'), signer: false, writable: false },
{ account: account.fixed('repayReserve'), signer: false, writable: true },
{ account: account.fixed('reserveLiquidityMint'), signer: false, writable: false },
{ account: account.fixed('reserveLiquiditySupply'), signer: false, writable: true },
// The repayment draws from the account the swap paid into.
{ account: account.fixed('borrowedAssetAta'), signer: false, writable: true },
{ account: account.fixed('tokenProgram'), signer: false, writable: false },
{ account: account.fixed('instructionsSysvar'), signer: false, writable: false },
],
accountGroup: 'farmAccounts',
data: [
data.literal(KAMINO_REPAY),
// Exactly what the swap produced, measured a moment ago.
data.encode('u64', expression.variable('swapped')),
],
label: 'repayWhatTheSwapProduced',
}),
],
});
export const compiled = compileTemplate(kaminoRepaySwapOutput);/// Swap collateral into the borrowed asset and repay exactly what the swap produced.
pub fn kamino_repay_swap_output() -> Template {
Template::new()
.input("routePlan", Type::Bytes(512))
.input("inAmount", Type::U64)
.input("quotedOutAmount", Type::U64)
.input("slippageBps", Type::U64)
.input("platformFeeBps", Type::U64)
.input("minimumRepayment", Type::U64)
.account("jupiter", account::program(JUPITER_V6))
.account("kamino", account::program(KAMINO_LEND))
.account("tokenProgram", account::program(TOKEN_PROGRAM_ID))
.account(
"instructionsSysvar",
account::readonly().address(INSTRUCTIONS_SYSVAR_ID),
)
.account("borrower", account::signer())
.account("collateralAta", account::writable())
.account("borrowedAssetAta", token_account())
.account("obligation", account::writable())
.account("lendingMarket", account::readonly())
.account("repayReserve", account::writable())
.account("reserveLiquidityMint", account::readonly())
.account("reserveLiquiditySupply", account::writable())
.account_group("routeAccounts")
.account_group("farmAccounts")
// The swap pays into `borrowedAssetAta` and Kamino repays from it.
.step(
step::require(
account_data(
"borrowedAssetAta",
TOKEN_ACCOUNT_OWNER_OFFSET,
ReadType::Pubkey,
)
.eq(key("borrower")),
)
.label("swapPaysTheBorrower"),
)
.step(
step::snapshot("balanceBefore", balance_of("borrowedAssetAta"))
.label("readBalanceBeforeSwap"),
)
.step(platform_fee_within_cap())
.step(
step::invoke("jupiter")
.readonly("tokenProgram")
.signer("borrower")
.writable("collateralAta")
.writable("borrowedAssetAta")
.account_group("routeAccounts")
.data_parts(jupiter_route_data(
input("inAmount"),
input("quotedOutAmount"),
))
.label("swapCollateralIntoDebtAsset"),
)
.step(
step::let_(
"swapped",
balance_of("borrowedAssetAta") - snapshot("balanceBefore"),
)
.label("measureSwapOutput"),
)
.step(
step::require(var("swapped").gte(input("minimumRepayment"))).label("swapWorthRepaying"),
)
// v2: the v1 handler refuses every caller but Kamino itself and a short whitelist.
.step(
step::invoke("kamino")
.signer("borrower")
.writable("obligation")
.readonly("lendingMarket")
.writable("repayReserve")
.readonly("reserveLiquidityMint")
.writable("reserveLiquiditySupply")
// The repayment draws from the account the swap paid into.
.writable("borrowedAssetAta")
.readonly("tokenProgram")
.readonly("instructionsSysvar")
.account_group("farmAccounts")
.data(data::literal(kamino_repay()))
// Exactly what the swap produced, measured a moment ago.
.data(data::u64(var("swapped")))
.label("repayWhatTheSwapProduced"),
)
}import type { Address, Instruction } from '@solana/kit';
import { buildKitRunInstruction, type KitAccountBinding } from '@jac0xb/ballista/kit';
import { compiled } from '../kamino-repay-swap-output.js';
import { JUPITER_V6, KAMINO_LEND, SYSVAR_INSTRUCTIONS, splitJupiterRoute } 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 buildKaminoRepayRun(input: {
templateAddress: Address;
borrower: Address;
collateralAta: Address;
/** The borrower's own token account: it receives the swap and funds the repayment. */
borrowedAssetAta: Address;
obligation: Address;
lendingMarket: Address;
/** The repayment's tail takes the market's authority PDA. */
lendingMarketAuthority: Address;
repayReserve: Address;
reserveLiquidityMint: Address;
reserveLiquiditySupply: Address;
/** The reserve's debt farm, if it has one. */
debtFarm?: KaminoFarm;
/** The Swap API's `route` data. */
routeData: Uint8Array;
minimumRepayment: bigint;
/** The route's account list from the fifth account on. */
routeAccounts: readonly KitAccountBinding[];
}): Instruction {
const route = splitJupiterRoute(input.routeData);
return buildKitRunInstruction({
compiled,
templateAddress: input.templateAddress,
inputs: {
routePlan: route.routePlan,
inAmount: route.inAmount,
quotedOutAmount: route.quotedOutAmount,
slippageBps: route.slippageBps,
platformFeeBps: route.platformFeeBps,
minimumRepayment: input.minimumRepayment,
},
accounts: {
jupiter: pinned(JUPITER_V6),
kamino: pinned(KAMINO_LEND),
tokenProgram: pinned(TOKEN_PROGRAM),
instructionsSysvar: pinned(SYSVAR_INSTRUCTIONS),
borrower: at(input.borrower),
collateralAta: at(input.collateralAta),
borrowedAssetAta: at(input.borrowedAssetAta),
obligation: at(input.obligation),
lendingMarket: at(input.lendingMarket),
repayReserve: at(input.repayReserve),
reserveLiquidityMint: at(input.reserveLiquidityMint),
reserveLiquiditySupply: at(input.reserveLiquiditySupply),
},
accountGroups: {
routeAccounts: input.routeAccounts,
// Kamino's v2 repayment ends in the farm pair, the lending market authority and Farms.
farmAccounts: [
...kaminoFarmPair(input.debtFarm),
{ address: input.lendingMarketAuthority },
KAMINO_FARMS_PROGRAM,
],
},
});
}pub struct KaminoRepayAccounts {
pub borrower: Pubkey,
pub collateral_ata: Pubkey,
/// The borrower's own token account: it receives the swap and funds the repayment.
pub borrowed_asset_ata: Pubkey,
pub obligation: Pubkey,
pub lending_market: Pubkey,
/// The repayment's tail takes the market's authority PDA.
pub lending_market_authority: Pubkey,
pub repay_reserve: Pubkey,
pub reserve_liquidity_mint: Pubkey,
pub reserve_liquidity_supply: Pubkey,
/// The reserve's debt farm, if it has one; see [`kamino_farm_pair`].
pub debt_farm: Option<(Pubkey, Pubkey)>,
}
/// `route` is the Swap API's `route` data split by [`RouteQuote::split`], and `route_accounts` its
/// account list from the fifth account on.
///
/// Send it behind [`kamino_refreshes`], in the same transaction.
pub fn run_kamino_repay(
template: Pubkey,
a: &KaminoRepayAccounts,
route: &RouteQuote,
minimum_repayment: u64,
route_accounts: Vec<AccountMeta>,
) -> Result<Instruction, Box<dyn Error>> {
// Kamino's v2 repayment ends in the farm pair, the lending market authority and Farms.
let mut farm_accounts = kamino_farm_pair(a.debt_farm).to_vec();
farm_accounts.push(AccountMeta::new_readonly(a.lending_market_authority, false));
farm_accounts.push(AccountMeta::new_readonly(KAMINO_FARMS, false));
let instruction = templates::kamino_repay_swap_output()
.compile()?
.run(template)
.input("routePlan", route.route_plan)
.input("inAmount", route.in_amount)
.input("quotedOutAmount", route.quoted_out_amount)
.input("slippageBps", route.slippage_bps)
.input("platformFeeBps", route.platform_fee_bps)
.input("minimumRepayment", minimum_repayment)
.account("jupiter", JUPITER_V6)
.account("kamino", KAMINO_LEND)
.account("tokenProgram", TOKEN_PROGRAM_ID)
.account("instructionsSysvar", INSTRUCTIONS_SYSVAR_ID)
.account("borrower", a.borrower)
.account("collateralAta", a.collateral_ata)
.account("borrowedAssetAta", a.borrowed_asset_ata)
.account("obligation", a.obligation)
.account("lendingMarket", a.lending_market)
.account("repayReserve", a.repay_reserve)
.account("reserveLiquidityMint", a.reserve_liquidity_mint)
.account("reserveLiquiditySupply", a.reserve_liquidity_supply)
.group("routeAccounts", route_accounts)
.group("farmAccounts", farm_accounts)
.instruction()?;
Ok(instruction)
}The Rust template takes its program addresses, token_account(), balance_of() and jupiter_route_data() from the shared helpers.
Run it
route starts its account list with the token program, the signer, and the signer's source and destination token accounts: here collateralAta and borrowedAssetAta. The template passes those four itself; the rest of the route's accounts arrive as the routeAccountsaccount group. Getting a Jupiter route says how to request one.
Kamino's v2 repayment takes 13 accounts: 9 the template passes, then a second group, farmAccounts, of four. They are the obligation's user state in the reserve's debt farm, that farm, the lending market authority and Kamino's Farms program. A reserve is Kamino's pool for one token, and a farm is a Kamino Farms rewards pool attached to it. For a reserve without a debt farm, as on the main market's SOL and USDC reserves, both farm slots hold the Kamino program, read-only.
The Run tabs pass the 12 declared accounts in order (jupiter, kamino, tokenProgram, instructionsSysvar, borrower, collateralAta, borrowedAssetAta, then Kamino's five from obligation to reserveLiquiditySupply), then the inputs routePlan, inAmount, quotedOutAmount, slippageBps, platformFeeBps and minimumRepayment, then routeAccounts and farmAccounts.
Before the run, refresh Kamino in the same transaction. Kamino repays only against a reserve 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_repay_swap_output.rssells1 SOLthrough Jupiter and a Meteora pool, behind Kamino's refreshes, for a borrower owing three times the route's quote. The debt falls by exactly what the swap produced. The whole transaction took141,830compute units and1,006 bytes, using the route's lookup table. - Failures. A
minimumRepaymentone unit above the output fails atswapWorthRepaying, swap included. An attacker's account, approved for the borrower, asborrowedAssetAtafails atswapPaysTheBorrower, before Jupiter is called. A route that charges a platform fee fails atplatformFeeWithinCap, before Jupiter is called. - Not tested. Devnet and mainnet, a reserve with a debt farm, and Token-2022 tokens: the template accepts SPL Token accounts only. Only
borrowedAssetAtais tied to the borrower; the route's accounts and the inputs are the run builder's choice, apart from the capped platform fee.