Le guide pour collecter frais et récompenses sur les positions Orca Whirlpool
Thomas CosiallsOrca Whirlpools est un protocole d'AMM (automated market maker) à liquidité concentrée sur Solana. Contrairement aux AMM à produit constant classiques, où la liquidité est répartie uniformément sur tous les prix, Whirlpools permet aux fournisseurs de liquidité (LP) de concentrer leur capital dans des fourchettes de prix précises, ce qui améliore nettement l'efficacité du capital.
Lorsque vous fournissez de la liquidité à un Whirlpool, vous percevez :
- Des frais de trading : un pourcentage de chaque swap réalisé dans la fourchette de prix de votre position
- Des récompenses : des jetons d'incitation optionnels distribués par le pool aux LP (jusqu'à 3 jetons de récompense différents par pool)
Pour interagir par programmation avec les positions Whirlpool depuis votre propre programme Solana, Orca fournit la crate whirlpool_cpi, qui permet des Cross-Program Invocations (CPI) vers le programme Whirlpool.
L'étape critique : mettre à jour frais et récompenses
Avant de plonger dans la logique de collecte, un concept essentiel : vous devez appeler l'instruction update_fees_and_rewards avant de collecter, sans quoi vous recevrez 0 jeton.
Les positions Whirlpool stockent les frais et récompenses accumulés sous forme de « points de contrôle » qui suivent la croissance depuis la dernière mise à jour. Le compte de position contient notamment :
- fee_growth_checkpoint_a / fee_growth_checkpoint_b : suivent l'accumulation des frais
- fee_owed_a / fee_owed_b : les montants de frais réellement réclamables
- reward_infos[].growth_inside_checkpoint : suit l'accumulation des récompenses par jeton
- reward_infos[].amount_owed : les montants de récompenses réellement réclamables
L'instruction update_fees_and_rewards lit la croissance globale des frais et récompenses depuis le pool et les tick arrays, calcule ce qui revient à votre position depuis le dernier point de contrôle, et met à jour les champs fee_owed et amount_owed. Sans cet appel préalable, ces champs conservent leurs valeurs précédentes, souvent zéro si vous ne les avez jamais mis à jour.
Le CPI de mise à jour des frais et récompenses
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(()) }
Les tick arrays sont nécessaires parce que la croissance des frais et récompenses est suivie au niveau des ticks : l'instruction doit lire les valeurs de croissance courantes sur les ticks qui bornent votre position.
Collecter les frais de trading
Les frais de trading s'accumulent dans les deux jetons du pool (Token A et Token B). Lorsqu'un swap intervient dans la fourchette de prix de votre position, une part est attribuée aux LP au prorata de leur part de liquidité.
Le CPI de collecte des frais
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(()) }
Le handler complet de collecte des frais
Voici comment assembler le tout dans un handler d'instruction Anchor :
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(()) }
Côté client : calculer les frais attendus
Avant d'appeler votre instruction de collecte, vous devriez calculer les frais attendus à l'aide du SDK Orca :
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)
Collecter les récompenses
Un Whirlpool peut avoir jusqu'à 3 jetons de récompense configurés par l'opérateur du pool. Ils sont stockés dans le tableau rewardInfos du compte Whirlpool. Chaque récompense comporte :
- mint : l'adresse du mint du jeton de récompense
- vault : le vault qui détient les jetons de récompense
- emissions_per_second : le taux d'émission de la récompense
Comprendre l'index de récompense
La clé de la collecte, c'est le paramètre reward_index (0, 1 ou 2). Vous devez :
-
Vérifier quels emplacements de récompense sont actifs en examinant pool_data.rewardInfos
-
Collecter chaque récompense active séparément, avec l'index correspondant
-
Utiliser les bons mint et vault issus de rewardInfos[index]
Le CPI de collecte des récompenses
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(()) }
Le handler complet de collecte des récompenses
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(()) }
Côté client : collecter les récompenses par 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() }
En résumé
Collecter frais et récompenses sur des positions Orca Whirlpool suppose de maîtriser un enchaînement précis :
- Appelez toujours update_fees_and_rewards en premier : cette instruction calcule et enregistre vos frais et récompenses en attente à partir de l'état courant du pool. Sans elle, collect_fees et collect_reward transfèrent 0 jeton.
- Les frais se collectent dans les deux jetons du pool : collect_fees vous rend Token A et Token B simultanément.
- Les récompenses se collectent par index : vérifiez whirlpool.rewardInfos[0..2] pour savoir quels emplacements sont actifs, puis appelez collect_reward pour chaque index actif, avec le mint et le vault correspondants.
- Utilisez le SDK Orca pour les estimations : calculez les montants attendus côté client avec collectFeesQuote et collectRewardsQuote avant de soumettre vos transactions.
La crate whirlpool_cpi rend ces CPI très simples, mais l'étape de mise à jour est le détail critique, facile à oublier, et qui se traduit silencieusement par des transferts nuls si on l'omet.

