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:
- requires the fee accounts,
tokenOwnerAccountAandtokenOwnerAccountB, 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; - calls
update_fees_and_rewardsif the position has liquidity. Without it,fee_owed_aandfee_owed_bhold only what the last update recorded, usually0. Whirlpools refuses the update for a position without liquidity; - reads
fee_owed_aandfee_owed_b; - calls
collect_feesif either fee is abovedustFloor, paying both fees to the fee accounts; - calls
increase_liquidity_by_token_amounts_v2if both fees are abovedustFloorand 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
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);/// 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"),
)
}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),
},
});
}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.
memoProgramis SPL Memo, which Whirlpools' v2 instructions take.tickArrayLowerandtickArrayUpperhold the position's lower and upper ticks, the prices its range starts and ends at. Whirlpools stores a pool's ticks88to 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.rsearns 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,784compute units and706 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
dustFloorstay 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.
- 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
- Failures. A price outside the bounds fails with
PriceSlippageOutOfBounds, and the update and collect revert with it. At adustFloorof0,a position over the full price range owed a fee of1 lamport(a billionth of a SOL), too little to buy liquidity, fails the same way withLiquidityZero; at a floor of1it lands and collects both fees. A stranger's account in both fee slots, or in token B's alone, fails atfeesGoToThePositionHolder. - 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.