Summary
In the afterUpdate function of the UpliftOnlyExample contract, there exists a logical flaw when an NFT's ownership is updated but the _from and _to addresses are the same. If the NFT is being updated and transferred to the same address (i.e., _from == _to), the contract will incorrectly delete the feeDataArray , lending to loss of LP NFT.
Vulnerability Details
The function afterUpdate is a post-transfer hook that is called after an overridden _update function in an LPNFT contract:
function _update(address to, uint256 tokenId, address auth) internal override returns (address previousOwner) {
previousOwner = super._update(to, tokenId, auth);
if (to != address(0) && previousOwner != address(0)) {
router.afterUpdate(previousOwner, to, tokenId);
}
}
In afterUpdate function, it will reorder the entire array by the following code:
if (tokenIdIndex != feeDataArrayLength - 1) {
for (uint i = tokenIdIndex + 1; i < feeDataArrayLength; i++) {
delete feeDataArray[i - 1];
feeDataArray[i - 1] = feeDataArray[i];
}
}
delete feeDataArray[feeDataArrayLength - 1];
feeDataArray.pop();
However, this function does not handle the scenario where _from == _to. If _from and _to are the same address, the function will attempt to:
Update the FeeData entry for the _tokenID.
Push the updated FeeData entry into the poolsFeeData array for the same address (_to == _from).
Reorder the FeeData array for _from by shifting elements and deleting the last entry.
This could result in duplicate entries or incorrect state updates in the poolsFeeData array, as the same FeeData entry is being pushed into the array while the original entry is still being processed.
Consider Bob has the folliwng LP NFT:
[
{ tokenID: 123, lpTokenDepositValue: 100, blockTimestampDeposit: 1000, upliftFeeBps: 10 },
{ tokenID: 456, lpTokenDepositValue: 200, blockTimestampDeposit: 2000, upliftFeeBps: 20 }
]
He transfers tokenID=123 NFT to himself, this NFT will be pushed into the poolsFeeData array:
[
{ tokenID: 123, lpTokenDepositValue: 100, blockTimestampDeposit: 1000, upliftFeeBps: 10 },
{ tokenID: 456, lpTokenDepositValue: 200, blockTimestampDeposit: 2000, upliftFeeBps: 20 },
{ tokenID: 123, lpTokenDepositValue: 100, blockTimestampDeposit: 1000, upliftFeeBps: 10 }
]
And then the function will reorder the poolsFeeData array by shifting elements. After reordering, the array becomes:
[
{ tokenID: 456, lpTokenDepositValue: 200, blockTimestampDeposit: 2000, upliftFeeBps: 20 }
{ tokenID: 456, lpTokenDepositValue: 200, blockTimestampDeposit: 2000, upliftFeeBps: 20 },
{ tokenID: 123, lpTokenDepositValue: 100, blockTimestampDeposit: 1000, upliftFeeBps: 10 }
]
And then the function will execute the following code:
delete feeDataArray[feeDataArrayLength - 1];
feeDataArray.pop();
To note that the feeDataArrayLength is queried before the push action:
FeeData[] storage feeDataArray = poolsFeeData[poolAddress][_from];
uint256 feeDataArrayLength = feeDataArray.length;
...
...
poolsFeeData[poolAddress][_to].push(feeDataArray[tokenIdIndex]);
So the feeDataArrayLength is 2 instead of 3, so the actual delete logic is :
delete feeDataArray[2 - 1];
feeDataArray.pop();
the poolsFeeData array becomes:
[
{ tokenID: 456, lpTokenDepositValue: 200, blockTimestampDeposit: 2000, upliftFeeBps: 20 }
{ tokenID: 0, lpTokenDepositValue: 0, blockTimestampDeposit: 0, upliftFeeBps: 0 },
]
We can see that the NFT( tokenID=123 ) has been deleted.
POC
You can add following test to UpliftExample.t.sol :
function testSelfTransferDeposits() public {
uint256[] memory maxAmountsIn = [dai.balanceOf(bob), usdc.balanceOf(bob)].toMemoryArray();
vm.startPrank(bob);
uint256 bptAmountDeposit = bptAmount / 150;
uint256[] memory tokenIndexArray = new uint256[]();
for (uint256 i = 0; i < 2; i++) {
tokenIndexArray[i] = i + 1;
upliftOnlyRouter.addLiquidityProportional(pool, maxAmountsIn, bptAmountDeposit, false, bytes(""));
skip(1 days);
}
UpliftOnlyExample.FeeData[] memory bobFees = upliftOnlyRouter.getUserPoolFeeData(pool, bob);
assertNotEq(bobFees[0].tokenID, 0, "first tokenId should not be 0");
assertNotEq(bobFees[1].tokenID, 0, "second tokenId should not be 0");
vm.stopPrank();
LPNFT lpNft = upliftOnlyRouter.lpNFT();
vm.startPrank(bob);
lpNft.transferFrom(bob, bob, 1);
bobFees = upliftOnlyRouter.getUserPoolFeeData(pool, bob);
assertNotEq(bobFees[0].tokenID, 0, "first tokenId should not be 0");
assertNotEq(bobFees[1].tokenID, 0, "second tokenId should not be 0");
vm.stopPrank();
}
run forge test --match-test testSelfTransferDeposits :
Ran 1 test for test/foundry/UpliftExample.t.sol:UpliftOnlyExampleTest
[FAIL: second tokenId should not be 0: 0 == 0] testSelfTransferDeposits() (gas: 958680)
Suite result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 16.24ms (893.13µs CPU time)
We can see that the second LP will be deleted.
In summary, because the afterUpdate function does not check if _from is equal to _to, it unintentionally performs updates and reaches a reordering logic that is irrelevant when no ownership change occurs. This can result in critical loss of liquidity NFT.
Impact
The impact is HIGH because it will cause funds loss and the likelihood is MEDIUM, so the severity is HIGH.
Tools Used
Manual Review
Recommendations
Consider following fix:
function afterUpdate(address _from, address _to, uint256 _tokenID) public {
if (msg.sender != address(lpNFT)) {
revert TransferUpdateNonNft(_from, _to, msg.sender, _tokenID);
}
if (_from == _to){return;}
...
...