QuantAMM

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

User can bypass the uplift Fee via transfer token to others

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: // Do addLiquidity operation - BPT is minted to this contract.
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: //this requires the pool to be registered with the QuantAMM update weight runner
245: //as well as approved with oracles that provide the prices
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: //this rounding favours the LP
257: lpTokenDepositValue: depositValue, // 0.5e18
258: //known use of timestamp, caveats are known.
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]; // 1 entry
...
610:
611: if (tokenIdIndexFound) {
612: if (_to != address(0)) {
613: // 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
614: //vault.transferLPTokens(_from, _to, feeDataArray[i].amount);
615: feeDataArray[tokenIdIndex].lpTokenDepositValue = lpTokenDepositValueNow;
616: feeDataArray[tokenIdIndex].blockTimestampDeposit = uint32(block.number);
617: feeDataArray[tokenIdIndex].upliftFeeBps = upliftFeeBps;
618:
619: //actual transfer not a afterTokenTransfer caused by a burn
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: // We only allow removeLiquidity via the Router/Hook itself so that fee is applied correctly.
466: hookAdjustedAmountsOutRaw = amountsOutRaw;
467:
468: //this rounding faxvours the LP
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);// @note : it returns values in bps so upto 0.9 e18 change can be
481: uint256 feePerLP;
482: // if the pool has increased in value since the deposit, the fee is calculated based on the deposit value
483: if (localData.lpTokenDepositValueChange > 0) { // @note : it will not work with bps less than 1
485: feePerLP =
486: (uint256(localData.lpTokenDepositValueChange) * (uint256(feeDataArray[i].upliftFeeBps) * 1e18)) /
487: 10000;
488: }
489: // if the pool has decreased in value since the deposit, the fee is calculated based on the base value - see wp
490: else {
491: //in most cases this should be a normal swap fee amount.
492: //there always myst be at least the swap fee amount to avoid deposit/withdraw attack surgace.
493: feePerLP = (uint256(minWithdrawalFeeBps) * 1e18) / 10000;
494:
}

Steps to Bypass Uplift Fee

  1. Initial Deposit: Alice deposits 10 WETH at a price of $3000. The stored lpTokenDepositValue is set to $30,000\.

  2. Price Increase: The price of WETH increases to $3400. If Alice withdraws, the uplift fee will be applied.

  3. NFT Transfer: Alice transfers the NFT to another address. The stored lpTokenDepositValue is updated to $34,000.

  4. 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.

Updates

Lead Judging Commences

n0kto Lead Judge 11 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!