Skip to main content

fusionamm_sdk/
decrease_liquidity.rs

1//
2// Copyright (c) Cryptic Dot
3//
4// Modification based on Orca Whirlpools (https://github.com/orca-so/whirlpools),
5// originally licensed under the Apache License, Version 2.0, prior to February 26, 2025.
6//
7// Modifications licensed under FusionAMM SDK Source-Available License v1.0
8// See the LICENSE file in the project root for license information.
9//
10
11use crate::{
12    token::{get_current_transfer_fee, prepare_token_accounts_instructions, TokenAccountStrategy},
13    FUNDER, SLIPPAGE_TOLERANCE_BPS,
14};
15use fusionamm_client::{get_position_address, get_tick_array_address, FusionPool, Position, TickArray};
16use fusionamm_client::{ClosePosition, CollectFees, CollectFeesInstructionArgs, DecreaseLiquidity, DecreaseLiquidityInstructionArgs};
17use fusionamm_core::{
18    collect_fees_quote, decrease_liquidity_quote, decrease_liquidity_quote_a, decrease_liquidity_quote_b, get_tick_array_start_tick_index,
19    get_tick_index_in_array, CollectFeesQuote, DecreaseLiquidityQuote,
20};
21use solana_client::nonblocking::rpc_client::RpcClient;
22use solana_instruction::Instruction;
23use solana_keypair::Keypair;
24use solana_pubkey::Pubkey;
25use spl_associated_token_account::get_associated_token_address_with_program_id;
26use std::{collections::HashSet, error::Error};
27// TODO: support transfer hooks
28
29/// Represents the parameters for decreasing liquidity in a pool.
30///
31/// You must specify only one of the parameters (`TokenA`, `TokenB`, or `Liquidity`).
32/// Based on the provided value, the SDK computes the other two parameters.
33#[derive(Debug, Clone)]
34pub enum DecreaseLiquidityParam {
35    /// Specifies the amount of Token A to withdraw.
36    TokenA(u64),
37    /// Specifies the amount of Token B to withdraw.
38    TokenB(u64),
39    /// Specifies the amount of liquidity to decrease.
40    Liquidity(u128),
41}
42
43/// Represents the instructions and quote for decreasing liquidity in a position.
44#[derive(Debug)]
45pub struct DecreaseLiquidityInstruction {
46    /// The quote details for decreasing liquidity, including:
47    /// - The liquidity delta.
48    /// - Estimated amounts of Token A and Token B to withdraw.
49    /// - Minimum token amounts based on the specified slippage tolerance.
50    pub quote: DecreaseLiquidityQuote,
51
52    /// A vector of Solana instructions required to execute the decrease liquidity operation.
53    pub instructions: Vec<Instruction>,
54
55    /// A vector of `Keypair` objects representing additional signers required for the instructions.
56    pub additional_signers: Vec<Keypair>,
57}
58
59#[cfg(not(doctest))]
60/// Generates instructions to decrease liquidity from an existing position.
61///
62/// This function computes the necessary quote and creates Solana instructions to reduce liquidity
63/// from an existing pool position, specified by the position's mint address.
64///
65/// # Arguments
66///
67/// * `rpc` - A reference to a Solana RPC client for fetching necessary accounts and pool data.
68/// * `position_mint_address` - The public key of the NFT mint address representing the pool position.
69/// * `param` - A variant of `DecreaseLiquidityParam` specifying the liquidity reduction method (by Token A, Token B, or liquidity amount).
70/// * `slippage_tolerance_bps` - An optional slippage tolerance in basis points. Defaults to the global slippage tolerance if not provided.
71/// * `authority` - An optional public key of the account authorizing the liquidity removal. Defaults to the global funder if not provided.
72///
73/// # Returns
74///
75/// A `Result` containing `DecreaseLiquidityInstruction` on success:
76///
77/// * `quote` - The computed quote for decreasing liquidity, including liquidity delta, token estimates, and minimum tokens.
78/// * `instructions` - A vector of `Instruction` objects required to execute the decrease liquidity operation.
79/// * `additional_signers` - A vector of `Keypair` objects representing additional signers required for the instructions.
80///
81/// # Errors
82///
83/// This function will return an error if:
84/// - The `authority` account is invalid or missing.
85/// - The position or token mint accounts are not found or have invalid data.
86/// - Any RPC request to the blockchain fails.
87///
88/// # Example
89/// ```rust
90/// use fusionamm_sdk::{
91///     decrease_liquidity_instructions, DecreaseLiquidityParam
92/// };
93/// use solana_client::nonblocking::rpc_client::RpcClient;
94/// use solana_pubkey::pubkey;
95/// use solana_keypair::Keypair;
96/// use solana_signer::Signer;
97///
98/// #[tokio::main]
99/// async fn main() {
100///     let rpc = RpcClient::new("https://api.devnet.solana.com".to_string());
101///     let wallet = Keypair::new(); // Load your wallet
102///     let position_mint_address = pubkey!("HqoV7Qv27REUtmd9UKSJGGmCRNx3531t33bDG1BUfo9K");
103///     let param = DecreaseLiquidityParam::TokenA(1_000_000);
104///     let result = decrease_liquidity_instructions(
105///         &rpc,
106///         position_mint_address,
107///         param,
108///         Some(100),
109///         Some(wallet.pubkey()),
110///     )
111///     .await.unwrap();
112///     println!("Liquidity Increase Quote: {:?}", result.quote);
113///     println!("Number of Instructions: {}", result.instructions.len());
114/// }
115/// ```
116pub async fn decrease_liquidity_instructions(
117    rpc: &RpcClient,
118    position_mint_address: Pubkey,
119    param: DecreaseLiquidityParam,
120    slippage_tolerance_bps: Option<u16>,
121    authority: Option<Pubkey>,
122) -> Result<DecreaseLiquidityInstruction, Box<dyn Error>> {
123    let slippage_tolerance_bps = slippage_tolerance_bps.unwrap_or(*SLIPPAGE_TOLERANCE_BPS.try_lock()?);
124    let authority = authority.unwrap_or(*FUNDER.try_lock()?);
125    if authority == Pubkey::default() {
126        return Err("Authority must be provided".into());
127    }
128
129    let position_address = get_position_address(&position_mint_address)?.0;
130    let position_info = rpc.get_account(&position_address).await?;
131    let position = Position::from_bytes(&position_info.data)?;
132
133    let pool_info = rpc.get_account(&position.fusion_pool).await?;
134    let pool = FusionPool::from_bytes(&pool_info.data)?;
135
136    let mint_infos = rpc
137        .get_multiple_accounts(&[pool.token_mint_a, pool.token_mint_b, position_mint_address])
138        .await?;
139
140    let mint_a_info = mint_infos[0].as_ref().ok_or("Token A mint info not found")?;
141    let mint_b_info = mint_infos[1].as_ref().ok_or("Token B mint info not found")?;
142    let position_mint_info = mint_infos[2].as_ref().ok_or("Position mint info not found")?;
143
144    let current_epoch = rpc.get_epoch_info().await?.epoch;
145    let transfer_fee_a = get_current_transfer_fee(Some(mint_a_info), current_epoch);
146    let transfer_fee_b = get_current_transfer_fee(Some(mint_b_info), current_epoch);
147
148    let quote = match param {
149        DecreaseLiquidityParam::TokenA(amount) => decrease_liquidity_quote_a(
150            amount,
151            slippage_tolerance_bps,
152            pool.sqrt_price,
153            position.tick_lower_index,
154            position.tick_upper_index,
155            transfer_fee_a,
156            transfer_fee_b,
157        ),
158        DecreaseLiquidityParam::TokenB(amount) => decrease_liquidity_quote_b(
159            amount,
160            slippage_tolerance_bps,
161            pool.sqrt_price,
162            position.tick_lower_index,
163            position.tick_upper_index,
164            transfer_fee_a,
165            transfer_fee_b,
166        ),
167        DecreaseLiquidityParam::Liquidity(amount) => decrease_liquidity_quote(
168            amount,
169            slippage_tolerance_bps,
170            pool.sqrt_price,
171            position.tick_lower_index,
172            position.tick_upper_index,
173            transfer_fee_a,
174            transfer_fee_b,
175        ),
176    }?;
177
178    let mut instructions: Vec<Instruction> = Vec::new();
179
180    let lower_tick_array_start_index = get_tick_array_start_tick_index(position.tick_lower_index, pool.tick_spacing);
181    let upper_tick_array_start_index = get_tick_array_start_tick_index(position.tick_upper_index, pool.tick_spacing);
182
183    let position_token_account_address = get_associated_token_address_with_program_id(&authority, &position_mint_address, &position_mint_info.owner);
184    let lower_tick_array_address = get_tick_array_address(&position.fusion_pool, lower_tick_array_start_index)?.0;
185    let upper_tick_array_address = get_tick_array_address(&position.fusion_pool, upper_tick_array_start_index)?.0;
186
187    let token_accounts = prepare_token_accounts_instructions(
188        rpc,
189        authority,
190        vec![
191            TokenAccountStrategy::WithoutBalance(pool.token_mint_a),
192            TokenAccountStrategy::WithoutBalance(pool.token_mint_b),
193        ],
194    )
195    .await?;
196
197    instructions.extend(token_accounts.create_instructions);
198
199    let token_owner_account_a = token_accounts
200        .token_account_addresses
201        .get(&pool.token_mint_a)
202        .ok_or("Token A owner account not found")?;
203    let token_owner_account_b = token_accounts
204        .token_account_addresses
205        .get(&pool.token_mint_b)
206        .ok_or("Token B owner account not found")?;
207
208    instructions.push(
209        DecreaseLiquidity {
210            fusion_pool: position.fusion_pool,
211            token_program_a: mint_a_info.owner,
212            token_program_b: mint_b_info.owner,
213            memo_program: spl_memo::ID,
214            position_authority: authority,
215            position: position_address,
216            position_token_account: position_token_account_address,
217            token_mint_a: pool.token_mint_a,
218            token_mint_b: pool.token_mint_b,
219            token_owner_account_a: *token_owner_account_a,
220            token_owner_account_b: *token_owner_account_b,
221            token_vault_a: pool.token_vault_a,
222            token_vault_b: pool.token_vault_b,
223            tick_array_lower: lower_tick_array_address,
224            tick_array_upper: upper_tick_array_address,
225        }
226        .instruction(DecreaseLiquidityInstructionArgs {
227            liquidity_amount: quote.liquidity_delta,
228            token_min_a: quote.token_min_a,
229            token_min_b: quote.token_min_b,
230            remaining_accounts_info: None,
231        }),
232    );
233
234    instructions.extend(token_accounts.cleanup_instructions);
235
236    Ok(DecreaseLiquidityInstruction {
237        quote,
238        instructions,
239        additional_signers: token_accounts.additional_signers,
240    })
241}
242
243/// Represents the instructions and quotes for closing a liquidity position.
244///
245/// This struct contains the instructions required to close a position, along with detailed
246/// information about the liquidity decrease, available fees to collect, and available rewards to collect.
247#[derive(Debug)]
248pub struct ClosePositionInstruction {
249    /// A vector of `Instruction` objects required to execute the position closure.
250    pub instructions: Vec<Instruction>,
251
252    /// A vector of `Keypair` objects representing additional signers required for the instructions.
253    pub additional_signers: Vec<Keypair>,
254
255    /// The computed quote for decreasing liquidity, including liquidity delta, token estimates, and minimum tokens.
256    pub quote: DecreaseLiquidityQuote,
257
258    /// Details of the fees available to collect from the position:
259    /// - `fee_owed_a` - The amount of fees available to collect in token A.
260    /// - `fee_owed_b` - The amount of fees available to collect in token B.
261    pub fees_quote: CollectFeesQuote,
262}
263
264#[cfg(not(doctest))]
265/// Generates instructions to close a liquidity position.
266///
267/// This function collects all fees and rewards, removes any remaining liquidity, and closes
268/// the position. It returns the necessary instructions, quotes for fees and rewards, and the
269/// liquidity quote for the closed position.
270///
271/// # Arguments
272///
273/// * `rpc` - A reference to a Solana RPC client for fetching accounts and pool data.
274/// * `position_mint_address` - The public key of the NFT mint address representing the position to be closed.
275/// * `slippage_tolerance_bps` - An optional slippage tolerance in basis points. Defaults to the global slippage tolerance if not provided.
276/// * `authority` - An optional public key of the account authorizing the transaction. Defaults to the global funder if not provided.
277///
278/// # Returns
279///
280/// A `Result` containing `ClosePositionInstruction` on success:
281///
282/// * `instructions` - A vector of `Instruction` objects required to execute the position closure.
283/// * `additional_signers` - A vector of `Keypair` objects representing additional signers required for the instructions.
284/// * `quote` - The computed quote for decreasing liquidity, including liquidity delta, token estimates, and minimum tokens.
285/// * `fees_quote` - Details of the fees available to collect from the position:
286///   - `fee_owed_a` - The amount of fees available to collect in token A.
287///   - `fee_owed_b` - The amount of fees available to collect in token B.
288///
289/// # Errors
290///
291/// This function will return an error if:
292/// - The `authority` account is invalid or missing.
293/// - The position, token mint, or reward accounts are not found or have invalid data.
294/// - Any RPC request to the blockchain fails.
295///
296/// # Example
297///
298/// ```rust
299/// use fusionamm_sdk::close_position_instructions;
300/// use solana_client::nonblocking::rpc_client::RpcClient;
301/// use solana_pubkey::pubkey;
302/// use solana_keypair::Keypair;
303/// use solana_signer::Signer;
304///
305/// #[tokio::main]
306/// async fn main() {
307///     let rpc = RpcClient::new("https://api.mainnet.solana.com".to_string());
308///     let wallet = Keypair::new(); // Load your wallet here
309///
310///     let position_mint_address = pubkey!("HqoV7Qv27REUtmd9UKSJGGmCRNx3531t33bDG1BUfo9K");
311///
312///     let result = close_position_instructions(
313///         &rpc,
314///         position_mint_address,
315///         Some(100),
316///         Some(wallet.pubkey()),
317///     )
318///     .await
319///     .unwrap();
320///
321///     println!("Quote token max B: {:?}", result.quote.token_est_b);
322///     println!("Fees Quote: {:?}", result.fees_quote);
323///     println!("Number of Instructions: {}", result.instructions.len());
324/// }
325/// ```
326pub async fn close_position_instructions(
327    rpc: &RpcClient,
328    position_mint_address: Pubkey,
329    slippage_tolerance_bps: Option<u16>,
330    authority: Option<Pubkey>,
331) -> Result<ClosePositionInstruction, Box<dyn Error>> {
332    let slippage_tolerance_bps = slippage_tolerance_bps.unwrap_or(*SLIPPAGE_TOLERANCE_BPS.try_lock()?);
333    let authority = authority.unwrap_or(*FUNDER.try_lock()?);
334    if authority == Pubkey::default() {
335        return Err("Authority must be provided".into());
336    }
337
338    let position_address = get_position_address(&position_mint_address)?.0;
339    let position_info = rpc.get_account(&position_address).await?;
340    let position = Position::from_bytes(&position_info.data)?;
341
342    let pool_info = rpc.get_account(&position.fusion_pool).await?;
343    let pool = FusionPool::from_bytes(&pool_info.data)?;
344
345    let mint_infos = rpc
346        .get_multiple_accounts(&[pool.token_mint_a, pool.token_mint_b, position_mint_address])
347        .await?;
348
349    let mint_a_info = mint_infos[0].as_ref().ok_or("Token A mint info not found")?;
350    let mint_b_info = mint_infos[1].as_ref().ok_or("Token B mint info not found")?;
351    let position_mint_info = mint_infos[2].as_ref().ok_or("Position mint info not found")?;
352
353    let current_epoch = rpc.get_epoch_info().await?.epoch;
354    let transfer_fee_a = get_current_transfer_fee(Some(mint_a_info), current_epoch);
355    let transfer_fee_b = get_current_transfer_fee(Some(mint_b_info), current_epoch);
356
357    let quote = decrease_liquidity_quote(
358        position.liquidity,
359        slippage_tolerance_bps,
360        pool.sqrt_price,
361        position.tick_lower_index,
362        position.tick_upper_index,
363        transfer_fee_a,
364        transfer_fee_b,
365    )?;
366
367    let lower_tick_array_start_index = get_tick_array_start_tick_index(position.tick_lower_index, pool.tick_spacing);
368    let upper_tick_array_start_index = get_tick_array_start_tick_index(position.tick_upper_index, pool.tick_spacing);
369
370    let position_token_account_address = get_associated_token_address_with_program_id(&authority, &position_mint_address, &position_mint_info.owner);
371    let lower_tick_array_address = get_tick_array_address(&position.fusion_pool, lower_tick_array_start_index)?.0;
372    let upper_tick_array_address = get_tick_array_address(&position.fusion_pool, upper_tick_array_start_index)?.0;
373
374    let tick_array_infos = rpc.get_multiple_accounts(&[lower_tick_array_address, upper_tick_array_address]).await?;
375
376    let lower_tick_array_info = tick_array_infos[0].as_ref().ok_or("Lower tick array info not found")?;
377    let lower_tick_array = TickArray::from_bytes(&lower_tick_array_info.data)?;
378    let lower_tick =
379        &lower_tick_array.ticks[get_tick_index_in_array(position.tick_lower_index, lower_tick_array_start_index, pool.tick_spacing)? as usize];
380
381    let upper_tick_array_info = tick_array_infos[1].as_ref().ok_or("Upper tick array info not found")?;
382    let upper_tick_array = TickArray::from_bytes(&upper_tick_array_info.data)?;
383    let upper_tick =
384        &upper_tick_array.ticks[get_tick_index_in_array(position.tick_upper_index, upper_tick_array_start_index, pool.tick_spacing)? as usize];
385
386    let fees_quote = collect_fees_quote(
387        pool.clone().into(),
388        position.clone().into(),
389        lower_tick.clone().into(),
390        upper_tick.clone().into(),
391        transfer_fee_a,
392        transfer_fee_b,
393    )?;
394
395    let mut required_mints: HashSet<TokenAccountStrategy> = HashSet::new();
396
397    if quote.liquidity_delta > 0 || fees_quote.fee_owed_a > 0 || fees_quote.fee_owed_b > 0 {
398        required_mints.insert(TokenAccountStrategy::WithoutBalance(pool.token_mint_a));
399        required_mints.insert(TokenAccountStrategy::WithoutBalance(pool.token_mint_b));
400    }
401
402    let token_accounts = prepare_token_accounts_instructions(rpc, authority, required_mints.into_iter().collect()).await?;
403
404    let mut instructions: Vec<Instruction> = Vec::new();
405    instructions.extend(token_accounts.create_instructions);
406
407    let token_owner_account_a = token_accounts
408        .token_account_addresses
409        .get(&pool.token_mint_a)
410        .ok_or("Token A owner account not found")?;
411    let token_owner_account_b = token_accounts
412        .token_account_addresses
413        .get(&pool.token_mint_b)
414        .ok_or("Token B owner account not found")?;
415
416    if quote.liquidity_delta > 0 {
417        instructions.push(
418            DecreaseLiquidity {
419                fusion_pool: position.fusion_pool,
420                token_program_a: mint_a_info.owner,
421                token_program_b: mint_b_info.owner,
422                memo_program: spl_memo::ID,
423                position_authority: authority,
424                position: position_address,
425                position_token_account: position_token_account_address,
426                token_mint_a: pool.token_mint_a,
427                token_mint_b: pool.token_mint_b,
428                token_owner_account_a: *token_owner_account_a,
429                token_owner_account_b: *token_owner_account_b,
430                token_vault_a: pool.token_vault_a,
431                token_vault_b: pool.token_vault_b,
432                tick_array_lower: lower_tick_array_address,
433                tick_array_upper: upper_tick_array_address,
434            }
435            .instruction(DecreaseLiquidityInstructionArgs {
436                liquidity_amount: quote.liquidity_delta,
437                token_min_a: quote.token_min_a,
438                token_min_b: quote.token_min_b,
439                remaining_accounts_info: None,
440            }),
441        );
442    }
443
444    if fees_quote.fee_owed_a > 0 || fees_quote.fee_owed_b > 0 {
445        instructions.push(
446            CollectFees {
447                fusion_pool: position.fusion_pool,
448                position_authority: authority,
449                position: position_address,
450                position_token_account: position_token_account_address,
451                token_owner_account_a: *token_owner_account_a,
452                token_owner_account_b: *token_owner_account_b,
453                token_vault_a: pool.token_vault_a,
454                token_vault_b: pool.token_vault_b,
455                token_mint_a: pool.token_mint_a,
456                token_mint_b: pool.token_mint_b,
457                token_program_a: mint_a_info.owner,
458                token_program_b: mint_b_info.owner,
459                memo_program: spl_memo::ID,
460            }
461            .instruction(CollectFeesInstructionArgs {
462                remaining_accounts_info: None,
463            }),
464        );
465    }
466
467    match position_mint_info.owner {
468        spl_token_2022::ID => {
469            instructions.push(
470                ClosePosition {
471                    position_authority: authority,
472                    position: position_address,
473                    position_token_account: position_token_account_address,
474                    position_mint: position_mint_address,
475                    receiver: authority,
476                    token2022_program: spl_token_2022::ID,
477                }
478                .instruction(),
479            );
480        }
481        _ => {
482            return Err("Unsupported token program".into());
483        }
484    }
485
486    instructions.extend(token_accounts.cleanup_instructions);
487
488    Ok(ClosePositionInstruction {
489        instructions,
490        additional_signers: token_accounts.additional_signers,
491        quote,
492        fees_quote,
493    })
494}
495
496#[cfg(test)]
497mod tests {
498    use std::collections::HashMap;
499    use std::error::Error;
500
501    use rstest::rstest;
502    use serial_test::serial;
503    use solana_client::nonblocking::rpc_client::RpcClient;
504    use solana_keypair::Keypair;
505    use solana_program::program_pack::Pack;
506    use solana_program_test::tokio;
507    use solana_pubkey::Pubkey;
508    use solana_signer::Signer;
509    use spl_token::state::Account as TokenAccount;
510    use spl_token_2022::{extension::StateWithExtensionsOwned, state::Account as TokenAccount2022, ID as TOKEN_2022_PROGRAM_ID};
511
512    use crate::tests::setup_position;
513    use crate::{
514        close_position_instructions, decrease_liquidity_instructions, increase_liquidity_instructions, swap_instructions,
515        tests::{
516            setup_ata_te, setup_ata_with_amount, setup_fusion_pool, setup_mint_te, setup_mint_te_fee, setup_mint_with_decimals, RpcContext,
517            SetupAtaConfig,
518        },
519        DecreaseLiquidityParam, IncreaseLiquidityParam, SwapType,
520    };
521    use fusionamm_client::{get_position_address, Position};
522
523    async fn get_token_balance(rpc: &RpcClient, address: Pubkey) -> Result<u64, Box<dyn Error>> {
524        let account_data = rpc.get_account(&address).await?;
525        if account_data.owner == TOKEN_2022_PROGRAM_ID {
526            let parsed = StateWithExtensionsOwned::<TokenAccount2022>::unpack(account_data.data)?;
527            Ok(parsed.base.amount)
528        } else {
529            let parsed = TokenAccount::unpack(&account_data.data)?;
530            Ok(parsed.amount)
531        }
532    }
533
534    async fn maybe_fetch_position(rpc: &RpcClient, position_pubkey: Pubkey) -> Result<Option<Position>, Box<dyn Error>> {
535        match rpc.get_account(&position_pubkey).await {
536            Ok(acc) => {
537                let p = Position::from_bytes(&acc.data)?;
538                Ok(Some(p))
539            }
540            Err(_) => Ok(None),
541        }
542    }
543
544    async fn fetch_position(rpc: &RpcClient, position_pubkey: Pubkey) -> Result<Position, Box<dyn Error>> {
545        let account = rpc.get_account(&position_pubkey).await?;
546        Ok(Position::from_bytes(&account.data)?)
547    }
548
549    async fn verify_decrease_liquidity(
550        ctx: &RpcContext,
551        decrease_ix: &crate::DecreaseLiquidityInstruction,
552        token_a_account: Pubkey,
553        token_b_account: Pubkey,
554        position_mint: Pubkey,
555    ) -> Result<(), Box<dyn Error>> {
556        // pre
557        let before_a = get_token_balance(&ctx.rpc, token_a_account).await?;
558        let before_b = get_token_balance(&ctx.rpc, token_b_account).await?;
559
560        // send
561        let signers: Vec<&Keypair> = decrease_ix.additional_signers.iter().collect();
562        ctx.send_transaction_with_signers(decrease_ix.instructions.clone(), signers).await?;
563
564        // post
565        let after_a = get_token_balance(&ctx.rpc, token_a_account).await?;
566        let after_b = get_token_balance(&ctx.rpc, token_b_account).await?;
567        let gained_a = after_a.saturating_sub(before_a);
568        let gained_b = after_b.saturating_sub(before_b);
569
570        // check quote
571        let quote = &decrease_ix.quote;
572        assert!(
573            gained_a >= quote.token_min_a && gained_a <= quote.token_est_a,
574            "Token A gain out of range: gained={}, expected={}..{}",
575            gained_a,
576            quote.token_min_a,
577            quote.token_est_a
578        );
579        assert!(
580            gained_b >= quote.token_min_b && gained_b <= quote.token_est_b,
581            "Token B gain out of range: gained={}, expected={}..{}",
582            gained_b,
583            quote.token_min_b,
584            quote.token_est_b
585        );
586
587        // confirm on-chain liquidity updated
588        let position_pubkey = get_position_address(&position_mint)?.0;
589        let position_data = fetch_position(&ctx.rpc, position_pubkey).await?;
590        assert_eq!(
591            position_data.liquidity, quote.liquidity_delta,
592            "Position liquidity mismatch! expected={}, got={}",
593            quote.liquidity_delta, position_data.liquidity
594        );
595
596        Ok(())
597    }
598
599    async fn setup_all_mints(ctx: &RpcContext) -> Result<HashMap<&'static str, Pubkey>, Box<dyn Error>> {
600        let mint_a = setup_mint_with_decimals(ctx, 9).await?;
601        let mint_b = setup_mint_with_decimals(ctx, 9).await?;
602        let mint_te_a = setup_mint_te(ctx, &[]).await?;
603        let mint_te_b = setup_mint_te(ctx, &[]).await?;
604        let mint_tefee = setup_mint_te_fee(ctx).await?;
605
606        let mut out = HashMap::new();
607        out.insert("A", mint_a);
608        out.insert("B", mint_b);
609        out.insert("TEA", mint_te_a);
610        out.insert("TEB", mint_te_b);
611        out.insert("TEFee", mint_tefee);
612        Ok(out)
613    }
614
615    async fn setup_all_atas(ctx: &RpcContext, minted: &HashMap<&'static str, Pubkey>) -> Result<HashMap<&'static str, Pubkey>, Box<dyn Error>> {
616        let token_balance = 1_000_000;
617        let ata_a = setup_ata_with_amount(ctx, minted["A"], token_balance).await?;
618        let ata_b = setup_ata_with_amount(ctx, minted["B"], token_balance).await?;
619        let ata_te_a = setup_ata_te(ctx, minted["TEA"], Some(SetupAtaConfig { amount: Some(token_balance) })).await?;
620        let ata_te_b = setup_ata_te(ctx, minted["TEB"], Some(SetupAtaConfig { amount: Some(token_balance) })).await?;
621        let ata_tefee = setup_ata_te(ctx, minted["TEFee"], Some(SetupAtaConfig { amount: Some(token_balance) })).await?;
622
623        let mut out = HashMap::new();
624        out.insert("A", ata_a);
625        out.insert("B", ata_b);
626        out.insert("TEA", ata_te_a);
627        out.insert("TEB", ata_te_b);
628        out.insert("TEFee", ata_tefee);
629        Ok(out)
630    }
631
632    fn parse_pool_name(pool_name: &str) -> (&'static str, &'static str) {
633        match pool_name {
634            "A-B" => ("A", "B"),
635            "A-TEA" => ("A", "TEA"),
636            "TEA-TEB" => ("TEA", "TEB"),
637            "A-TEFee" => ("A", "TEFee"),
638            _ => panic!("Unknown combo: {}", pool_name),
639        }
640    }
641
642    #[rstest]
643    #[case("A-B",    "equally centered", -100, 100)]
644    #[case("A-B",    "one sided A",      -100, -1)]
645    #[case("A-B", "one sided B", 1, 100)]
646    #[case("A-TEA",  "equally centered", -100, 100)]
647    #[case("A-TEA",  "one sided A",      -100, -1)]
648    #[case("A-TEA", "one sided B", 1, 100)]
649    #[case("TEA-TEB","equally centered", -100, 100)]
650    #[case("TEA-TEB","one sided A",      -100, -1)]
651    #[case("TEA-TEB", "one sided B", 1, 100)]
652    #[case("A-TEFee","equally centered", -100, 100)]
653    #[case("A-TEFee","one sided A",      -100, -1)]
654    #[case("A-TEFee", "one sided B", 1, 100)]
655    #[serial]
656    fn test_decrease_liquidity_cases(#[case] pool_name: &str, #[case] _position_name: &str, #[case] lower_tick: i32, #[case] upper_tick: i32) {
657        let rt = tokio::runtime::Runtime::new().unwrap();
658        rt.block_on(async {
659            let ctx = RpcContext::new().await;
660
661            let minted = setup_all_mints(&ctx).await.unwrap();
662            let user_atas = setup_all_atas(&ctx, &minted).await.unwrap();
663
664            let (mkey_a, mkey_b) = parse_pool_name(pool_name);
665            let pubkey_a = minted[mkey_a];
666            let pubkey_b = minted[mkey_b];
667
668            let swapped = pubkey_a > pubkey_b;
669            let (final_a, final_b) = if pubkey_a < pubkey_b {
670                (pubkey_a, pubkey_b)
671            } else {
672                (pubkey_b, pubkey_a)
673            };
674
675            let tick_spacing = 64;
676            let fee_rate = 300;
677            let pool_pubkey = setup_fusion_pool(&ctx, final_a, final_b, tick_spacing, fee_rate).await.unwrap();
678
679            let position_mint = setup_position(&ctx, pool_pubkey, Some((lower_tick, upper_tick)), None).await.unwrap();
680
681            let inc_ix = increase_liquidity_instructions(
682                &ctx.rpc,
683                position_mint,
684                IncreaseLiquidityParam::Liquidity(100_000),
685                Some(100),
686                Some(ctx.signer.pubkey()),
687            )
688            .await
689            .unwrap();
690            ctx.send_transaction_with_signers(inc_ix.instructions, vec![]).await.unwrap();
691
692            let dec_ix = decrease_liquidity_instructions(
693                &ctx.rpc,
694                position_mint,
695                DecreaseLiquidityParam::Liquidity(50_000),
696                Some(100),
697                Some(ctx.signer.pubkey()),
698            )
699            .await
700            .unwrap();
701
702            let user_ata_for_token_a = if swapped { user_atas[mkey_b] } else { user_atas[mkey_a] };
703            let user_ata_for_token_b = if swapped { user_atas[mkey_a] } else { user_atas[mkey_b] };
704
705            verify_decrease_liquidity(&ctx, &dec_ix, user_ata_for_token_a, user_ata_for_token_b, position_mint)
706                .await
707                .unwrap();
708        });
709    }
710
711    #[rstest]
712    #[case("A-B",    "equally centered", -100, 100)]
713    #[case("A-B",    "one sided A",      -100, -1)]
714    #[case("A-TEA",  "equally centered", -100, 100)]
715    #[case("A-TEA",  "one sided A",      -100, -1)]
716    #[case("TEA-TEB","equally centered", -100, 100)]
717    #[case("TEA-TEB","one sided A",      -100, -1)]
718    #[case("A-TEFee","equally centered", -100, 100)]
719    #[case("A-TEFee","one sided A",      -100, -1)]
720    #[tokio::test]
721    #[serial]
722    async fn test_close_position_cases(
723        #[case] pool_name: &str,
724        #[case] range_name: &str,
725        #[case] lower_tick: i32,
726        #[case] upper_tick: i32,
727    ) -> Result<(), Box<dyn Error>> {
728        let ctx = RpcContext::new().await;
729        let minted = setup_all_mints(&ctx).await?;
730        let user_atas = setup_all_atas(&ctx, &minted).await?;
731
732        let (mkey_a, mkey_b) = parse_pool_name(pool_name);
733        let pubkey_a = minted[mkey_a];
734        let pubkey_b = minted[mkey_b];
735        let swapped = pubkey_a > pubkey_b;
736        let (final_a, final_b) = if pubkey_a < pubkey_b {
737            (pubkey_a, pubkey_b)
738        } else {
739            (pubkey_b, pubkey_a)
740        };
741
742        let tick_spacing = 64;
743        let fee_rate = 300;
744        let pool_pubkey = setup_fusion_pool(&ctx, final_a, final_b, tick_spacing, fee_rate).await?;
745        let position_mint = setup_position(&ctx, pool_pubkey, Some((lower_tick, upper_tick)), None).await?;
746
747        let inc_ix = increase_liquidity_instructions(
748            &ctx.rpc,
749            position_mint,
750            IncreaseLiquidityParam::Liquidity(100_000),
751            Some(100),
752            Some(ctx.signer.pubkey()),
753        )
754        .await?;
755        ctx.send_transaction_with_signers(inc_ix.instructions, vec![]).await?;
756
757        let swap_ix = swap_instructions(&ctx.rpc, pool_pubkey, 100, final_a, SwapType::ExactIn, Some(100), Some(ctx.signer.pubkey())).await?;
758        ctx.send_transaction_with_signers(swap_ix.instructions, swap_ix.additional_signers.iter().collect())
759            .await?;
760
761        let before_a = get_token_balance(&ctx.rpc, if swapped { user_atas[mkey_b] } else { user_atas[mkey_a] }).await?;
762        let before_b = get_token_balance(&ctx.rpc, if swapped { user_atas[mkey_a] } else { user_atas[mkey_b] }).await?;
763
764        let close_ix = close_position_instructions(&ctx.rpc, position_mint, Some(100), Some(ctx.signer.pubkey())).await?;
765        let signers: Vec<&Keypair> = close_ix.additional_signers.iter().collect();
766        ctx.send_transaction_with_signers(close_ix.instructions.clone(), signers).await?;
767
768        let position_address = get_position_address(&position_mint)?.0;
769        let position_after = maybe_fetch_position(&ctx.rpc, position_address).await?;
770        assert!(position_after.is_none(), "[{} {}] position={} was not closed!", pool_name, range_name, position_mint);
771
772        let after_a = get_token_balance(&ctx.rpc, if swapped { user_atas[mkey_b] } else { user_atas[mkey_a] }).await?;
773        let after_b = get_token_balance(&ctx.rpc, if swapped { user_atas[mkey_a] } else { user_atas[mkey_b] }).await?;
774        let gained_a = after_a.saturating_sub(before_a);
775        let gained_b = after_b.saturating_sub(before_b);
776
777        let total_expected_a = close_ix.quote.token_est_a + close_ix.fees_quote.fee_owed_a;
778        let total_expected_b = close_ix.quote.token_est_b + close_ix.fees_quote.fee_owed_b;
779
780        assert_eq!(
781            gained_a, total_expected_a,
782            "[{} {}] position={} token A mismatch: gained={}, expected={}",
783            pool_name, range_name, position_mint, gained_a, total_expected_a
784        );
785        assert_eq!(
786            gained_b, total_expected_b,
787            "[{} {}] position={} token B mismatch: gained={}, expected={}",
788            pool_name, range_name, position_mint, gained_b, total_expected_b
789        );
790        Ok(())
791    }
792
793    #[tokio::test]
794    #[serial]
795    async fn test_close_position_fails_if_missing_mint() -> Result<(), Box<dyn Error>> {
796        let ctx = RpcContext::new().await;
797
798        let bogus_mint = Pubkey::new_unique();
799
800        let res = close_position_instructions(&ctx.rpc, bogus_mint, Some(100), Some(ctx.signer.pubkey())).await;
801
802        assert!(res.is_err(), "Expected error when position mint doesn't exist");
803
804        Ok(())
805    }
806}