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:

  1. Comprobar qué slots de recompensa están activos examinando pool_data.rewardInfos

  2. Cobrar cada recompensa activa por separado, con su índice correspondiente

  3. 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.

Caso de estudio relacionado

Más artículos

¿Quieres llevar tu proyecto al siguiente nivel?

Contáctanos hoy para ver cómo podemos ayudarte a alcanzar tus objetivos en el sector blockchain.