Skip to content

Compound collected fees ​

Orca

Status: Tested locally in LiteSVM against Orca's Whirlpools program and a SOL/USDC pool copied from mainnet; not yet run on devnet or mainnet.

Cost: Ballista's own work took 8,972 of the tested transaction's 43,784compute units; Whirlpools took the rest. Ballista charges no fee; see what it costs.

What it does ​

Collects the fees an Orca Whirlpools position has earned and adds them back to the position as liquidity.

What a position has earned is known only when the transaction runs, since trades keep paying it fees after you sign. Whoever holds a position's NFT, a token with a supply of one, owns the position. The template:

  1. requires the fee accounts, tokenOwnerAccountA and tokenOwnerAccountB, to belong to the NFT's holder, not merely to the signer (feesGoToThePositionHolder). Whirlpools checks only their mints, so a run built by someone else could otherwise send the fees anywhere;
  2. calls update_fees_and_rewards if the position has liquidity. Without it, fee_owed_a and fee_owed_b hold only what the last update recorded, usually 0. Whirlpools refuses the update for a position without liquidity;
  3. reads fee_owed_a and fee_owed_b;
  4. calls collect_fees if either fee is above dustFloor, paying both fees to the fee accounts;
  5. calls increase_liquidity_by_token_amounts_v2 if both fees are above dustFloor and the position has liquidity. With the fees as its limits, Whirlpools adds the most liquidity they buy at the price when the transaction runs. One fee is used whole, and the rest of the other stays in the holder's account.

While the pool's price is inside the position's range, the prices it provides liquidity for, new liquidity takes both tokens. So fees in one token are collected but not reinvested, and a position emptied of liquidity is collected, not refilled.

Don't use a floor of 0

A fee above the floor can still be too small to buy any liquidity. The deposit then fails with Whirlpools' LiquidityZero (6012), and the whole run reverts, collect included. A few base units (a token's smallest unit) cover SOL/USDC, but a pool whose token A is worth less per unit needs more: a few thousand is safer. One floor applies to both fees, each counted in its own token's base units.

Template ​

ts
import {
  TOKEN_PROGRAM_ADDRESS_BYTES,
  account,
  compileTemplate,
  data,
  defineTemplate,
  expression,
  step,
} from '@jac0xb/ballista';
import {
  MEMO_PROGRAM,
  OPTION_NONE,
  ORCA_BY_TOKEN_AMOUNTS,
  ORCA_COLLECT_FEES,
  ORCA_INCREASE_LIQUIDITY_BY_TOKEN_AMOUNTS_V2,
  ORCA_POSITION,
  ORCA_UPDATE_FEES_AND_REWARDS,
  ORCA_WHIRLPOOL,
  TOKEN_ACCOUNT_LENGTH,
  TOKEN_ACCOUNT_OWNER_OFFSET,
  addressBytes,
} from './shared.js';

const position = account.fixed('position');

export const orcaCompoundFees = defineTemplate({
  inputs: {
    /**
     * Fees at or below this, in either token's base units, are not worth collecting. Not safe at
     * 0: a fee too small to buy any liquidity fails the deposit, and the whole run with it.
     */
    dustFloor: { type: 'u64' },
    /** The lowest pool sqrt price (Q64.64) the deposit accepts. */
    minSqrtPrice: { type: 'u128' },
    /** The highest pool sqrt price (Q64.64) the deposit accepts. */
    maxSqrtPrice: { type: 'u128' },
  },
  accounts: {
    whirlpoolProgram: { executable: true, address: addressBytes(ORCA_WHIRLPOOL) },
    tokenProgram: { executable: true, address: TOKEN_PROGRAM_ADDRESS_BYTES },
    memoProgram: { executable: true, address: addressBytes(MEMO_PROGRAM) },
    positionAuthority: { signer: true },
    whirlpool: { writable: true },
    /** Owner-pinned so `liquidity` and the owed fees are read from a real Whirlpool position. */
    position: {
      writable: true,
      owner: addressBytes(ORCA_WHIRLPOOL),
      minDataLength: ORCA_POSITION.length,
    },
    /**
     * Read for its owner field, the position's real holder (`feesGoToThePositionHolder`). Could be
     * Token- or Token-2022-owned, so its owning program is not pinned; Whirlpools' own mint and
     * amount checks on this account make that read trustworthy without one.
     */
    positionTokenAccount: { unsafeUnpinned: true, minDataLength: TOKEN_ACCOUNT_LENGTH },
    tokenMintA: {},
    tokenMintB: {},
    /** Must belong to the position's holder (`feesGoToThePositionHolder`). */
    tokenOwnerAccountA: {
      writable: true,
      owner: TOKEN_PROGRAM_ADDRESS_BYTES,
      minDataLength: TOKEN_ACCOUNT_LENGTH,
    },
    tokenOwnerAccountB: {
      writable: true,
      owner: TOKEN_PROGRAM_ADDRESS_BYTES,
      minDataLength: TOKEN_ACCOUNT_LENGTH,
    },
    tokenVaultA: { writable: true },
    tokenVaultB: { writable: true },
    tickArrayLower: { writable: true },
    tickArrayUpper: { writable: true },
  },
  steps: [
    // Whirlpools' collect_fees checks only the mint of these accounts; nothing stops a run built
    // by someone other than the position's holder from pointing them elsewhere. The holder is
    // positionTokenAccount's owner, not positionAuthority, which may only be its delegate.
    step.let(
      'positionHolder',
      expression.accountData(account.fixed('positionTokenAccount'), TOKEN_ACCOUNT_OWNER_OFFSET, 'pubkey'),
      'readPositionHolder',
    ),
    step.require(
      expression.and(
        expression.equal(
          expression.accountData(account.fixed('tokenOwnerAccountA'), TOKEN_ACCOUNT_OWNER_OFFSET, 'pubkey'),
          expression.variable('positionHolder'),
        ),
        expression.equal(
          expression.accountData(account.fixed('tokenOwnerAccountB'), TOKEN_ACCOUNT_OWNER_OFFSET, 'pubkey'),
          expression.variable('positionHolder'),
        ),
      ),
      'feesGoToThePositionHolder',
    ),
    step.let(
      'hasLiquidity',
      expression.greaterThan(
        expression.accountData(position, ORCA_POSITION.liquidity, 'u128'),
        expression.u128(0),
      ),
      'readLiquidity',
    ),
    // Folds the pool's fee growth into the position, so the owed fees are current. Whirlpools
    // refuses it for a position without liquidity, which earns nothing.
    step.invoke({
      program: account.fixed('whirlpoolProgram'),
      accounts: [
        { account: account.fixed('whirlpool'), signer: false, writable: true },
        { account: position, signer: false, writable: true },
        { account: account.fixed('tickArrayLower'), signer: false, writable: false },
        { account: account.fixed('tickArrayUpper'), signer: false, writable: false },
      ],
      data: [data.literal(ORCA_UPDATE_FEES_AND_REWARDS)],
      when: expression.variable('hasLiquidity'),
      label: 'updateFees',
    }),
    // Read after the update, which makes them current, and before the collect, which zeroes them.
    step.let('owedA', expression.accountData(position, ORCA_POSITION.feeOwedA, 'u64'), 'readFeesOwedA'),
    step.let('owedB', expression.accountData(position, ORCA_POSITION.feeOwedB, 'u64'), 'readFeesOwedB'),
    step.let(
      'earnedA',
      expression.greaterThan(expression.variable('owedA'), expression.input('dustFloor')),
    ),
    step.let(
      'earnedB',
      expression.greaterThan(expression.variable('owedB'), expression.input('dustFloor')),
    ),
    step.invoke({
      program: account.fixed('whirlpoolProgram'),
      accounts: [
        { account: account.fixed('whirlpool'), signer: false, writable: false },
        { account: account.fixed('positionAuthority'), signer: true, writable: false },
        { account: position, signer: false, writable: true },
        { account: account.fixed('positionTokenAccount'), signer: false, writable: false },
        { account: account.fixed('tokenOwnerAccountA'), signer: false, writable: true },
        { account: account.fixed('tokenVaultA'), signer: false, writable: true },
        { account: account.fixed('tokenOwnerAccountB'), signer: false, writable: true },
        { account: account.fixed('tokenVaultB'), signer: false, writable: true },
        { account: account.fixed('tokenProgram'), signer: false, writable: false },
      ],
      data: [data.literal(ORCA_COLLECT_FEES)],
      // Either fee is worth collecting.
      when: expression.or(expression.variable('earnedA'), expression.variable('earnedB')),
      label: 'collectFees',
    }),
    // By token amounts: with the fees as caps, Whirlpools works out the most liquidity they buy at
    // the price when it runs. `increase_liquidity` would take a liquidity fixed at signing.
    step.invoke({
      program: account.fixed('whirlpoolProgram'),
      accounts: [
        { account: account.fixed('whirlpool'), signer: false, writable: true },
        { account: account.fixed('tokenProgram'), signer: false, writable: false },
        { account: account.fixed('tokenProgram'), signer: false, writable: false },
        { account: account.fixed('memoProgram'), signer: false, writable: false },
        { account: account.fixed('positionAuthority'), signer: true, writable: false },
        { account: position, signer: false, writable: true },
        { account: account.fixed('positionTokenAccount'), signer: false, writable: false },
        { account: account.fixed('tokenMintA'), signer: false, writable: false },
        { account: account.fixed('tokenMintB'), signer: false, writable: false },
        { account: account.fixed('tokenOwnerAccountA'), signer: false, writable: true },
        { account: account.fixed('tokenOwnerAccountB'), signer: false, writable: true },
        { account: account.fixed('tokenVaultA'), signer: false, writable: true },
        { account: account.fixed('tokenVaultB'), signer: false, writable: true },
        { account: account.fixed('tickArrayLower'), signer: false, writable: true },
        { account: account.fixed('tickArrayUpper'), signer: false, writable: true },
      ],
      data: [
        data.literal(ORCA_INCREASE_LIQUIDITY_BY_TOKEN_AMOUNTS_V2),
        data.literal(ORCA_BY_TOKEN_AMOUNTS),
        data.encode('u64', expression.variable('owedA')),
        data.encode('u64', expression.variable('owedB')),
        data.encode('u128', expression.input('minSqrtPrice')),
        data.encode('u128', expression.input('maxSqrtPrice')),
        data.literal(OPTION_NONE),
      ],
      // In range, liquidity needs both tokens; an emptied position stays empty.
      when: expression.and(
        expression.variable('hasLiquidity'),
        expression.and(expression.variable('earnedA'), expression.variable('earnedB')),
      ),
      label: 'compoundFees',
    }),
  ],
});

export const compiled = compileTemplate(orcaCompoundFees);
rs
/// Collect a Whirlpools position's fees and add them back as liquidity.
pub fn orca_compound_fees() -> Template {
    Template::new()
        .input("dustFloor", Type::U64)
        .input("minSqrtPrice", Type::U128)
        .input("maxSqrtPrice", Type::U128)
        .account("whirlpoolProgram", account::program(ORCA_WHIRLPOOL))
        .account("tokenProgram", account::program(TOKEN_PROGRAM_ID))
        .account("memoProgram", account::program(MEMO_PROGRAM))
        .account("positionAuthority", account::signer())
        .account("whirlpool", account::writable())
        .account(
            "position",
            account::writable()
                .owner(ORCA_WHIRLPOOL)
                .min_data_length(ORCA_POSITION_LENGTH),
        )
        .account(
            "positionTokenAccount",
            account::readonly()
                .unsafe_unpinned()
                .min_data_length(TOKEN_ACCOUNT_LENGTH),
        )
        .account("tokenMintA", account::readonly())
        .account("tokenMintB", account::readonly())
        .account("tokenOwnerAccountA", token_account())
        .account("tokenOwnerAccountB", token_account())
        .account("tokenVaultA", account::writable())
        .account("tokenVaultB", account::writable())
        .account("tickArrayLower", account::writable())
        .account("tickArrayUpper", account::writable())
        // The holder is positionTokenAccount's owner, not positionAuthority, which may only be its
        // delegate.
        .step(
            step::let_(
                "positionHolder",
                account_data(
                    "positionTokenAccount",
                    TOKEN_ACCOUNT_OWNER_OFFSET,
                    ReadType::Pubkey,
                ),
            )
            .label("readPositionHolder"),
        )
        .step(
            step::require(
                account_data(
                    "tokenOwnerAccountA",
                    TOKEN_ACCOUNT_OWNER_OFFSET,
                    ReadType::Pubkey,
                )
                .eq(var("positionHolder"))
                .and(
                    account_data(
                        "tokenOwnerAccountB",
                        TOKEN_ACCOUNT_OWNER_OFFSET,
                        ReadType::Pubkey,
                    )
                    .eq(var("positionHolder")),
                ),
            )
            .label("feesGoToThePositionHolder"),
        )
        .step(
            step::let_(
                "hasLiquidity",
                account_data("position", ORCA_POSITION_LIQUIDITY, ReadType::U128).gt(u128(0)),
            )
            .label("readLiquidity"),
        )
        // Folds the pool's fee growth into the position, so the owed fees are current.
        .step(
            step::invoke("whirlpoolProgram")
                .writable("whirlpool")
                .writable("position")
                .readonly("tickArrayLower")
                .readonly("tickArrayUpper")
                .data(data::literal(orca_update_fees_and_rewards()))
                .when(var("hasLiquidity"))
                .label("updateFees"),
        )
        // Read after the update, which makes them current, and before the collect, which zeroes them.
        .step(
            step::let_(
                "owedA",
                account_data("position", ORCA_POSITION_FEE_OWED_A, ReadType::U64),
            )
            .label("readFeesOwedA"),
        )
        .step(
            step::let_(
                "owedB",
                account_data("position", ORCA_POSITION_FEE_OWED_B, ReadType::U64),
            )
            .label("readFeesOwedB"),
        )
        .step(step::let_("earnedA", var("owedA").gt(input("dustFloor"))))
        .step(step::let_("earnedB", var("owedB").gt(input("dustFloor"))))
        .step(
            step::invoke("whirlpoolProgram")
                .readonly("whirlpool")
                .signer("positionAuthority")
                .writable("position")
                .readonly("positionTokenAccount")
                .writable("tokenOwnerAccountA")
                .writable("tokenVaultA")
                .writable("tokenOwnerAccountB")
                .writable("tokenVaultB")
                .readonly("tokenProgram")
                .data(data::literal(orca_collect_fees()))
                // Either fee is worth collecting.
                .when(var("earnedA").or(var("earnedB")))
                .label("collectFees"),
        )
        // By token amounts: with the fees as caps, Whirlpools works out the most liquidity they buy
        // at the price when it runs.
        .step(
            step::invoke("whirlpoolProgram")
                .writable("whirlpool")
                .readonly("tokenProgram")
                .readonly("tokenProgram")
                .readonly("memoProgram")
                .signer("positionAuthority")
                .writable("position")
                .readonly("positionTokenAccount")
                .readonly("tokenMintA")
                .readonly("tokenMintB")
                .writable("tokenOwnerAccountA")
                .writable("tokenOwnerAccountB")
                .writable("tokenVaultA")
                .writable("tokenVaultB")
                .writable("tickArrayLower")
                .writable("tickArrayUpper")
                .data(data::literal(orca_increase_liquidity_by_token_amounts_v2()))
                .data(data::literal(ORCA_BY_TOKEN_AMOUNTS))
                .data(data::u64(var("owedA")))
                .data(data::u64(var("owedB")))
                .data(data::u128(input("minSqrtPrice")))
                .data(data::u128(input("maxSqrtPrice")))
                .data(data::literal(OPTION_NONE))
                // In range, liquidity needs both tokens; an emptied position stays empty.
                .when(var("hasLiquidity").and(var("earnedA").and(var("earnedB"))))
                .label("compoundFees"),
        )
}
ts
import type { Address, Instruction } from '@solana/kit';

import { buildKitRunInstruction } from '@jac0xb/ballista/kit';
import { compiled } from '../orca-compound-fees.js';
import { MEMO_PROGRAM, ORCA_WHIRLPOOL } from '../shared.js';
import { TOKEN_PROGRAM, at, pinned } from './programs.js';

export function buildOrcaCompoundRun(input: {
  templateAddress: Address;
  /** Signs for the position: the holder of its NFT, or a delegate approved on it. */
  positionAuthority: Address;
  whirlpool: Address;
  position: Address;
  /** The token account holding the position's NFT. Its owner is the position's holder. */
  positionTokenAccount: Address;
  tokenMintA: Address;
  tokenMintB: Address;
  /** The holder's own token accounts: the fees go there and are reinvested from there. */
  tokenOwnerAccountA: Address;
  tokenOwnerAccountB: Address;
  tokenVaultA: Address;
  tokenVaultB: Address;
  /** The tick arrays holding the position's lower and upper ticks. */
  tickArrayLower: Address;
  tickArrayUpper: Address;
  /** Fees at or below this, in either token's base units, are not collected. Not safe at 0. */
  dustFloor: bigint;
  /** The pool sqrt prices (Q64.64) the deposit accepts: Orca's `get_sqrt_price_slippage_bounds`. */
  minSqrtPrice: bigint;
  maxSqrtPrice: bigint;
}): Instruction {
  return buildKitRunInstruction({
    compiled,
    templateAddress: input.templateAddress,
    inputs: {
      dustFloor: input.dustFloor,
      minSqrtPrice: input.minSqrtPrice,
      maxSqrtPrice: input.maxSqrtPrice,
    },
    accounts: {
      whirlpoolProgram: pinned(ORCA_WHIRLPOOL),
      tokenProgram: pinned(TOKEN_PROGRAM),
      memoProgram: pinned(MEMO_PROGRAM),
      positionAuthority: at(input.positionAuthority),
      whirlpool: at(input.whirlpool),
      position: at(input.position),
      positionTokenAccount: at(input.positionTokenAccount),
      tokenMintA: at(input.tokenMintA),
      tokenMintB: at(input.tokenMintB),
      tokenOwnerAccountA: at(input.tokenOwnerAccountA),
      tokenOwnerAccountB: at(input.tokenOwnerAccountB),
      tokenVaultA: at(input.tokenVaultA),
      tokenVaultB: at(input.tokenVaultB),
      tickArrayLower: at(input.tickArrayLower),
      tickArrayUpper: at(input.tickArrayUpper),
    },
  });
}
rs
pub struct OrcaCompoundAccounts {
    /// Signs for the position: the holder of its NFT, or a delegate approved on it.
    pub position_authority: Pubkey,
    pub whirlpool: Pubkey,
    pub position: Pubkey,
    /// The token account holding the position's NFT. Its owner is the position's holder.
    pub position_token_account: Pubkey,
    pub token_mint_a: Pubkey,
    pub token_mint_b: Pubkey,
    /// The holder's own token accounts: the fees go there and are reinvested from there.
    pub token_owner_account_a: Pubkey,
    pub token_owner_account_b: Pubkey,
    pub token_vault_a: Pubkey,
    pub token_vault_b: Pubkey,
    /// The tick arrays holding the position's lower and upper ticks.
    pub tick_array_lower: Pubkey,
    pub tick_array_upper: Pubkey,
}

/// `dust_floor` is not safe at 0: a fee too small to buy any liquidity fails the whole run.
/// `sqrt_price_bounds` is `(min, max)`, the pool sqrt prices (Q64.64) the deposit accepts:
/// Orca's `get_sqrt_price_slippage_bounds` for the current price and a tolerance.
pub fn run_orca_compound(
    template: Pubkey,
    a: &OrcaCompoundAccounts,
    dust_floor: u64,
    (min_sqrt_price, max_sqrt_price): (u128, u128),
) -> Result<Instruction, Box<dyn Error>> {
    let instruction = templates::orca_compound_fees()
        .compile()?
        .run(template)
        .input("dustFloor", dust_floor)
        .input("minSqrtPrice", min_sqrt_price)
        .input("maxSqrtPrice", max_sqrt_price)
        .account("whirlpoolProgram", ORCA_WHIRLPOOL)
        .account("tokenProgram", TOKEN_PROGRAM_ID)
        .account("memoProgram", MEMO_PROGRAM)
        .account("positionAuthority", a.position_authority)
        .account("whirlpool", a.whirlpool)
        .account("position", a.position)
        .account("positionTokenAccount", a.position_token_account)
        .account("tokenMintA", a.token_mint_a)
        .account("tokenMintB", a.token_mint_b)
        .account("tokenOwnerAccountA", a.token_owner_account_a)
        .account("tokenOwnerAccountB", a.token_owner_account_b)
        .account("tokenVaultA", a.token_vault_a)
        .account("tokenVaultB", a.token_vault_b)
        .account("tickArrayLower", a.tick_array_lower)
        .account("tickArrayUpper", a.tick_array_upper)
        .instruction()?;
    Ok(instruction)
}

The offsets come from Orca's Position account and the SPL Token account; see reading offsets. The Rust template takes its program addresses, offsets, discriminators and token_account() from the shared helpers.

Run it ​

The Run tabs pass the template's 15 accounts in the order it declares them: whirlpoolProgram, tokenProgram, memoProgram, positionAuthority, whirlpool, position, positionTokenAccount, tokenMintA, tokenMintB, tokenOwnerAccountA, tokenOwnerAccountB, tokenVaultA, tokenVaultB, tickArrayLower and tickArrayUpper. There is no account group.

  • memoProgram is SPL Memo, which Whirlpools' v2 instructions take.
  • tickArrayLower and tickArrayUpper hold the position's lower and upper ticks, the prices its range starts and ends at. Whirlpools stores a pool's ticks 88 to an account, in tick arrays.
  • Both pool mints must be SPL Token mints, as SOL and USDC are.

The inputs are dustFloor (a u64), then minSqrtPrice and maxSqrtPrice (u128s), the lowest and highest pool price the deposit accepts. Whirlpools stores a price as its square root, the sqrt price, in Q64.64 fixed point: a u128 whose low 64 bits are the fraction. Orca's get_sqrt_price_slippage_bounds computes both bounds from the pool's current sqrt price and a tolerance in basis points (hundredths of a percent). If the price is outside them when the run lands, the deposit fails with PriceSlippageOutOfBounds (6069), and the whole run reverts.

positionAuthority signs for the position. It can be the holder, or a delegate: an account, such as a keeper bot, that the holder approved on positionTokenAccount with the token program's approve. A delegate that reinvests also needs approval on both fee accounts, since the deposit spends from them under its signature.

Approving a keeper hands it the position

Neither approval is limited to this template. Outside it, the delegate can call Whirlpools' collect_fees itself and send the fees to accounts of its own, or move the NFT, and with it the position. It can also spend from the fee accounts up to the amount approved there. Approve only a keeper you would trust with the position itself, and approve a bounded amount on the fee accounts.

What has been tested ​

  • In LiteSVM. tests/protocols/tests/orca_compound_fees.rs earns fees with real swaps through the pool, then runs the template:
    • Fees in both tokens are updated, collected and reinvested. The liquidity added is exactly what Orca's own math says the fees buy, and one fee is used whole. The run took 43,784 compute units and 706 bytes.
    • Fees in one token are collected whole, not reinvested. With no fees, only the update runs. A position without liquidity gets no Whirlpools call, and an emptied one is collected, not refilled.
    • Fees at or below dustFloor stay owed. At a floor equal to the smaller fee, both fees are collected and neither is reinvested.
    • A price move inside the bounds still lands.
  • Failures. A price outside the bounds fails with PriceSlippageOutOfBounds, and the update and collect revert with it. At a dustFloor of 0, a position over the full price range owed a fee of 1 lamport (a billionth of a SOL), too little to buy liquidity, fails the same way with LiquidityZero; at a floor of 1 it lands and collects both fees. A stranger's account in both fee slots, or in token B's alone, fails at feesGoToThePositionHolder.
  • A delegate. A keeper approved on the NFT and both fee accounts signs the run, and the holder's fees are collected and reinvested.
  • Whirlpools alone. Without Ballista, fees owed rise only on an update, the update fails without liquidity, and in-range liquidity needs both tokens (tests/protocols/tests/orca_setup.rs).
  • Every Whirlpools call passes the same accounts, in the same order and with the same signer and writable flags, as Orca's own Rust client (tests/protocols/tests/orca_cpis.rs).
  • An opt-in test checks the Orca offsets against devnet accounts.

All protocol templates · What has been tested

BALLISTA / A SMALL MACHINE FOR COMPLEX TRANSACTIONS