QuantAMM

QuantAMM
49,600 OP
View results
Submission Details
Severity: medium
Valid

UpliftOnlyExample invalid handling of NFT transferring to self

Summary

The UpliftOnlyExample hook allows transfer deposit NFT to oneself. Because this situation is incorrectly handled in afterUpdate it puts the mapping used for tracking NFTs an invalid state (clears the values and leaves an empty entry). User loses control of all funds related to the NFT and he can't withdraw any remaining NFTs unless transfered to a different account.

Vulnerability Details

LPNFT used for tracking deposits does not prevent transfers where from == to . But the corresponding afterUpdate which performs changes from the old to the new owner does not correctly handle this situation.

Here is an excerpt from UpliftOnlyExample - afterUpdate function where the deposit state is transferred to the new owner.

// @audit example owner deposits: [dp1, dp2, dp3]
// @audit we'll examine the transfer of "dp2"
FeeData[] storage feeDataArray = poolsFeeData[poolAddress][_from];
// @audit important saves the original length (3)
uint256 feeDataArrayLength = feeDataArray.length;
uint256 tokenIdIndex;
bool tokenIdIndexFound = false;
//find the tokenID index in the array
// @audit finds the dp2 index
for (uint256 i; i < feeDataArrayLength; ++i) {
if (feeDataArray[i].tokenID == _tokenID) {
tokenIdIndex = i;
tokenIdIndexFound = true;
break;
}
}
if (tokenIdIndexFound) {
if (_to != address(0)) {
// Update the deposit value to the current value of the pool in base currency (e.g. USD) and the block index to the current block number
// @audit updates the dp2 values
feeDataArray[tokenIdIndex].lpTokenDepositValue = lpTokenDepositValueNow;
feeDataArray[tokenIdIndex].blockTimestampDeposit = uint32(block.number);
feeDataArray[tokenIdIndex].upliftFeeBps = upliftFeeBps;
//actual transfer not a afterTokenTransfer caused by a burn
// @audit _from == _to, so pushes the same entry onto the same owner array
// @audit poolsFeeData[poolAddress][_to] = [dp1, dp2, dp3, new duplicate of "new dp2"]
poolsFeeData[poolAddress][_to].push(feeDataArray[tokenIdIndex]);
if (tokenIdIndex != feeDataArrayLength - 1) {
//Reordering the entire array could be expensive but it is the only way to keep true FILO
// @audit iterates over all values EXCEPT the "new dp2" because feeDataArrayLength = 3
for (uint i = tokenIdIndex + 1; i < feeDataArrayLength; i++) {
// @audit clears the transferred value and copies all entries back
delete feeDataArray[i - 1];
feeDataArray[i - 1] = feeDataArray[i];
}
// @audit because feeDataArray == poolsFeeData[poolAddress][_to] after the loop:
// @audit poolsFeeData[poolAddress][_to] = [dp1, "copied dp3", dp3, "new dp2"]
}
// @audit feeDataArrayLength is still (3)
// @audit poolsFeeData[poolAddress][_to][3-1] = zeroes out all values (dp3)
delete feeDataArray[feeDataArrayLength - 1];
// @audit removes last value poolsFeeData[poolAddress][_to][3] -> removed (new dp2)
feeDataArray.pop();
// @audit final value: [dp1, "pushed d3", "zeroed dp3"]
}
}

Impact

  • Users who transfer to self will lose any value associated with the NFT - Lose of value.

  • Because all the transferred NFT mapping values are zeroed-out, this include tokenID. The NFT is no longer transferrable or withdrawable. So essentially unremovable. - State pollution.

  • The zeroed out NFT will always be at the end of the mapping and left-over NFTs will also be stuck and unwithdrawable. They can only be recovered if transfered to another address. - Extra gas spending to recover any remaining value.

Argument could be made that there is no reason to do this and apart from a mistaken transfer this should not happen. But currently the code also resets fee data (lpTokenDepositValue) on transfer. Meaning the user can avoid paying higher upliftFeeBps and only pay minWithdrawalFeeBps if he continuesly moves the NFT. This creates a real incetive to try transferring to self, to avoid double transfer (own wallet -> secondary wallet -> own wallet) for value resetting.

Lose of value (depending on deposit can be high) + incetive do so (evade fees) - high.

Tools Used

Manual review + foundary tests using a modified testRemoveLiquidityDoublePositivePriceChange (run from UpliftOnlyExampleTest).

The test imitates the same example described in "vulnerability details". User creates 3 deposits and transfers the second one (ID 2) to self.

Resulting in deposits 1 and 3 being correct but the last deposit containing only zeroed out values

function testNftTransferToSelf() public {
uint256[] memory maxAmountsIn = [dai.balanceOf(alice), usdc.balanceOf(alice)].toMemoryArray();
uint256 input = 1e18;
vm.prank(alice);
uint256[] memory amountsIn = upliftOnlyRouter.addLiquidityProportional(pool, maxAmountsIn, input, false, bytes(""));
vm.prank(alice);
amountsIn = upliftOnlyRouter.addLiquidityProportional(pool, maxAmountsIn, input, false, bytes(""));
vm.prank(alice);
amountsIn = upliftOnlyRouter.addLiquidityProportional(pool, maxAmountsIn, input, false, bytes(""));
int256[] memory prices = new int256[]();
prices[0] = 0;
prices[1] = 20e18; // 20x price increase (1e18 originally)
updateWeightRunner.setMockPrices(pool, prices);
LPNFT nft = LPNFT(upliftOnlyRouter.lpNFT());
UpliftOnlyExample.FeeData[] memory aliceFeeData = upliftOnlyRouter.getUserPoolFeeData(pool, alice);
console2.log("---BEFORE TRANSFER---");
console2.log("aliceFeeData length", aliceFeeData.length);
console2.log("aliceFeeData[0] tokenID", aliceFeeData[0].tokenID);
console2.log("aliceFeeData[1] tokenID", aliceFeeData[1].tokenID);
console2.log("aliceFeeData[2] tokenID", aliceFeeData[2].tokenID);
// transfer DP2
vm.prank(alice);
nft.transferFrom(address(alice), address(alice), 2);
aliceFeeData = upliftOnlyRouter.getUserPoolFeeData(pool, alice);
console2.log("---AFTER TRANSFER---");
console2.log("Total deposits", aliceFeeData.length);
console2.log("Deposit 1");
console2.log("aliceFeeData amount", aliceFeeData[0].amount);
console2.log("aliceFeeData tokenID", aliceFeeData[0].tokenID);
console2.log("aliceFeeData lpTokenDepositValue", aliceFeeData[0].lpTokenDepositValue);
console2.log("aliceFeeData blockTimestampDeposit", aliceFeeData[0].blockTimestampDeposit);
console2.log("aliceFeeData upliftFeeBps", aliceFeeData[0].upliftFeeBps);
console2.log("copied Deposit 3");
console2.log("aliceFeeData amount", aliceFeeData[1].amount);
console2.log("aliceFeeData tokenID", aliceFeeData[1].tokenID);
console2.log("aliceFeeData lpTokenDepositValue", aliceFeeData[1].lpTokenDepositValue);
console2.log("aliceFeeData blockTimestampDeposit", aliceFeeData[1].blockTimestampDeposit);
console2.log("aliceFeeData upliftFeeBps", aliceFeeData[1].upliftFeeBps);
console2.log("zeroed Deposit 3");
console2.log("aliceFeeData amount", aliceFeeData[2].amount);
console2.log("aliceFeeData tokenID", aliceFeeData[2].tokenID);
console2.log("aliceFeeData lpTokenDepositValue", aliceFeeData[2].lpTokenDepositValue);
console2.log("aliceFeeData blockTimestampDeposit", aliceFeeData[2].blockTimestampDeposit);
console2.log("aliceFeeData upliftFeeBps", aliceFeeData[2].upliftFeeBps);
// withdrawal fails
// vm.prank(alice);
// upliftOnlyRouter.removeLiquidityProportional(input, minAmountsOut, false, pool);
}

The test prints out:

---BEFORE TRANSFER---
aliceFeeData length 3
aliceFeeData[0] tokenID 1
aliceFeeData[1] tokenID 2
aliceFeeData[2] tokenID 3
---AFTER TRANSFER---
Total deposits 3
Deposit 1
aliceFeeData amount 1000000000000000000
aliceFeeData tokenID 1
aliceFeeData lpTokenDepositValue 500000000000000000
aliceFeeData blockTimestampDeposit 1682899200
aliceFeeData upliftFeeBps 200
copied Deposit 3
aliceFeeData amount 1000000000000000000
aliceFeeData tokenID 3
aliceFeeData lpTokenDepositValue 500000000000000000
aliceFeeData blockTimestampDeposit 1682899200
aliceFeeData upliftFeeBps 200
zeroed Deposit 3
aliceFeeData amount 0
aliceFeeData tokenID 0
aliceFeeData lpTokenDepositValue 0
aliceFeeData blockTimestampDeposit 0
aliceFeeData upliftFeeBps 0

Recommendations

The is no benefit in supporting transfers to self so the easiest solution would be to prevent them in LPNFT:

function _update(address to, uint256 tokenId, address auth) internal override returns (address previousOwner) {
if (to == previousOwner) { // @audit add self transfer prevetion
revert("Can't transfer to self");
}
if (to != address(0) && previousOwner != address(0)) {
//if transfering the record in the vault needs to be changed to reflect the change in ownership
router.afterUpdate(previousOwner, to, tokenId);
}
}

Otherwise add explicit handling for when user transfers to self

function afterUpdate () {
...
if (tokenIdIndexFound) {
if (_from == _to) {
// @audit because no actual movement is needed, we just update the deposit values
feeDataArray[tokenIdIndex].lpTokenDepositValue = lpTokenDepositValueNow;
feeDataArray[tokenIdIndex].blockTimestampDeposit = uint32(block.number);
feeDataArray[tokenIdIndex].upliftFeeBps = upliftFeeBps;
} else if (_to != address(0)) {
...
}
}
Updates

Lead Judging Commences

n0kto Lead Judge 10 months ago
Submission Judgement Published
Validated
Assigned finding tags:

finding_afterUpdate_erase_self_transfer

Likelihood: Low, when users wants to transfer tokens they already own to themselves. Impact: High, funds loss.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!