QuantAMM

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

User can bypass 100% of the uplift fees by transferring the NFT before withdrawing funds

Summary

Users can bypass the “uplift” fee—meant to charge them whenever the pool’s value has grown since their original deposit—by simply transferring their NFT to a new address before withdrawing.

This transfer resets the recorded “deposit value” to the pool’s current, higher price(lpTokenDepositValueNow), thereby nullifying any accrued fee from earlier gains. As a result, the user ends up paying no uplift fee when they remove liquidity, even if the pool has significantly increased in value since their true deposit time.

Vulnerability Details

The key code is in the afterUpdate(...) function of UpliftOnlyExample, which is called whenever an NFT is transferred. Notice how the original lpTokenDepositValue (the baseline price used to calculate uplift fees) gets overwritten with the latest pool value:

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

function afterUpdate(address _from, address _to, uint256 _tokenID) public {
if (msg.sender != address(lpNFT)) {
revert TransferUpdateNonNft(_from, _to, msg.sender, _tokenID);
}
address poolAddress = nftPool[_tokenID];
//...
int256[] memory prices = IUpdateWeightRunner(_updateWeightRunner).getData(poolAddress);
@> uint256 lpTokenDepositValueNow = getPoolLPTokenValue(prices, poolAddress, MULDIRECTION.MULDOWN);
@> FeeData[] storage feeDataArray = poolsFeeData[poolAddress][_from];
// ...
// Once we find the corresponding FeeData entry in feeDataArray:
if (tokenIdIndexFound) {
if (_to != address(0)) {
// @audit-issue: Reset deposit baseline to current pool value,
// wiping out prior gains
@> feeDataArray[tokenIdIndex].lpTokenDepositValue = lpTokenDepositValueNow;
feeDataArray[tokenIdIndex].blockTimestampDeposit = uint32(block.number);
feeDataArray[tokenIdIndex].upliftFeeBps = upliftFeeBps;
// Move this deposit record to the new owner's array
poolsFeeData[poolAddress][_to].push(feeDataArray[tokenIdIndex]);
// Remove it from the old owner’s array
// ...
}
}
}

Because lpTokenDepositValue gets set to lpTokenDepositValueNow, the system treats the new owner as if they just deposited at the updated, higher price. Any earlier price growth that happened while the old owner held the NFT is effectively erased from the fee calculation.

Notice when the user withdraws his funds by calling removeLiquidityProportional, BalancerV3`s Vault will trigger

onAfterRemoveLiquidityand it is here that fees are calculated and applied in case the LP value has grown over time.

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

function onAfterRemoveLiquidity(
address router,
address pool,
RemoveLiquidityKind,
uint256 bptAmountIn,
uint256[] memory,
uint256[] memory amountsOutRaw,
uint256[] memory,
bytes memory userData
) public override onlySelfRouter(router) returns (bool, uint256[] memory hookAdjustedAmountsOutRaw) {
...
@> localData.lpTokenDepositValueNow = getPoolLPTokenValue(localData.prices, pool, MULDIRECTION.MULDOWN);
@> FeeData[] storage feeDataArray = poolsFeeData[pool][userAddress];
...
for (uint256 i = localData.feeDataArrayLength - 1; i >= 0; --i) {
@> localData.lpTokenDepositValue = feeDataArray[i].lpTokenDepositValue;
localData.lpTokenDepositValueChange =
(int256(localData.lpTokenDepositValueNow) - int256(localData.lpTokenDepositValue)) /
int256(localData.lpTokenDepositValue);
uint256 feePerLP;
// if the pool has increased in value since the deposit, the fee is calculated based on the deposit value
@> if (localData.lpTokenDepositValueChange > 0) {
feePerLP =
(uint256(localData.lpTokenDepositValueChange) * (uint256(feeDataArray[i].upliftFeeBps) * 1e18)) /
10000;
}
@> // if the pool has decreased in value since the deposit, the fee is calculated based on the base value - see wp
else {
//in most cases this should be a normal swap fee amount.
//there always myst be at least the swap fee amount to avoid deposit/withdraw attack surgace.
feePerLP = (uint256(minWithdrawalFeeBps) * 1e18) / 10000;
}

Now that the user has his NFT with the current lpTokenDepositValue, the contract thinks that the LP value has remained/decreased over time.

User skips all the protocol fees applied on gains over time.

In a nutshell, the scenario to bypass is quite simple:

  1. User transfers his NFT to a secondary address

  2. User withdraw funds.

That's it.

Impact

  • The protocol relies on these uplift fees for revenue. Thus the loss of funds for the protocol.

  • Honest users who do not realize they can reset their baseline effectively pay more, while malicious users or arbitrageurs pay none.

Likelihood: High, Impact High, therefore submitting it as a High.

Tools Used

Manual Review

Recommendations

If a transfer should reset the baseline for the recipient, then the protocol should first charge the current “uplift” fee to the outgoing owner, ensuring they can’t escape fees accrued up to that point.

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!