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;}
...
...