Skip to content
Request an audit

‹ All findings

Reward Token Loss for LPs During NFT Position Transfer

Crit/HighBMX·Sherlock · Perpetuals DEX · 2nd September 2025H-6

Summary

In DeliHook v4 pools, when a liquidity provider (LP) transfers their NFT position to a new owner, the auto-claiming of rewards is executed in favor of the new owner instead of the previous owner.

As a result, the previous LP permanently loses their accumulated reward tokens at the time of transfer.

Currently, during position removal or modification, the protocol performs an auto-claim of reward tokens to the LP.

However, during a position transfer (DeliHook v4 Pools), the transferFrom function changes ownership before the unsubscribe process is triggered. This ordering causes the reward claim to be credited to the new owner, not the previous owner.

Vulnerability Details

When an NFT position is transferred, the following function is called:

solidity
function transferFrom(
    address from,
    address to,
    uint256 id
) public virtual override onlyIfPoolManagerLocked {
    super.transferFrom(from, to, id);   // Ownership is updated first
    if (positionInfo[id].hasSubscriber()) _unsubscribe(id);
}
  • Ownership of the NFT position is changed first.
  • _unsubscribe is then called, which triggers the notifyUnsubscribe logic.
solidity
    function notifyUnsubscribe(
        uint256 tokenId
    ) external override onlyAuthorizedCaller {
        NotifyContext memory c = _buildContextFromToken(tokenId, true);

        dailyEpochGauge.notifyUnsubscribeWithContext(
            c.posKey,
            c.pidRaw,
            c.currentTick,
            c.owner,
            c.tickLower,
            c.tickUpper,
            c.liquidity
        );

        ...
    }

During unsubscribe, context is built for the position, including the owner:

solidity
    function _buildContextFromToken(
        uint256 tokenId,
        bool includeOwner
    ) internal view returns (NotifyContext memory ctx) {
        IPositionHandler handler = getHandler(tokenId);

        ...
 
        if (includeOwner) {
            ctx.owner = handler.ownerOf(tokenId);
        }

    }
solidity
    function ownerOf(uint256 tokenId) external view override returns (address) {
        return IERC721(address(positionManager)).ownerOf(tokenId);
    }

Because the transfer already occurred, ownerOf(tokenId) returns the new owner's address.

The unsubscribe function then passes this owner to notifyUnsubscribeWithContext:

solidity
    function notifyUnsubscribeWithContext(
        bytes32 posKey,
        bytes32 poolIdRaw,
        int24 currentTick,
        address ownerAddr,
        int24 tickLower,
        int24 tickUpper,
        uint128 liquidity
    ) external onlyPositionManagerAdapter {
        ....

        _claimRewards(posKey, ownerAddr);

        _removePosition(pid, posKey);

    }

Finally, rewards are transferred:

solidity
    function _claimRewards(
        bytes32 posKey,
        address recipient
    ) internal returns (uint256 amount) {
        amount = positionRewards[posKey].claim();

        if (amount > 0) {
            BMX.transfer(recipient, amount); 

            emit Claimed(recipient, amount);
        }
    }

Since recipient is the new owner, the previous LP loses all their accumulated rewards.

Root Cause

The reward claim in notifyUnsubscribeWithContext (https://github.com/sherlock-audit/2025-09-bmx-deli-swap/blob/main/deli-swap-contracts/src/DailyEpochGauge.sol#L706-L707) uses the current NFT owner (post-transfer) instead of the previous LP (position owner):

solidity
    function notifyUnsubscribeWithContext(
        bytes32 posKey,
        bytes32 poolIdRaw,
        int24 currentTick,
        address ownerAddr,
        int24 tickLower,
        int24 tickUpper,
        uint128 liquidity
    ) external onlyPositionManagerAdapter {

        ...

        _claimRewards(posKey, ownerAddr); // root-cause
        _removePosition(pid, posKey);
    }

Internal Pre-conditions

  1. LP holds an NFT position in a DeliHook v4 pool.
  2. LP transfers their NFT position to another account.

External Pre-conditions

None

Attack Path

  1. An attacker purchases an existing NFT position from an LP.
  1. The transferFrom function executes, updating the NFT ownership to the attacker.
  1. _unsubscribe is triggered, building context with the attacker's address as the new owner.
  1. notifyUnsubscribeWithContext calls _claimRewards with the attacker's address.
  1. The attacker receives all reward tokens that belonged to the previous LP.

Impact

  • Loss of accumulated rewards: The previous LP permanently loses all their unclaimed rewards.
  • High Impact — direct and irreversible economic loss for LPs.

PoC

None

Mitigation

The reward claim logic should be updated to reference the recorded position owner (tracked from subscription) rather than the current NFT owner.

solidity
    function notifyUnsubscribeWithContext(
        bytes32 posKey,
        bytes32 poolIdRaw,
        int24 currentTick,
        address ownerAddr,
        int24 tickLower,
        int24 tickUpper,
        uint128 liquidity
    ) external onlyPositionManagerAdapter {

        ...
        
        @ audit Bug
-     _claimRewards(posKey, ownerAddr); 
     
       @ audit Fix

+     address owner = positionOwner[posKey];
+     _claimRewards(posKey, ownerAddr);



    }