Summary
When users add liquidity through the uplift Router/hook contract, they are charged an uplift fee if the price increases after their deposit. However, this fee can be bypassed by transferring the NFT to another address and then withdrawing liquidity, which only incurs the withdrawal fee instead of the uplift fee.
Vulnerability Details
When a user deposits or adds liquidity, an NFT is minted for the user, and the router stores the current USD value of the assets along with the amount of their BPT tokens.
contracts/hooks-quantamm/UpliftOnlyExample.sol:220
220: function addLiquidityProportional(
221: address pool,
222: uint256[] memory maxAmountsIn,
223: uint256 exactBptAmountOut,
224: bool wethIsEth,
225: bytes memory userData
226: ) external payable saveSender(msg.sender) returns (uint256[] memory amountsIn) {
228: if (poolsFeeData[pool][msg.sender].length > 100) {
229: revert TooManyDeposits(pool, msg.sender);
230: }
231:
232: amountsIn = _addLiquidityProportional(
233: pool,
234: msg.sender,
235: address(this),
236: maxAmountsIn,
237: exactBptAmountOut,
238: wethIsEth,
239: userData
240: );
241:
242: uint256 tokenID = lpNFT.mint(msg.sender);
243:
244:
245:
246: uint256 depositValue = getPoolLPTokenValue(
247: IUpdateWeightRunner(_updateWeightRunner).getData(pool),
248: pool,
249: MULDIRECTION.MULDOWN
250: );
251:
252: poolsFeeData[pool][msg.sender].push(
253: FeeData({
254: tokenID: tokenID,
255: amount: exactBptAmountOut,
256:
257: lpTokenDepositValue: depositValue,
258:
259: blockTimestampDeposit: uint40(block.timestamp),
260: upliftFeeBps: upliftFeeBps
261: })
262: );
263:
264: nftPool[tokenID] = pool;
265: }
When a user withdraws liquidity from the pool, an uplift fee is charged if the price has increased. However, this fee can be bypassed by transferring the NFT to another account. Below, we examine the function that handles NFT transfers:
contracts/hooks-quantamm/UpliftOnlyExample.sol:579
579: function afterUpdate(address _from, address _to, uint256 _tokenID) public {
580: if (msg.sender != address(lpNFT)) {
581: revert TransferUpdateNonNft(_from, _to, msg.sender, _tokenID);
582: }
583:
584: address poolAddress = nftPool[_tokenID];
585:
586: if (poolAddress == address(0)) {
587: revert TransferUpdateTokenIDInvaid(_from, _to, _tokenID);
588: }
589:
590: int256[] memory prices = IUpdateWeightRunner(_updateWeightRunner).getData(poolAddress);
591: uint256 lpTokenDepositValueNow = getPoolLPTokenValue(prices, poolAddress, MULDIRECTION.MULDOWN);
592:
593: FeeData[] storage feeDataArray = poolsFeeData[poolAddress][_from];
...
610:
611: if (tokenIdIndexFound) {
612: if (_to != address(0)) {
613:
614:
615: feeDataArray[tokenIdIndex].lpTokenDepositValue = lpTokenDepositValueNow;
616: feeDataArray[tokenIdIndex].blockTimestampDeposit = uint32(block.number);
617: feeDataArray[tokenIdIndex].upliftFeeBps = upliftFeeBps;
618:
619:
620: poolsFeeData[poolAddress][_to].push(feeDataArray[tokenIdIndex]);
...
633: }
634: }
635:
From the code above, it is evident that the lpTokenDepositValue is only updated to reflect the latest price, and the FeeData is stored for the receiver. Consequently, when a user withdraws, only the withdrawal fee will be applied.
Breakdown of the removeLiquidity Function:
During the withdrawal process, the current price is fetched and compared with the stored USD value.
If the difference is greater than 0, the uplift fee is applied.
Otherwise, only the withdrawal fee is charged.
contracts/hooks-quantamm/UpliftOnlyExample.sol:434
434: function onAfterRemoveLiquidity(
435: address router,
436: address pool,
437: RemoveLiquidityKind,
438: uint256 bptAmountIn,
439: uint256[] memory,
440: uint256[] memory amountsOutRaw,
441: uint256[] memory,
442: bytes memory userData
443: ) public override onlySelfRouter(router) returns (bool, uint256[] memory hookAdjustedAmountsOutRaw) {
444: address userAddress = address(bytes20(userData));
...
465:
466: hookAdjustedAmountsOutRaw = amountsOutRaw;
467:
468:
469: localData.lpTokenDepositValueNow = getPoolLPTokenValue(localData.prices, pool, MULDIRECTION.MULDOWN);
470:
471: FeeData[] storage feeDataArray = poolsFeeData[pool][userAddress];
472: localData.feeDataArrayLength = feeDataArray.length;
473: localData.amountLeft = bptAmountIn;
474: for (uint256 i = localData.feeDataArrayLength - 1; i >= 0; --i) {
475: localData.lpTokenDepositValue = feeDataArray[i].lpTokenDepositValue;
477:
478: localData.lpTokenDepositValueChange =
479: (int256(localData.lpTokenDepositValueNow) - int256(localData.lpTokenDepositValue)) /
480: int256(localData.lpTokenDepositValue);
481: uint256 feePerLP;
482:
483: if (localData.lpTokenDepositValueChange > 0) {
485: feePerLP =
486: (uint256(localData.lpTokenDepositValueChange) * (uint256(feeDataArray[i].upliftFeeBps) * 1e18)) /
487: 10000;
488: }
489:
490: else {
491:
492:
493: feePerLP = (uint256(minWithdrawalFeeBps) * 1e18) / 10000;
494:
}
Steps to Bypass Uplift Fee
Initial Deposit: Alice deposits 10 WETH at a price of $3000. The stored lpTokenDepositValue is set to $30,000\.
Price Increase: The price of WETH increases to $3400. If Alice withdraws, the uplift fee will be applied.
NFT Transfer: Alice transfers the NFT to another address. The stored lpTokenDepositValue is updated to $34,000.
Withdrawal: Alice calls the removeLiquidityProportional function. Since the current USD value and the stored USD value are the same, no uplift fee is applied.
Impact
The user can bypass the uplift fee by transferring the NFT to another address. and the Withdrawal Fee according to current config is less than uplift fee.
Tools Used
Manual Review
Recommendations
When a user transfers the NFT, the best approach would be to apply the uplift fee if the price has increased.