QuantAMM

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

UpliftOnlyExample.sol :: onAfterRemoveLiquidity() dividing after multiplication in the fee calculation leads to a reduction in the fees collected.

Summary

onAfterRemoveLiquidity() is invoked by the router to calculate the fees a user must pay when removing liquidity. The issue lies in the fee calculation, where division occurs after multiplication. This approach results in precision loss due to Solidity's natural truncation behavior, causingto collect fewer fees than intended.

Vulnerability Details

onAfterRemoveLiquidity() is implemented as follows:

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) {
address userAddress = address(bytes20(userData));
AfterRemoveLiquidityData memory localData = AfterRemoveLiquidityData({
pool: pool,
bptAmountIn: bptAmountIn,
amountsOutRaw: amountsOutRaw,
minAmountsOut: new uint256[](amountsOutRaw.length),
accruedFees: new uint256[](amountsOutRaw.length),
accruedQuantAMMFees: new uint256[](amountsOutRaw.length),
currentFee: minWithdrawalFeeBps,
feeAmount: 0,
prices: IUpdateWeightRunner(_updateWeightRunner).getData(pool),
lpTokenDepositValueNow: 0,
lpTokenDepositValueChange: 0,
lpTokenDepositValue: 0,
tokens: new IERC20[](amountsOutRaw.length),
feeDataArrayLength: 0,
amountLeft: 0,
feePercentage: 0,
adminFeePercent: 0
});
// We only allow removeLiquidity via the Router/Hook itself so that fee is applied correctly.
hookAdjustedAmountsOutRaw = amountsOutRaw;
//this rounding faxvours the LP
localData.lpTokenDepositValueNow = getPoolLPTokenValue(localData.prices, pool, MULDIRECTION.MULDOWN);
FeeData[] storage feeDataArray = poolsFeeData[pool][userAddress];
localData.feeDataArrayLength = feeDataArray.length;
localData.amountLeft = bptAmountIn;
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;
}
///code....
}

As observed, the function first calculates the change in the LP value and, if the value has increased, determines the corresponding fee using the first if.

However, the issue arises because the decimal adjustment (multiplying by 1e18) is not applied during the calculation of localData.lpTokenDepositValueChange but instead in feePerLP. This causes precision loss, leading to collect fewer fees than intended.

Impact

Due to precision loss in the fee calculation, fewer fees are collected.

POC

To better understand the issue, you can copy the following proof of concept (POC) into Remix and observe the precision loss. Use the following inputs: The inputs simulate an 18% price increase to observe the effect. Execute comparePrecisionLoss().

  • priceNow = 1000000000000000000 (1e18)

  • pricePrevious = 123213213213232333

  • upliftFeeBps = 1000 (10%)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract PrecisionLoss {
// Current implementation
function divisionBeforeMultiplication(
uint256 priceNow,
uint256 pricePrevious,
uint256 upliftFeeBps
) public pure returns (uint256) {
// Calculate the percentage change (division first)
uint256 priceChange = (priceNow - pricePrevious) / pricePrevious;
// Calculate the fee (multiplying after division)
return (priceChange * upliftFeeBps * 1e18) / 10000;
}
// Correct implementation
function multiplicationBeforeDivision(
uint256 priceNow,
uint256 pricePrevious,
uint256 upliftFeeBps
) public pure returns (uint256) {
// Calculate the percentage change (multiplication first)
uint256 priceChange = (priceNow - pricePrevious) * 1e18 / pricePrevious;
// Calculate the fee (dividing last)
return priceChange * upliftFeeBps / 10000;
}
// Helper function to compare the results of both methods
function comparePrecisionLoss(
uint256 priceNow,
uint256 pricePrevious,
uint256 upliftFeeBps
) public pure returns (uint256 divBeforeMult, uint256 multBeforeDiv) {
divBeforeMult = divisionBeforeMultiplication(priceNow, pricePrevious, upliftFeeBps);
multBeforeDiv = multiplicationBeforeDivision(priceNow, pricePrevious, upliftFeeBps);
}
}
----------LOGS----------
divBeforeMult = 700000000000000000;
multBeforeDiv 711601267365216490;

As demonstrated, the result obtained by dividing before multiplication causes truncation, leading to a precision loss.

loss= 711601267365216490 - 700000000000000000 / 711601267365216490 = 11601267365216490 wei = 0.0116 eth

As you can see, the amount of loss is not insignificant, and this is just from a single transaction. Over time, with more transactions, this loss will accumulate.

Tools Used

Manual review.

Recommendations

To resolve the issue, adjust the decimals first by multiplying before performing the division.

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) {
address userAddress = address(bytes20(userData));
AfterRemoveLiquidityData memory localData = AfterRemoveLiquidityData({
pool: pool,
bptAmountIn: bptAmountIn,
amountsOutRaw: amountsOutRaw,
minAmountsOut: new uint256[](amountsOutRaw.length),
accruedFees: new uint256[](amountsOutRaw.length),
accruedQuantAMMFees: new uint256[](amountsOutRaw.length),
currentFee: minWithdrawalFeeBps,
feeAmount: 0,
prices: IUpdateWeightRunner(_updateWeightRunner).getData(pool),
lpTokenDepositValueNow: 0,
lpTokenDepositValueChange: 0,
lpTokenDepositValue: 0,
tokens: new IERC20[](amountsOutRaw.length),
feeDataArrayLength: 0,
amountLeft: 0,
feePercentage: 0,
adminFeePercent: 0
});
// We only allow removeLiquidity via the Router/Hook itself so that fee is applied correctly.
hookAdjustedAmountsOutRaw = amountsOutRaw;
//this rounding faxvours the LP
localData.lpTokenDepositValueNow = getPoolLPTokenValue(localData.prices, pool, MULDIRECTION.MULDOWN);
FeeData[] storage feeDataArray = poolsFeeData[pool][userAddress];
localData.feeDataArrayLength = feeDataArray.length;
localData.amountLeft = bptAmountIn;
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);
+ localData.lpTokenDepositValueChange =
+ (((int256(localData.lpTokenDepositValueNow) - int256(localData.lpTokenDepositValue))) * 1e18) /
+ 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;
+ feePerLP =
+ (uint256(localData.lpTokenDepositValueChange) * (uint256(feeDataArray[i].upliftFeeBps)) /
+ 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;
}
///code....
}
Updates

Lead Judging Commences

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

finding_onAfterRemoveLiquidity_lpTokenDepositValueChange_rounding_error_100%_minimum

Likelihood: High, every call to the function (withdraw) Impact: Low/Medium, uplift fees will be applied only when the price of one asset is doubled but fixed fees will still be collected.

Support

FAQs

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