diff --git a/programs/clmm/src/instructions/decrease_liquidity.rs b/programs/clmm/src/instructions/decrease_liquidity.rs index 8bd2828..b290c0d 100644 --- a/programs/clmm/src/instructions/decrease_liquidity.rs +++ b/programs/clmm/src/instructions/decrease_liquidity.rs @@ -20,6 +20,7 @@ pub fn decrease_liquidity<'b, 'c: 'info, 'info>( tick_array_upper_info: &AccountInfo<'info>, recipient_token_account_0: &AccountInfo<'info>, recipient_token_account_1: &AccountInfo<'info>, + rent_recipient: &AccountInfo<'info>, token_program: &'b Program<'info, Token>, token_program_2022: Option>, _memo_program: Option>, @@ -89,6 +90,7 @@ pub fn decrease_liquidity<'b, 'c: 'info, 'info>( tick_array_lower_info, tick_array_upper_info, tickarray_bitmap_extension, + rent_recipient, liquidity, )?; @@ -198,6 +200,7 @@ pub fn decrease_liquidity_and_update_position<'c: 'info, 'info>( tick_array_lower_info: &AccountInfo<'info>, tick_array_upper_info: &AccountInfo<'info>, tick_array_bitmap_extension: Option<&'c AccountInfo<'info>>, + rent_recipient: &AccountInfo<'info>, liquidity: u128, ) -> Result<(u64, u64, u64, u64)> { let mut pool_state = pool_state_loader.load_mut()?; @@ -216,6 +219,7 @@ pub fn decrease_liquidity_and_update_position<'c: 'info, 'info>( tick_array_lower_info, tick_array_upper_info, tick_array_bitmap_extension, + rent_recipient, personal_position.tick_lower_index, personal_position.tick_upper_index, liquidity, @@ -273,6 +277,7 @@ pub fn burn_liquidity<'c: 'info, 'info>( tick_array_lower_info: &AccountInfo<'info>, tick_array_upper_info: &AccountInfo<'info>, tickarray_bitmap_extension: Option<&'c AccountInfo<'info>>, + rent_recipient: &AccountInfo<'info>, tick_lower_index: i32, tick_upper_index: i32, liquidity: u128, @@ -330,7 +335,8 @@ pub fn burn_liquidity<'c: 'info, 'info>( }; // Drop mutable borrows here drop(tick_arrays); // Release RefMut so realloc and re-load can access the account - // Realloc for dynamic tick arrays (shrink only, no rent refund) + // Realloc for dynamic tick arrays (shrink) and refund excess rent to caller + let rent = Rent::get()?; if is_same_array { let mut delta: i64 = 0; if result.tick_array_realloc.lower_shrink { delta -= DynamicTickData::LEN as i64; } @@ -338,19 +344,36 @@ pub fn burn_liquidity<'c: 'info, 'info>( if delta < 0 { let new_size = (tick_array_lower_info.data_len() as i64 + delta) as usize; tick_array_lower_info.realloc(new_size, true)?; + let excess = tick_array_lower_info.lamports() + .checked_sub(rent.minimum_balance(new_size)) + .unwrap_or(0); + if excess > 0 { + **tick_array_lower_info.try_borrow_mut_lamports()? -= excess; + **rent_recipient.try_borrow_mut_lamports()? += excess; + } } } else { if result.tick_array_realloc.lower_shrink { - tick_array_lower_info.realloc( - tick_array_lower_info.data_len() - DynamicTickData::LEN, - true, - )?; + let new_size = tick_array_lower_info.data_len() - DynamicTickData::LEN; + tick_array_lower_info.realloc(new_size, true)?; + let excess = tick_array_lower_info.lamports() + .checked_sub(rent.minimum_balance(new_size)) + .unwrap_or(0); + if excess > 0 { + **tick_array_lower_info.try_borrow_mut_lamports()? -= excess; + **rent_recipient.try_borrow_mut_lamports()? += excess; + } } if result.tick_array_realloc.upper_shrink { - tick_array_upper_info.realloc( - tick_array_upper_info.data_len() - DynamicTickData::LEN, - true, - )?; + let new_size = tick_array_upper_info.data_len() - DynamicTickData::LEN; + tick_array_upper_info.realloc(new_size, true)?; + let excess = tick_array_upper_info.lamports() + .checked_sub(rent.minimum_balance(new_size)) + .unwrap_or(0); + if excess > 0 { + **tick_array_upper_info.try_borrow_mut_lamports()? -= excess; + **rent_recipient.try_borrow_mut_lamports()? += excess; + } } } diff --git a/programs/clmm/src/instructions/decrease_liquidity_v2.rs b/programs/clmm/src/instructions/decrease_liquidity_v2.rs index e537e46..b85294c 100644 --- a/programs/clmm/src/instructions/decrease_liquidity_v2.rs +++ b/programs/clmm/src/instructions/decrease_liquidity_v2.rs @@ -8,6 +8,7 @@ use anchor_spl::token_interface::{Token2022, TokenAccount}; #[derive(Accounts)] pub struct DecreaseLiquidityV2<'info> { /// The position owner or delegated authority + #[account(mut)] pub nft_owner: Signer<'info>, /// The token account for the tokenized position @@ -107,13 +108,14 @@ pub fn decrease_liquidity_v2<'a, 'b, 'c: 'info, 'info>( amount_1_min: u64, ) -> Result<()> { // Store AccountInfo values to avoid temporary lifetime issues + let nft_owner_info = ctx.accounts.nft_owner.to_account_info(); let tick_array_lower_info = ctx.accounts.tick_array_lower.to_account_info(); let tick_array_upper_info = ctx.accounts.tick_array_upper.to_account_info(); let token_vault_0_info = ctx.accounts.token_vault_0.to_account_info(); let token_vault_1_info = ctx.accounts.token_vault_1.to_account_info(); let recipient_token_account_0_info = ctx.accounts.recipient_token_account_0.to_account_info(); let recipient_token_account_1_info = ctx.accounts.recipient_token_account_1.to_account_info(); - + decrease_liquidity( &ctx.accounts.pool_state, &mut ctx.accounts.personal_position, @@ -123,6 +125,7 @@ pub fn decrease_liquidity_v2<'a, 'b, 'c: 'info, 'info>( &tick_array_upper_info, &recipient_token_account_0_info, &recipient_token_account_1_info, + &nft_owner_info, &ctx.accounts.token_program, Some(ctx.accounts.token_program_2022.clone()), Some(ctx.accounts.memo_program.clone()), diff --git a/tests/helpers/init-utils.ts b/tests/helpers/init-utils.ts index 3422579..72e1ee9 100644 --- a/tests/helpers/init-utils.ts +++ b/tests/helpers/init-utils.ts @@ -537,7 +537,7 @@ export async function decreaseLiquidity( const ix = new TransactionInstruction({ programId: PROGRAM_ID, keys: [ - { pubkey: payer.publicKey, isSigner: true, isWritable: false }, // nft_owner + { pubkey: payer.publicKey, isSigner: true, isWritable: true }, // nft_owner (mut for rent refund) { pubkey: positionNftAccount, isSigner: false, isWritable: false }, // nft_account { pubkey: personalPosition, isSigner: false, isWritable: true }, // personal_position { pubkey: poolPda, isSigner: false, isWritable: true }, // pool_state diff --git a/tests/integration/position-refund.test.ts b/tests/integration/position-refund.test.ts index 0df2c08..903820f 100644 --- a/tests/integration/position-refund.test.ts +++ b/tests/integration/position-refund.test.ts @@ -55,17 +55,31 @@ describe("position refund — open then close", () => { const personalPosRent = BigInt(personalPosAcc!.lamports); const nftMintRent = BigInt(nftMintAcc!.lamports); const nftAtaRent = BigInt(nftAtaAcc!.lamports); - const expectedRefund = personalPosRent + nftMintRent + nftAtaRent; + + // Record tick array state BEFORE decrease (for tick array rent refund check) + const tickArrayLowerBefore = await context.banksClient.getAccount(pos.tickArrayLower); + const tickArrayLowerLamportsBefore = BigInt(tickArrayLowerBefore!.lamports); + const tickArrayLowerSizeBefore = tickArrayLowerBefore!.data.length; + + // If lower == upper, they're the same account; otherwise record upper separately + const isSameArray = pos.tickArrayLower.equals(pos.tickArrayUpper); + let tickArrayUpperLamportsBefore = 0n; + let tickArrayUpperSizeBefore = 0; + if (!isSameArray) { + const tickArrayUpperBefore = await context.banksClient.getAccount(pos.tickArrayUpper); + tickArrayUpperLamportsBefore = BigInt(tickArrayUpperBefore!.lamports); + tickArrayUpperSizeBefore = tickArrayUpperBefore!.data.length; + } console.log(`\n --- Account rents before close ---`); console.log(` PersonalPosition: ${personalPosAcc!.data.length} bytes, ${personalPosRent} lamports`); console.log(` NFT Mint (T22): ${nftMintAcc!.data.length} bytes, ${nftMintRent} lamports`); console.log(` NFT ATA (T22): ${nftAtaAcc!.data.length} bytes, ${nftAtaRent} lamports`); - console.log(` Total refundable: ${expectedRefund} lamports`); + console.log(` Tick array lower: ${tickArrayLowerSizeBefore} bytes, ${tickArrayLowerLamportsBefore} lamports (same_array=${isSameArray})`); - const balanceBeforeClose = BigInt((await context.banksClient.getAccount(context.payer.publicKey))!.lamports); + const balanceBeforeDecrease = BigInt((await context.banksClient.getAccount(context.payer.publicKey))!.lamports); - // 3. Decrease ALL liquidity + // 3. Decrease ALL liquidity (this should shrink dynamic tick arrays and refund rent) await decreaseLiquidity( context, pool.poolPda, pos.positionNftMint, pos.positionNftAccount, pos.personalPosition, @@ -77,7 +91,45 @@ describe("position refund — open then close", () => { new BN(0), new BN(0), ); - // 4. Close position + // 4. Check tick array shrank and rent was refunded + const tickArrayLowerAfterDecrease = await context.banksClient.getAccount(pos.tickArrayLower); + const tickArrayLowerSizeAfter = tickArrayLowerAfterDecrease!.data.length; + const tickArrayLowerLamportsAfter = BigInt(tickArrayLowerAfterDecrease!.lamports); + + const tickArrayShrinkBytes = tickArrayLowerSizeBefore - tickArrayLowerSizeAfter; + const tickArrayRentRefund = tickArrayLowerLamportsBefore - tickArrayLowerLamportsAfter; + + console.log(`\n --- Tick array rent refund (after decrease) ---`); + console.log(` Lower array shrank: ${tickArrayLowerSizeBefore} → ${tickArrayLowerSizeAfter} bytes (−${tickArrayShrinkBytes} bytes)`); + console.log(` Lamports released: ${tickArrayRentRefund} lamports`); + + // Tick array should have shrunk (2 ticks deinitialized × 112 bytes each = 224 bytes for same-array) + expect(tickArrayShrinkBytes).toBeGreaterThan(0); + // Rent refund should be positive + expect(tickArrayRentRefund).toBeGreaterThan(0n); + + let totalTickArrayRentRefund = tickArrayRentRefund; + if (!isSameArray) { + const tickArrayUpperAfterDecrease = await context.banksClient.getAccount(pos.tickArrayUpper); + const upperSizeAfter = tickArrayUpperAfterDecrease!.data.length; + const upperLamportsAfter = BigInt(tickArrayUpperAfterDecrease!.lamports); + const upperRefund = tickArrayUpperLamportsBefore - upperLamportsAfter; + totalTickArrayRentRefund += upperRefund; + console.log(` Upper array shrank: ${tickArrayUpperSizeBefore} → ${upperSizeAfter} bytes`); + console.log(` Upper lamports released: ${upperRefund} lamports`); + } + + const balanceAfterDecrease = BigInt((await context.banksClient.getAccount(context.payer.publicKey))!.lamports); + const decreaseTxFee = 5_000n; + const decreaseGain = balanceAfterDecrease - balanceBeforeDecrease + decreaseTxFee; + + console.log(` User gross gain from decrease: ${decreaseGain} lamports (includes token + rent refund)`); + // The decrease gain should include at least the tick array rent refund + expect(decreaseGain).toBeGreaterThanOrEqual(totalTickArrayRentRefund); + + // 5. Close position + const balanceBeforeClose = balanceAfterDecrease; + await closePosition( context, pos.positionNftMint, @@ -87,7 +139,7 @@ describe("position refund — open then close", () => { const balanceAfterClose = BigInt((await context.banksClient.getAccount(context.payer.publicKey))!.lamports); - // 5. Verify all 3 accounts are CLOSED (null) + // 6. Verify all 3 accounts are CLOSED (null) const personalPosAfter = await context.banksClient.getAccount(pos.personalPosition); const nftMintAfter = await context.banksClient.getAccount(pos.positionNftMint); const nftAtaAfter = await context.banksClient.getAccount(pos.positionNftAccount); @@ -101,20 +153,168 @@ describe("position refund — open then close", () => { expect(nftMintAfter).toBeNull(); expect(nftAtaAfter).toBeNull(); - // 6. Verify refund amount - // Decrease + close = 2 txs × 1 signature each = 10,000 lamports in fees - const txFees = 10_000n; - const actualGain = balanceAfterClose - balanceBeforeClose; - - console.log(`\n --- Refund verification ---`); - console.log(` Balance gained: ${actualGain} lamports`); - console.log(` Tx fees paid: ${txFees} lamports`); - console.log(` Gross refund: ${actualGain + txFees} lamports`); - console.log(` Expected refund: ${expectedRefund} lamports`); - console.log(` Match: ${actualGain + txFees === expectedRefund ? "EXACT ✓" : "MISMATCH ✗"}`); + // 7. Verify close refund (only the 3 closed accounts) + const closeTxFee = 5_000n; + const closeExpectedRefund = personalPosRent + nftMintRent + nftAtaRent; + const closeActualGain = balanceAfterClose - balanceBeforeClose; + + console.log(`\n --- Close refund verification ---`); + console.log(` Balance gained: ${closeActualGain} lamports`); + console.log(` Tx fee paid: ${closeTxFee} lamports`); + console.log(` Gross refund: ${closeActualGain + closeTxFee} lamports`); + console.log(` Expected refund: ${closeExpectedRefund} lamports`); + console.log(` Match: ${closeActualGain + closeTxFee === closeExpectedRefund ? "EXACT ✓" : "MISMATCH ✗"}`); + console.log(``); + + expect(closeActualGain + closeTxFee).toBe(closeExpectedRefund); + }); + + it("refunds tick array rent when ticks are in DIFFERENT arrays", async () => { + const tickSpacing = 10; + const context = await startBankrun(); + fundAdmin(context); + + const ammConfig = await createAmmConfig(context, 0, tickSpacing); + + let mintKeypairA = Keypair.generate(); + let mintKeypairB = Keypair.generate(); + await createMint(context, mintKeypairA, 6); + await createMint(context, mintKeypairB, 6); + + const [mint0, mint1] = + mintKeypairA.publicKey.toBuffer().compare(mintKeypairB.publicKey.toBuffer()) < 0 + ? [mintKeypairA.publicKey, mintKeypairB.publicKey] + : [mintKeypairB.publicKey, mintKeypairA.publicKey]; + + // Price = 1.0, current tick = 0 + const sqrtPriceX64 = new BN("18446744073709551616"); + const pool = await createPool(context, ammConfig, mint0, mint1, sqrtPriceX64); + + const userAta0 = await createAndMintTo(context, mint0, context.payer.publicKey, 1_000_000_000_000n); + const userAta1 = await createAndMintTo(context, mint1, context.payer.publicKey, 1_000_000_000_000n); + + // Record SOL balance BEFORE opening position (full cycle start) + const balanceBeforeOpen = BigInt((await context.banksClient.getAccount(context.payer.publicKey))!.lamports); + + // Tick array size = 60 * tickSpacing(10) = 600 + // tickLower = 10 → array start = 0 + // tickUpper = 600 → array start = 600 (DIFFERENT array) + const pos = await openPosition( + context, pool.poolPda, mint0, mint1, + pool.vault0, pool.vault1, userAta0, userAta1, + 10, 600, tickSpacing, + new BN(1_000_000), + new BN(1_000_000_000), + new BN(1_000_000_000), + ); + + // Verify they are indeed different arrays + const isSameArray = pos.tickArrayLower.equals(pos.tickArrayUpper); + expect(isSameArray).toBe(false); + console.log(`\n --- Different array test ---`); + console.log(` Lower tick array: ${pos.tickArrayLower.toBase58()}`); + console.log(` Upper tick array: ${pos.tickArrayUpper.toBase58()}`); + console.log(` Same array: ${isSameArray}`); + + // Record tick array states BEFORE decrease + const lowerBefore = await context.banksClient.getAccount(pos.tickArrayLower); + const upperBefore = await context.banksClient.getAccount(pos.tickArrayUpper); + const lowerLamportsBefore = BigInt(lowerBefore!.lamports); + const lowerSizeBefore = lowerBefore!.data.length; + const upperLamportsBefore = BigInt(upperBefore!.lamports); + const upperSizeBefore = upperBefore!.data.length; + + console.log(` Lower array before: ${lowerSizeBefore} bytes, ${lowerLamportsBefore} lamports`); + console.log(` Upper array before: ${upperSizeBefore} bytes, ${upperLamportsBefore} lamports`); + + const balanceBefore = BigInt((await context.banksClient.getAccount(context.payer.publicKey))!.lamports); + + // Decrease ALL liquidity + await decreaseLiquidity( + context, pool.poolPda, + pos.positionNftMint, pos.positionNftAccount, pos.personalPosition, + pos.tickArrayLower, pos.tickArrayUpper, + mint0, mint1, + pool.vault0, pool.vault1, + userAta0, userAta1, + new BN(1_000_000), + new BN(0), new BN(0), + ); + + // Check both tick arrays shrank independently + const lowerAfter = await context.banksClient.getAccount(pos.tickArrayLower); + const upperAfter = await context.banksClient.getAccount(pos.tickArrayUpper); + const lowerSizeAfter = lowerAfter!.data.length; + const upperSizeAfter = upperAfter!.data.length; + const lowerLamportsAfter = BigInt(lowerAfter!.lamports); + const upperLamportsAfter = BigInt(upperAfter!.lamports); + + const lowerShrink = lowerSizeBefore - lowerSizeAfter; + const upperShrink = upperSizeBefore - upperSizeAfter; + const lowerRefund = lowerLamportsBefore - lowerLamportsAfter; + const upperRefund = upperLamportsBefore - upperLamportsAfter; + + console.log(`\n --- Tick array rent refund (different arrays) ---`); + console.log(` Lower shrank: ${lowerSizeBefore} → ${lowerSizeAfter} bytes (−${lowerShrink}), refund: ${lowerRefund} lamports`); + console.log(` Upper shrank: ${upperSizeBefore} → ${upperSizeAfter} bytes (−${upperShrink}), refund: ${upperRefund} lamports`); + + // Each array should shrink by 112 bytes (1 tick deinitialized per array) + expect(lowerShrink).toBe(112); + expect(upperShrink).toBe(112); + // Both should have positive rent refunds + expect(lowerRefund).toBeGreaterThan(0n); + expect(upperRefund).toBeGreaterThan(0n); + + // Verify user received the refund + const balanceAfter = BigInt((await context.banksClient.getAccount(context.payer.publicKey))!.lamports); + const txFee = 5_000n; + const totalRefund = lowerRefund + upperRefund; + const userGain = balanceAfter - balanceBefore + txFee; + + console.log(` Total tick array refund: ${totalRefund} lamports`); + console.log(` Balance before decrease: ${balanceBefore} lamports`); + console.log(` Balance after decrease: ${balanceAfter} lamports`); + console.log(` Tx fee: ${txFee} lamports`); + console.log(` User gross gain: ${userGain} lamports`); + console.log(``); + + expect(userGain).toBeGreaterThanOrEqual(totalRefund); + + // Close position to complete the full cycle + await closePosition( + context, + pos.positionNftMint, + pos.positionNftAccount, + pos.personalPosition, + ); + + const balanceAfterClose = BigInt((await context.banksClient.getAccount(context.payer.publicKey))!.lamports); + + // Full cycle: open → decrease → close + // open = 10,000 (2 signers: payer + nftMint), decrease = 5,000, close = 5,000 + const totalTxFees = 20_000n; + const netChange = balanceAfterClose - balanceBeforeOpen; + const grossChange = netChange + totalTxFees; + + // Tick array base accounts (120-byte headers) persist — their rent is NOT refundable + // because tick arrays are shared infrastructure (other positions may use them) + const tickArrayLowerFinal = await context.banksClient.getAccount(pos.tickArrayLower); + const tickArrayUpperFinal = await context.banksClient.getAccount(pos.tickArrayUpper); + const remainingTickArrayRent = + BigInt(tickArrayLowerFinal!.lamports) + BigInt(tickArrayUpperFinal!.lamports); + + console.log(` --- Full cycle balance (open → decrease → close) ---`); + console.log(` Balance before open: ${balanceBeforeOpen} lamports`); + console.log(` Balance after close: ${balanceAfterClose} lamports`); + console.log(` Net change: ${netChange} lamports`); + console.log(` Total tx fees: ${totalTxFees} lamports`); + console.log(` Gross change (excl fees): ${grossChange} lamports`); + console.log(` Remaining tick array rent: ${remainingTickArrayRent} lamports (${tickArrayLowerFinal!.data.length} bytes × 2 arrays)`); + console.log(` Unaccounted: ${grossChange + remainingTickArrayRent} lamports`); console.log(``); - // Gross refund (gain + fees) should exactly equal the rent from the 3 closed accounts - expect(actualGain + txFees).toBe(expectedRefund); + // The only non-refundable SOL should be the tick array base headers (shared infrastructure) + // grossChange + remainingTickArrayRent should be 0 + expect(grossChange + remainingTickArrayRent).toBe(0n); }); });