The Guide for Collecting Fees and Rewards from Orca Whirlpool Positions
Thomas CosiallsOrca Whirlpools is a concentrated liquidity AMM (Automated Market Maker) protocol on Solana. Unlike traditional constant-product AMMs where liquidity is spread uniformly across all prices, Whirlpools allows liquidity providers (LPs) to concentrate their capital within specific price ranges, resulting in significantly higher capital efficiency.
When you provide liquidity to a Whirlpool, you receive:
- Trading Fees: A percentage of every swap that occurs within your position's price range
- Rewards: Optional incentive tokens distributed by the pool to LPs (up to 3 different reward tokens per pool)
To interact with Whirlpool positions programmatically from your own Solana program, Orca provides the whirlpool_cpi crate, which enables Cross-Program Invocations (CPI) to the Whirlpool program.
The Critical Step: Updating Fees and Rewards
Before diving into the collection logic, there's a crucial concept to understand: you must call update_fees_and_rewards instruction before collecting, or you will receive 0 tokens.
Whirlpool positions store accumulated fees and rewards as "checkpoints" that track growth since the last update. The position account contains fields like:
- fee_growth_checkpoint_a / fee_growth_checkpoint_b: Track fee accumulation
- fee_owed_a / fee_owed_b: The actual claimable fee amounts
- reward_infos[].growth_inside_checkpoint: Tracks reward accumulation per reward token
- reward_infos[].amount_owed: The actual claimable reward amounts
The update_fees_and_rewards instruction reads the current global fee/reward growth from the pool and tick arrays, calculates what's owed to your position since the last checkpoint, and updates the fee_owed and amount_owed fields. Without calling this first, those fields remain at their previous values—often zero if you've never updated them.
The Update Fees and Rewards CPI
use whirlpool_cpi::{self, program::Whirlpool as WhirlpoolProgram}; pub fn execute_update_fees_and_rewards_cpi<'info>( whirlpool_program: &Program<'info, WhirlpoolProgram>, whirlpool: &AccountInfo<'info>, position: &AccountInfo<'info>, tick_array_lower: &AccountInfo<'info>, tick_array_upper: &AccountInfo<'info>, signer_seeds: Option<&[&[&[u8]]]>, ) -> Result<()> { let cpi_program = whirlpool_program.to_account_info(); let cpi_accounts = whirlpool_cpi::cpi::accounts::UpdateFeesAndRewards { whirlpool: whirlpool.clone(), position: position.clone(), tick_array_lower: tick_array_lower.clone(), tick_array_upper: tick_array_upper.clone(), }; let cpi_ctx = if let Some(seeds) = signer_seeds { CpiContext::new_with_signer(cpi_program, cpi_accounts, seeds) } else { CpiContext::new(cpi_program, cpi_accounts) }; msg!("CPI: whirlpool update_fees_and_rewards instruction"); whirlpool_cpi::cpi::update_fees_and_rewards(cpi_ctx)?; Ok(()) }
The tick arrays are required because fee/reward growth is tracked at the tick level, and the instruction needs to read the current growth values from the ticks that bound your position.
Collecting Trading Fees
Trading fees are accumulated in both tokens of the pool (Token A and Token B). When swaps occur within your position's price range, a portion of each swap is allocated to LPs proportional to their liquidity share.
The Collect Fees CPI
pub fn execute_collect_fees_cpi<'info>( whirlpool_program: &Program<'info, WhirlpoolProgram>, whirlpool: &AccountInfo<'info>, position_authority: &AccountInfo<'info>, position: &AccountInfo<'info>, position_token_account: &AccountInfo<'info>, token_vault_a: &AccountInfo<'info>, token_vault_b: &AccountInfo<'info>, token_owner_account_a: &AccountInfo<'info>, token_owner_account_b: &AccountInfo<'info>, token_program: &Program<'info, Token>, signer_seeds: Option<&[&[&[u8]]]>, ) -> Result<()> { let cpi_program = whirlpool_program.to_account_info(); let cpi_accounts = whirlpool_cpi::cpi::accounts::CollectFees { whirlpool: whirlpool.clone(), position_authority: position_authority.clone(), position: position.clone(), position_token_account: position_token_account.clone(), token_owner_account_a: token_owner_account_a.clone(), token_vault_a: token_vault_a.clone(), token_owner_account_b: token_owner_account_b.clone(), token_vault_b: token_vault_b.clone(), token_program: token_program.to_account_info(), }; let cpi_ctx = if let Some(seeds) = signer_seeds { CpiContext::new_with_signer(cpi_program, cpi_accounts, seeds) } else { CpiContext::new(cpi_program, cpi_accounts) }; msg!("CPI: whirlpool collect_fees instruction"); whirlpool_cpi::cpi::collect_fees(cpi_ctx)?; Ok(()) }
Complete Fee Collection Handler
Here's how to put it all together in an Anchor instruction handler:
pub fn handler( ctx: Context<CollectFeesOrcaPosition>, operation_id: u64, fee_owed_a: u64, fee_owed_b: u64, ) -> Result<()> { // Capture balances BEFORE the CPI call for verification let token_a_before = ctx.accounts.vault_token_a_account.amount; let token_b_before = ctx.accounts.vault_token_b_account.amount; // Prepare signer seeds for PDA authority let vault_seeds: &[&[&[u8]]] = &[&[ Vault::SEED_PREFIX, ctx.accounts.vault.owner.as_ref(), &operation_id.to_le_bytes(), &[ctx.accounts.vault.bump], ]]; // CRITICAL: Update fees and rewards BEFORE collecting execute_update_fees_and_rewards_cpi( &ctx.accounts.whirlpool_program, &ctx.accounts.whirlpool.to_account_info(), &ctx.accounts.position.to_account_info(), &ctx.accounts.tick_array_lower.to_account_info(), &ctx.accounts.tick_array_upper.to_account_info(), Some(vault_seeds), )?; // Now collect the fees execute_collect_fees_cpi( &ctx.accounts.whirlpool_program, &ctx.accounts.whirlpool.to_account_info(), &ctx.accounts.vault.to_account_info(), &ctx.accounts.position.to_account_info(), &ctx.accounts.position_token_account.to_account_info(), &ctx.accounts.token_vault_a.to_account_info(), &ctx.accounts.token_vault_b.to_account_info(), &ctx.accounts.vault_token_a_account.to_account_info(), &ctx.accounts.vault_token_b_account.to_account_info(), &ctx.accounts.token_program, Some(vault_seeds), )?; // Verify collection by checking balance changes ctx.accounts.vault_token_a_account.reload()?; ctx.accounts.vault_token_b_account.reload()?; let token_a_collected = ctx.accounts.vault_token_a_account.amount .checked_sub(token_a_before) .ok_or(ErrorCode::MathOverflow)?; let token_b_collected = ctx.accounts.vault_token_b_account.amount .checked_sub(token_b_before) .ok_or(ErrorCode::MathOverflow)?; msg!("Fees collected - Token A: {}, Token B: {}", token_a_collected, token_b_collected); Ok(()) }
Client-Side: Calculating Expected Fees
Before calling your collect fees instruction, you should calculate the expected fees using the Orca SDK:
import { fetchWhirlpool, fetchPosition, fetchTickArray, getTickArrayAddress } from "@orca-so/whirlpools-client" import { collectFeesQuote, getTickArrayStartTickIndex, getTickIndexInArray } from "@orca-so/whirlpools-core" // Fetch pool and position data const pool_data = await fetchWhirlpool(rpc, whirlpoolAddress) const position = await fetchPosition(rpc, positionPda) // Calculate tick array addresses for the position's range const tickSpacing = pool_data.data.tickSpacing const tickIndexStartLower = getTickArrayStartTickIndex(position.data.tickLowerIndex, tickSpacing) const tickIndexStartUpper = getTickArrayStartTickIndex(position.data.tickUpperIndex, tickSpacing) const [tickArrayLowerAddress] = await getTickArrayAddress(whirlpoolAddress, tickIndexStartLower) const [tickArrayUpperAddress] = await getTickArrayAddress(whirlpoolAddress, tickIndexStartUpper) // Fetch tick array states const tickArrayLowerState = await fetchTickArray(rpc, tickArrayLowerAddress) const tickArrayUpperState = await fetchTickArray(rpc, tickArrayUpperAddress) // Get the specific tick states const tickIndexInLowerArray = getTickIndexInArray( position.data.tickLowerIndex, tickIndexStartLower, tickSpacing ) const tickIndexInUpperArray = getTickIndexInArray( position.data.tickUpperIndex, tickIndexStartUpper, tickSpacing ) const tickLowerState = tickArrayLowerState.data.ticks[tickIndexInLowerArray] const tickUpperState = tickArrayUpperState.data.ticks[tickIndexInUpperArray] // Calculate the fees quote const feesQuote = collectFeesQuote( pool_data.data, position.data, tickLowerState, tickUpperState ) console.log("Expected fees - Token A:", feesQuote.feeOwedA, "Token B:", feesQuote.feeOwedB)
Collecting Rewards
Whirlpools can have up to 3 reward tokens configured by the pool operator. These are stored in the rewardInfos array on the Whirlpool account. Each reward has:
- mint: The reward token's mint address
- vault: The vault holding reward tokens
- emissions_per_second: The reward emission rate
Understanding the Reward Index
The key to collecting rewards is the reward_index parameter (0, 1, or 2). You must:
1. Check which reward slots are active by examining pool_data.rewardInfos
2. Collect each active reward separately with its corresponding index
3. Use the correct mint and vault from rewardInfos[index]
The Collect Rewards CPI
pub fn execute_collect_rewards_cpi<'info>( whirlpool_program: &Program<'info, WhirlpoolProgram>, whirlpool: &AccountInfo<'info>, position_authority: &AccountInfo<'info>, position: &AccountInfo<'info>, position_token_account: &AccountInfo<'info>, reward_owner_account: &AccountInfo<'info>, reward_vault: &AccountInfo<'info>, token_program: &Program<'info, Token>, reward_index: u8, signer_seeds: Option<&[&[&[u8]]]>, ) -> Result<()> { let cpi_program = whirlpool_program.to_account_info(); let cpi_accounts = whirlpool_cpi::cpi::accounts::CollectReward { whirlpool: whirlpool.clone(), position_authority: position_authority.clone(), position: position.clone(), position_token_account: position_token_account.clone(), reward_owner_account: reward_owner_account.clone(), reward_vault: reward_vault.clone(), token_program: token_program.to_account_info(), }; let cpi_ctx = if let Some(seeds) = signer_seeds { CpiContext::new_with_signer(cpi_program, cpi_accounts, seeds) } else { CpiContext::new(cpi_program, cpi_accounts) }; msg!("CPI: whirlpool collect_reward instruction"); whirlpool_cpi::cpi::collect_reward(cpi_ctx, reward_index)?; Ok(()) }
Complete Reward Collection Handler
pub fn handler( ctx: Context<CollectRewardsOrcaPosition>, operation_id: u64, reward_index: u8, ) -> Result<()> { let token_balance_before = ctx.accounts.vault_token_reward_account.amount; let vault_seeds: &[&[&[u8]]] = &[&[ Vault::SEED_PREFIX, ctx.accounts.vault.owner.as_ref(), &operation_id.to_le_bytes(), &[ctx.accounts.vault.bump], ]]; // CRITICAL: Update fees and rewards BEFORE collecting execute_update_fees_and_rewards_cpi( &ctx.accounts.whirlpool_program, &ctx.accounts.whirlpool.to_account_info(), &ctx.accounts.position.to_account_info(), &ctx.accounts.tick_array_lower.to_account_info(), &ctx.accounts.tick_array_upper.to_account_info(), Some(vault_seeds), )?; // Collect the specific reward by index execute_collect_rewards_cpi( &ctx.accounts.whirlpool_program, &ctx.accounts.whirlpool.to_account_info(), &ctx.accounts.vault.to_account_info(), &ctx.accounts.position.to_account_info(), &ctx.accounts.position_token_account.to_account_info(), &ctx.accounts.vault_token_reward_account.to_account_info(), &ctx.accounts.reward_vault.to_account_info(), &ctx.accounts.token_program, reward_index, Some(vault_seeds), )?; ctx.accounts.vault_token_reward_account.reload()?; let reward_received = ctx.accounts.vault_token_reward_account.amount .checked_sub(token_balance_before) .ok_or(ErrorCode::MathOverflow)?; msg!("Rewards collected - Index: {}, Amount: {}", reward_index, reward_received); Ok(()) }
Client-Side: Collecting Rewards by Index
import { collectRewardsQuote } from "@orca-so/whirlpools-core" // Calculate expected rewards const timestamp = Date.now() / 1000 const rewardsQuote = collectRewardsQuote( pool_data.data, position.data, tickLowerState, tickUpperState, BigInt(Math.floor(timestamp)) ) console.log("Expected rewards:", rewardsQuote) // Iterate through active rewards (up to 3) for (let rewardIndex = 0; rewardIndex < 3; rewardIndex++) { const rewardInfo = pool_data.data.rewardInfos[rewardIndex] // Skip if this reward slot is not initialized if (rewardInfo.mint.toString() === "11111111111111111111111111111111") { continue } // Get or create the ATA for receiving this reward token const rewardOwnerAccount = getAssociatedTokenAddressSync( new PublicKey(rewardInfo.mint.toString()), vaultPda, true // allowOwnerOffCurve for PDA ) const tx = await program.methods .collectRewardsOrcaPosition(operationId, rewardIndex) .accounts({ user: user.publicKey, vault: vaultPda, whirlpoolProgram: ORCA_WHIRLPOOL_PROGRAM_ID, whirlpool: whirlpoolAddress, position: positionPda, positionTokenAccount: positionTokenAccount, tickArrayLower: tickArrayLowerAddress, tickArrayUpper: tickArrayUpperAddress, vaultTokenRewardAccount: rewardOwnerAccount, rewardTokenMint: rewardInfo.mint, rewardVault: rewardInfo.vault, tokenProgram: TOKEN_PROGRAM_ID, associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, systemProgram: SystemProgram.programId, }) .signers([user]) .rpc() }
Summary
Collecting fees and rewards from Orca Whirlpool positions requires understanding a key workflow:
- Always call update_fees_and_rewards first - This instruction calculates and records your pending fees/rewards based on the current pool state. Without it, collect_fees and collect_reward will transfer 0 tokens.
- Fees are collected in both pool tokens - Use collect_fees to receive Token A and Token B simultaneously.
- Rewards are collected per index - Check whirlpool.rewardInfos[0..2] to see which reward slots are active, then call collect_reward for each active index with the corresponding mint and vault.
- Use the Orca SDK for quotes - Calculate expected amounts client-side using collectFeesQuote and collectRewardsQuote before submitting transactions.
The whirlpool_cpi crate makes these CPIs straightforward, but the update step is the critical detail that's easy to miss—and will silently result in zero transfers if omitted.

