La guía para cobrar comisiones y recompensas de posiciones Orca Whirlpool
Orca Whirlpools es un protocolo AMM (Automated Market Maker, creador de mercado automatizado) de liquidez concentrada en Solana. A diferencia de los AMM tradicionales de producto constante, en los que la liquidez se reparte de forma uniforme entre todos los precios, Whirlpools permite a los proveedores de liquidez (LP) concentrar su capital en rangos de precio concretos, lo que se traduce en una eficiencia de capital considerablemente mayor.
Cuando aportas liquidez a un Whirlpool, recibes:
- Comisiones de trading: un porcentaje de cada swap que se produce dentro del rango de precios de tu posición
- Recompensas: tokens de incentivo opcionales que el pool distribuye a los LP (hasta 3 tokens de recompensa distintos por pool)
Para interactuar de forma programática con posiciones de Whirlpool desde tu propio programa de Solana, Orca ofrece el crate whirlpool_cpi, que permite hacer Cross-Program Invocations (CPI) al programa Whirlpool.
El paso crítico: actualizar comisiones y recompensas
Antes de entrar en la lógica de cobro, hay un concepto crucial que debes entender: tienes que llamar a la instrucción update_fees_and_rewards antes de cobrar, o recibirás 0 tokens.
Las posiciones de Whirlpool almacenan las comisiones y recompensas acumuladas como "checkpoints" (puntos de control) que registran el crecimiento desde la última actualización. La cuenta de la posición contiene campos como:
- fee_growth_checkpoint_a / fee_growth_checkpoint_b: registran la acumulación de comisiones
- fee_owed_a / fee_owed_b: los importes de comisiones que realmente puedes reclamar
- reward_infos[].growth_inside_checkpoint: registra la acumulación de recompensas por cada token de recompensa
- reward_infos[].amount_owed: los importes de recompensas que realmente puedes reclamar
La instrucción update_fees_and_rewards lee el crecimiento global actual de comisiones y recompensas desde el pool y los tick arrays, calcula lo que se le debe a tu posición desde el último checkpoint y actualiza los campos fee_owed y amount_owed. Si no la llamas primero, esos campos conservan sus valores anteriores, que a menudo son cero si nunca los has actualizado.
La CPI de actualización de comisiones y recompensas
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(())
}Los tick arrays son necesarios porque el crecimiento de comisiones y recompensas se registra a nivel de tick, y la instrucción necesita leer los valores de crecimiento actuales de los ticks que delimitan tu posición.
Cobrar las comisiones de trading
Las comisiones de trading se acumulan en los dos tokens del pool (Token A y Token B). Cuando se producen swaps dentro del rango de precios de tu posición, una parte de cada swap se asigna a los LP en proporción a su participación en la liquidez.
La CPI de cobro de comisiones
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(())
}Handler completo de cobro de comisiones
Así se combina todo en un handler de instrucción de 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(())
}Lado del cliente: calcular las comisiones esperadas
Antes de llamar a tu instrucción de cobro de comisiones, conviene calcular las comisiones esperadas con el SDK de 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)Cobrar las recompensas
Los Whirlpools pueden tener hasta 3 tokens de recompensa configurados por el operador del pool. Se almacenan en el array rewardInfos de la cuenta Whirlpool. Cada recompensa tiene:
- mint: la dirección del mint del token de recompensa
- vault: el vault que contiene los tokens de recompensa
- emissions_per_second: la tasa de emisión de la recompensa
Entender el índice de recompensa
La clave para cobrar las recompensas es el parámetro reward_index (0, 1 o 2). Debes:
-
Comprobar qué slots de recompensa están activos examinando pool_data.rewardInfos
-
Cobrar cada recompensa activa por separado, con su índice correspondiente
-
Usar el mint y el vault correctos de rewardInfos[index]
La CPI de cobro de recompensas
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(())
}Handler completo de cobro de recompensas
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(())
}Lado del cliente: cobrar recompensas por índice
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()
}Resumen
Cobrar las comisiones y recompensas de posiciones Orca Whirlpool exige entender un flujo de trabajo clave:
- Llama siempre primero a update_fees_and_rewards: esta instrucción calcula y registra tus comisiones y recompensas pendientes en función del estado actual del pool. Sin ella, collect_fees y collect_reward transferirán 0 tokens.
- Las comisiones se cobran en los dos tokens del pool: usa collect_fees para recibir Token A y Token B a la vez.
- Las recompensas se cobran por índice: revisa whirlpool.rewardInfos[0..2] para ver qué slots de recompensa están activos y luego llama a collect_reward para cada índice activo con el mint y el vault correspondientes.
- Usa el SDK de Orca para las estimaciones: calcula en el lado del cliente los importes esperados con collectFeesQuote y collectRewardsQuote antes de enviar las transacciones.
El crate whirlpool_cpi hace que estas CPI sean sencillas, pero el paso de actualización es el detalle crítico que resulta fácil pasar por alto, y si lo omites, el resultado serán transferencias de cero tokens sin ningún aviso.


