Skip to content

Deposit what a swap produced ​

Jupiter · Kamino

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,503 of the tested transaction's 151,372compute units; the protocols took the rest. Ballista charges no fee; see what it costs.

What it does ​

Swaps through Jupiter and deposits exactly what the swap produced into Kamino, in one transaction.

Jupiter's route instruction carries the amount in, the quoted amount out, slippageBps and platformFeeBps. Jupiter reports the amount that actually came out only as an event, which it emits by making a CPI (a call from one program to another) to itself. An event isn't return data, so a calling program can't read it with get_return_data. The only reliable source is the destination token account, after the swap has run.

Kamino's deposit_reserve_liquidity_and_obligation_collateral_v2 needs that number as its liquidity_amount. A plain transaction has to fix it before the swap runs. Guess too high and the deposit fails; guess too low and the rest stays in your token account.

The template requires the route's platformFeeBps to be at most MAX_PLATFORM_FEE_BPS, a constant that is 0 (platformFeeWithinCap), before the swap. It reads the destination balance before and after the swap, requires the difference to be at least minimumOut (swapMetItsFloor), and asks Kamino to deposit exactly that difference. Kamino mints whole cTokens, its receipts for a deposit, and takes only what they are worth, so less than one cToken's worth (a base unit or so) can stay behind.

It does not guard against:

  • A bad route. The route arrives as routePlan, inAmount, quotedOutAmount and slippageBps, so what it sells and its quote are up to whoever builds the run. Set minimumOut from your own quote.
  • Spending the owner's other token accounts. The owner signs route, and Jupiter passes that authority to every step.

Template ​

ts
import {
  TOKEN_PROGRAM_ADDRESS_BYTES,
  account,
  compileTemplate,
  data,
  defineTemplate,
  expression,
  step,
} from '@jac0xb/ballista';
import {
  JUPITER_ROUTE,
  JUPITER_V6,
  KAMINO_DEPOSIT,
  KAMINO_LEND,
  SYSVAR_INSTRUCTIONS,
  TOKEN_ACCOUNT_AMOUNT_OFFSET,
  TOKEN_ACCOUNT_LENGTH,
  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 jupiterDepositExactOutput = 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' },
    /** Below this the route is not worth depositing and the run fails instead. */
    minimumOut: { 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) },
    owner: { signer: true, writable: true },
    /** What the route sells from. */
    sourceAta: { writable: true },
    /**
     * The route's destination, and the account the deposit draws from. Kamino debits it with
     * `owner`'s authority, so another wallet's account fails the deposit.
     */
    destinationAta: {
      writable: true,
      owner: TOKEN_PROGRAM_ADDRESS_BYTES,
      minDataLength: TOKEN_ACCOUNT_LENGTH,
    },
    obligation: { writable: true },
    lendingMarket: {},
    lendingMarketAuthority: {},
    reserve: { writable: true },
    reserveLiquidityMint: {},
    reserveLiquiditySupply: { writable: true },
    reserveCollateralMint: { writable: true },
    reserveDestinationDepositCollateral: { writable: true },
  },
  /**
   * `routeAccounts`: Jupiter's own list, whose length depends on the route. `farmAccounts`:
   * Kamino's v2 tail, the farm pair and the Farms program. It is a group because the pair is
   * writable when the reserve has a farm and the Kamino program, read-only, when it doesn't, and a
   * declared account has one fixed writable flag.
   */
  accountGroups: ['routeAccounts', 'farmAccounts'],
  steps: [
    step.snapshot(
      'balanceBefore',
      expression.accountData(account.fixed('destinationAta'), 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'),
      accounts: [
        { account: account.fixed('tokenProgram'), signer: false, writable: false },
        { account: account.fixed('owner'), signer: true, writable: false },
        { account: account.fixed('sourceAta'), signer: false, writable: true },
        { account: account.fixed('destinationAta'), 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: 'swap',
    }),

    step.let(
      'received',
      expression.subtract(
        expression.accountData(account.fixed('destinationAta'), TOKEN_ACCOUNT_AMOUNT_OFFSET, 'u64'),
        expression.snapshot('balanceBefore'),
      ),
      'measureSwapOutput',
    ),

    step.require(
      expression.greaterThanOrEqual(expression.variable('received'), expression.input('minimumOut')),
      'swapMetItsFloor',
    ),

    // Kamino's v2 deposit: v1 refuses calls from other programs (`CpiDisabled`).
    step.invoke({
      program: account.fixed('kamino'),
      accounts: [
        { account: account.fixed('owner'), signer: true, writable: true },
        { 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('reserve'), signer: false, writable: true },
        { account: account.fixed('reserveLiquidityMint'), signer: false, writable: false },
        { account: account.fixed('reserveLiquiditySupply'), signer: false, writable: true },
        { account: account.fixed('reserveCollateralMint'), signer: false, writable: true },
        { account: account.fixed('reserveDestinationDepositCollateral'), signer: false, writable: true },
        // The deposit draws from the account the swap paid into.
        { account: account.fixed('destinationAta'), signer: false, writable: true },
        // `placeholder_user_destination_collateral`, never used: the Kamino program means "none".
        { account: account.fixed('kamino'), signer: false, writable: false },
        // `collateral_token_program`, then `liquidity_token_program`.
        { 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_DEPOSIT),
        // Exactly what the swap produced, measured a moment ago.
        data.encode('u64', expression.variable('received')),
      ],
      label: 'depositSwapOutput',
    }),
  ],
});
rs
/// Deposit into Kamino exactly what a Jupiter swap produced.
pub fn jupiter_deposit_exact_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)
        // Below this the route is not worth depositing and the run fails instead.
        .input("minimumOut", 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("owner", account::signer().writable())
        // What the route sells from.
        .account("sourceAta", account::writable())
        // The route's destination, and the account the deposit draws from.
        .account("destinationAta", token_account())
        .account("obligation", account::writable())
        .account("lendingMarket", account::readonly())
        .account("lendingMarketAuthority", account::readonly())
        .account("reserve", account::writable())
        .account("reserveLiquidityMint", account::readonly())
        .account("reserveLiquiditySupply", account::writable())
        .account("reserveCollateralMint", account::writable())
        .account("reserveDestinationDepositCollateral", account::writable())
        // Jupiter's own accounts, and Kamino's v2 farm tail.
        .account_group("routeAccounts")
        .account_group("farmAccounts")
        .step(
            step::snapshot("balanceBefore", balance_of("destinationAta"))
                .label("readBalanceBeforeSwap"),
        )
        .step(platform_fee_within_cap())
        .step(
            step::invoke("jupiter")
                .readonly("tokenProgram")
                .signer("owner")
                .writable("sourceAta")
                .writable("destinationAta")
                .account_group("routeAccounts")
                .data_parts(jupiter_route_data(
                    input("inAmount"),
                    input("quotedOutAmount"),
                ))
                .label("swap"),
        )
        .step(
            step::let_(
                "received",
                balance_of("destinationAta") - snapshot("balanceBefore"),
            )
            .label("measureSwapOutput"),
        )
        .step(step::require(var("received").gte(input("minimumOut"))).label("swapMetItsFloor"))
        // Kamino's v2 deposit: v1 refuses calls from other programs (`CpiDisabled`).
        .step(
            step::invoke("kamino")
                .writable_signer("owner")
                .writable("obligation")
                .readonly("lendingMarket")
                .readonly("lendingMarketAuthority")
                .writable("reserve")
                .readonly("reserveLiquidityMint")
                .writable("reserveLiquiditySupply")
                .writable("reserveCollateralMint")
                .writable("reserveDestinationDepositCollateral")
                // The deposit draws from the account the swap paid into.
                .writable("destinationAta")
                // `placeholder_user_destination_collateral`: the Kamino program means "none".
                .readonly("kamino")
                // `collateral_token_program`, then `liquidity_token_program`.
                .readonly("tokenProgram")
                .readonly("tokenProgram")
                .readonly("instructionsSysvar")
                .account_group("farmAccounts")
                .data(data::literal(kamino_deposit()))
                // Exactly what the swap produced, measured a moment ago.
                .data(data::u64(var("received")))
                .label("depositSwapOutput"),
        )
}
ts
import type { Address, Instruction } from '@solana/kit';

import { buildKitRunInstruction, type KitAccountBinding } from '@jac0xb/ballista/kit';
import { compiled } from '../jupiter-deposit-exact-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 buildJupiterDepositRun(input: {
  templateAddress: Address;
  owner: Address;
  sourceAta: Address;
  destinationAta: Address;
  obligation: Address;
  lendingMarket: Address;
  lendingMarketAuthority: Address;
  reserve: Address;
  reserveLiquidityMint: Address;
  reserveLiquiditySupply: Address;
  reserveCollateralMint: Address;
  reserveDestinationDepositCollateral: Address;
  /** The reserve's collateral farm, if it has one. */
  collateralFarm?: KaminoFarm;
  /** The Swap API's `route` data. */
  routeData: Uint8Array;
  minimumOut: bigint;
  /** The route's account list from the fifth account on; the template passes the first four. */
  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,
      minimumOut: input.minimumOut,
    },
    accounts: {
      jupiter: pinned(JUPITER_V6),
      kamino: pinned(KAMINO_LEND),
      tokenProgram: pinned(TOKEN_PROGRAM),
      instructionsSysvar: pinned(SYSVAR_INSTRUCTIONS),
      owner: at(input.owner),
      sourceAta: at(input.sourceAta),
      destinationAta: at(input.destinationAta),
      obligation: at(input.obligation),
      lendingMarket: at(input.lendingMarket),
      lendingMarketAuthority: at(input.lendingMarketAuthority),
      reserve: at(input.reserve),
      reserveLiquidityMint: at(input.reserveLiquidityMint),
      reserveLiquiditySupply: at(input.reserveLiquiditySupply),
      reserveCollateralMint: at(input.reserveCollateralMint),
      reserveDestinationDepositCollateral: at(input.reserveDestinationDepositCollateral),
    },
    accountGroups: {
      routeAccounts: input.routeAccounts,
      // Kamino's v2 deposit ends in the farm pair and the Farms program.
      farmAccounts: [...kaminoFarmPair(input.collateralFarm), KAMINO_FARMS_PROGRAM],
    },
  });
}
rs
pub struct JupiterDepositAccounts {
    pub owner: Pubkey,
    pub source_ata: Pubkey,
    pub destination_ata: Pubkey,
    pub obligation: Pubkey,
    pub lending_market: Pubkey,
    pub lending_market_authority: Pubkey,
    pub reserve: Pubkey,
    pub reserve_liquidity_mint: Pubkey,
    pub reserve_liquidity_supply: Pubkey,
    pub reserve_collateral_mint: Pubkey,
    pub reserve_destination_deposit_collateral: Pubkey,
    /// The reserve's collateral farm, if it has one; see [`kamino_farm_pair`].
    pub collateral_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: the template passes the first four (token program,
/// owner, source, destination) itself.
///
/// Send it behind [`kamino_refreshes`], in the same transaction.
pub fn run_jupiter_deposit(
    template: Pubkey,
    a: &JupiterDepositAccounts,
    route: &RouteQuote,
    minimum_out: u64,
    route_accounts: Vec<AccountMeta>,
) -> Result<Instruction, Box<dyn Error>> {
    // Kamino's v2 deposit ends in the farm pair and the Farms program.
    let mut farm_accounts = kamino_farm_pair(a.collateral_farm).to_vec();
    farm_accounts.push(AccountMeta::new_readonly(KAMINO_FARMS, false));
    let instruction = templates::jupiter_deposit_exact_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("minimumOut", minimum_out)
        .account("jupiter", JUPITER_V6)
        .account("kamino", KAMINO_LEND)
        .account("tokenProgram", TOKEN_PROGRAM_ID)
        .account("instructionsSysvar", INSTRUCTIONS_SYSVAR_ID)
        .account("owner", a.owner)
        .account("sourceAta", a.source_ata)
        .account("destinationAta", a.destination_ata)
        .account("obligation", a.obligation)
        .account("lendingMarket", a.lending_market)
        .account("lendingMarketAuthority", a.lending_market_authority)
        .account("reserve", a.reserve)
        .account("reserveLiquidityMint", a.reserve_liquidity_mint)
        .account("reserveLiquiditySupply", a.reserve_liquidity_supply)
        .account("reserveCollateralMint", a.reserve_collateral_mint)
        .account(
            "reserveDestinationDepositCollateral",
            a.reserve_destination_deposit_collateral,
        )
        .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 signing owner, and the owner's source and destination token accounts. The template passes those four itself, and the deposit draws from the destination it measured. The rest of Jupiter's list changes from route to route, so it arrives as the routeAccounts account group: a list of any length that the caller supplies at run time. Group members keep the writable flag (permission to be modified) that the transaction gave them, and are never passed as signers.

Kamino's v2 deposit takes 17 accounts: 14 the template passes, then a second group, farmAccounts, of three. They are the obligation's user state in the reserve's collateral farm, that farm, and Kamino's Farms program. For a reserve without a farm, both farm slots hold the Kamino program, read-only.

The Run tabs pass the 15 declared accounts in order (jupiter, kamino, tokenProgram, instructionsSysvar, owner, sourceAta, destinationAta, then Kamino's eight from obligation to reserveDestinationDepositCollateral), then the inputs routePlan, inAmount, quotedOutAmount, slippageBps, platformFeeBps and minimumOut, then routeAccounts and farmAccounts.

Before the run:

  • Refresh Kamino in the same transaction. Kamino deposits only into an obligation refreshed in the same slot, and the template doesn't refresh. Refreshing Kamino has the order and the helpers that build it.
  • Create the farm user state once, with init_obligation_farms_for_reserve, before an obligation's first deposit into a reserve with a collateral farm.

A fuller TypeScript runner, clients/js/examples/protocols/run-jupiter-deposit.ts, takes the Swap API's response directly: it refuses data that isn't route, checks that the account list starts with the four accounts above, forwards the rest as the group, and fills farmAccounts.

Getting a Jupiter route says how to request the route from Jupiter's Swap API and what to keep from its response.

What has been tested ​

  • In LiteSVM. tests/protocols/tests/jupiter_deposit_exact_output.rs sells 1 SOL for USDC through Jupiter and a Meteora pool, then deposits into Kamino's USDC reserve, which has a collateral farm. Of the 121,391,105 USDC units the swap produced, Kamino took all but 1, its cToken rounding. The whole transaction, refreshes included, took 151,372 compute units and 1,085 bytes.
  • Failures. A minimumOut one unit above the fill fails at swapMetItsFloor, and nothing is deposited. Kamino refuses a deposit with no refresh in its slot (ObligationStale), and one into a farmed reserve without the farm accounts (FarmAccountsMissing, in tests/protocols/tests/kamino_contract.rs). A route that charges a platform fee fails at platformFeeWithinCap, before Jupiter is called.

All protocol templates · What has been tested

BALLISTA / A SMALL MACHINE FOR COMPLEX TRANSACTIONS