From ca4188696bc8b4eb2dff2b432f60829a38d4e561 Mon Sep 17 00:00:00 2001 From: bangyro <229454856+bangyro@users.noreply.github.com> Date: Wed, 17 Jun 2026 20:42:52 +0800 Subject: [PATCH] fix: Reward Delegate Can Clear Rewards Without Transfer --- .../src/instructions/ix_claim_reward.rs | 32 ++++++++----- tests/delegatePosition.test.ts | 46 +++++++++++++++++++ 2 files changed, 67 insertions(+), 11 deletions(-) diff --git a/programs/cp-amm/src/instructions/ix_claim_reward.rs b/programs/cp-amm/src/instructions/ix_claim_reward.rs index 23f15029..8d6b7238 100644 --- a/programs/cp-amm/src/instructions/ix_claim_reward.rs +++ b/programs/cp-amm/src/instructions/ix_claim_reward.rs @@ -78,17 +78,27 @@ pub fn handle_claim_reward( let mut pool = ctx.accounts.pool.load_mut()?; let mut position = ctx.accounts.position.load_mut()?; - position.assert_authority_with_owner_destinations( - &ctx.accounts.position_nft_account, - &ctx.accounts.signer.key(), - PositionDelegatePermission::ClaimReward, - PositionDelegatePermission::ClaimRewardToOwner, - &[( - &ctx.accounts.user_token_account.to_account_info(), - ctx.accounts.reward_mint.key(), - ctx.accounts.token_program.key(), - )], - )?; + if ctx.accounts.reward_vault.is_frozen() && skip_reward == 1 { + // this clears the pending reward without transferring it, + // so only the unrestricted `ClaimReward` permission allowed to perfomr it + position.assert_authority( + &ctx.accounts.position_nft_account, + &ctx.accounts.signer.key(), + PositionDelegatePermission::ClaimReward, + )?; + } else { + position.assert_authority_with_owner_destinations( + &ctx.accounts.position_nft_account, + &ctx.accounts.signer.key(), + PositionDelegatePermission::ClaimReward, + PositionDelegatePermission::ClaimRewardToOwner, + &[( + &ctx.accounts.user_token_account.to_account_info(), + ctx.accounts.reward_mint.key(), + ctx.accounts.token_program.key(), + )], + )?; + } let current_time = Clock::get()?.unix_timestamp as u64; diff --git a/tests/delegatePosition.test.ts b/tests/delegatePosition.test.ts index 430e39dd..7d03e0c8 100644 --- a/tests/delegatePosition.test.ts +++ b/tests/delegatePosition.test.ts @@ -28,6 +28,7 @@ import { createPosition, createToken, derivePositionNftAccount, + deriveRewardVaultAddress, encodeDelegatePermissions, encodePermissions, fundReward, @@ -59,6 +60,7 @@ import { generateKpAndFund } from "./helpers/common"; import { BaseFeeMode, encodeFeeTimeSchedulerParams } from "./helpers/feeCodec"; import { expectThrowsErrorCode } from "./helpers/svm"; import { + freezeTokenAccount, getOrCreateAssociatedTokenAccount, getTokenAccount, } from "./helpers/token"; @@ -960,6 +962,50 @@ describe("Delegate Position", () => { expect(result).instanceOf(TransactionMetadata); const after = new BN(getTokenBalance(svm, userAtaReward)); expect(after.gt(before)).to.be.true; + + // delegate with ClaimRewardToOwner permission must not be able to discard pending + // reward via skip_reward when the vault is frozen + const rewardVault = deriveRewardVaultAddress(pool, rewardIndex); + freezeTokenAccount(svm, admin, rewardMint, rewardVault); + expect(getTokenAccount(svm, rewardVault).state).eq(2); // frozen + + const skipResult = await claimReward(svm, { + index: rewardIndex, + user: delegate, + pool, + position: targetPosition, + skipReward: 1, + userTokenAccount: userAtaReward, + }); + expectThrowsErrorCode(skipResult, INVALID_PERMISSION_CODE); + + // delegate with ClaimReward permission can discard the pending reward + // on a frozen vault without any transfer + await updateDelegatePermission(svm, { + owner: user, + position: targetPosition, + delegate: delegate.publicKey, + permission: encodeDelegatePermissions([ + PositionDelegatePermission.ClaimReward, + ]), + }); + + const beforeDelegate = new BN(getTokenBalance(svm, delegateAtaReward)); + const discardResult = await claimReward(svm, { + index: rewardIndex, + user: delegate, + pool, + position: targetPosition, + skipReward: 1, + userTokenAccount: userAtaReward, + }); + expect(discardResult).instanceOf(TransactionMetadata); + const positionState = getPosition(svm, targetPosition); + expect( + positionState.rewardInfos[rewardIndex].rewardPendings.toNumber() + ).eq(0); + const afterDelegate = new BN(getTokenBalance(svm, delegateAtaReward)); + expect(afterDelegate.eq(beforeDelegate)).to.be.true; }); it("delegate cannot claim to delegate ATA", async () => {