QuantAMM

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

“Uplift Fee” Incorrectly Falls Back to Minimum Fee Due to Integer Division

Summary

A critical arithmetic error in the onAfterRemoveLiquidity function causes a protocol pool’s “uplift” fee calculation to zero out whenever the pool’s value grows less than 100%. Instead of charging an uplift fee, the code mistakenly reverts to the minimum fee, significantly under-collecting fees for moderate or even substantial positive gains. A proof-of-concept demonstrates that with a 50% price increase, the code applies only the minimum fee (5 BPS) instead of the intended 100 BPS uplift fee, potentially leading to major revenue loss.

Vulnerability Details

In the UpliftOnlyExample.sol#L474-L476 snippet below, the deposit’s value change lpTokenDepositValueChange is computed with integer division:

localData.lpTokenDepositValueChange =
(int256(localData.lpTokenDepositValueNow) - int256(localData.lpTokenDepositValue)) /
int256(localData.lpTokenDepositValue);

Since Solidity integer division truncates any fractional part, any gain smaller than the deposit’s full value leads to lpTokenDepositValueChange being zero. For example, if a position grows 50% (valueNow = 1.5 × depositValue), the difference (1.5D - 1.0D) is 0.5D, but integer division by 1.0D yields zero.

The immediate downstream effect is shown in UpliftOnlyExample.sol#L478-L490. The code checks whether lpTokenDepositValueChange > 0. If it is zero, the logic incorrectly falls back to the “minimum withdrawal fee,” instead of the intended “uplift fee.”

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
else {
feePerLP = (uint256(minWithdrawalFeeBps) * 1e18) / 10000;
}

Hence, even when a depositor’s position appreciates by a moderate percentage (e.g., 10%, 50%, or 80%), the computed lpTokenDepositValueChange remains 0 and triggers the fallback minWithdrawalFeeBps. This drastically undercharges withdrawal fees, effectively nullifying the “uplift” fee logic for positive price changes under 100% growth. As a result, LPs will pay only the minimal fee in scenarios where they would normally be subject to a higher fee based on their real gains.

Impact

This flaw in fee calculation allows LPs to systematically evade the intended uplift fee whenever their position appreciates less than 100%. In real-world conditions, even moderate gains (like 10%, 30%, or 50%) get zeroed out, causing the protocol to charge only the minimum fee instead of a proportionally larger fee based on the actual uplift. As a result, the protocol forfeits significant revenue and violates the intended fee structure:

  • Serious Undercharging: The pool cannot collect the correct “uplift” portion, leading to lost revenue that can accumulate quickly over multiple deposits/withdrawals.

  • Broken Fee Mechanism: The entire premise of an uplift-based fee becomes unreliable, since nearly any typical growth scenario falls below a factor of 2×, thus never triggering the intended logic.

  • Exploitable for Profit: Users aware of this vulnerability can strategically deposit and withdraw under moderate gains to exploit the cheaper fee, amplifying potential losses for the protocol.

Below is a proof-of-concept test demonstrating the incorrect fallback to the minimal fee in a “small positive price change” scenario. The test shows that a 50% increase in pool value still charges only the minimum withdrawal fee instead of the uplift fee:

PoC Test Code

// import ...
import "forge-std/console2.sol";
import {IVaultExplorer} from "@balancer-labs/v3-interfaces/contracts/vault/IVaultExplorer.sol";
// import ...
contract UpliftOnlyExampleTest is BaseVaultTest {
// other test functions ...
// PoC test function
function testRemoveLiquiditySmallPositivePriceChangeWrongFee() public {
// Add liquidity
uint256[] memory maxAmountsIn = [dai.balanceOf(bob), usdc.balanceOf(bob)].toMemoryArray();
vm.prank(bob);
upliftOnlyRouter.addLiquidityProportional(pool, maxAmountsIn, bptAmount, false, bytes(""));
vm.stopPrank();
// Set prices to be 3/2 of the initial prices (int256(i) * 1e18)
// (refer to the test function `testRemoveLiquidityDoublePositivePriceChange`, #L551)
int256[] memory prices = new int256[]();
int256 priceMultiplier = 3e18 / 2;
for (uint256 i = 0; i < tokens.length; ++i) {
prices[i] = int256(i) * priceMultiplier;
}
updateWeightRunner.setMockPrices(pool, prices);
// upliftOnlyRouter default fee rates
console2.log("upliftOnlyRouter.upliftFeeBps: ", upliftOnlyRouter.upliftFeeBps());
console2.log("upliftOnlyRouter.minWithdrawalFeeBps: ", upliftOnlyRouter.minWithdrawalFeeBps());
// lpTokenDepositValueChange should only consider prices change ratio, since pool balances remain the same
// (priceMultiplier - 1e18) times 1e18 before division to avoid rounding to zero
// (refer to the function `getPoolLPTokenValue` of the contract `UpliftOnlyExample`, #L672)
// (refer to the logics of test function `testRemoveLiquidityDoublePositivePriceChange`)
int256 lpTokenDepositValueChange = (priceMultiplier - 1e18) * 1e18 / 1e18;
console2.log("lpTokenDepositValueChange: ", lpTokenDepositValueChange);
assertTrue(lpTokenDepositValueChange > 0, "lpTokenDepositValueChange should be positive");
// Calculate expected feePerLP and feePercentageBps
// (refer to the function `onAfterRemoveLiquidity` of the contract `UpliftOnlyExample`, #L480-484)
// (refer to the function `onAfterRemoveLiquidity` of the contract `UpliftOnlyExample`, #L495, #L514)
uint256 feePerLPExpected = uint256(lpTokenDepositValueChange) * uint256(upliftOnlyRouter.upliftFeeBps()) / 10000;
uint256 feePercentageExpectedBps = (bptAmount * feePerLPExpected) / bptAmount * 10000 / 1e18;
console2.log("feePercentageExpectedBps: ", feePercentageExpectedBps);
// Record pool states before remove liquidity
uint256 bptTotalSupplyBeforeRemoveLiquidity = IVaultExplorer(address(vault)).totalSupply(pool);
uint256[] memory poolBalancesBeforeRemoveLiquidity =
IVaultExplorer(address(vault)).getPoolData(pool).balancesLiveScaled18;
// Remove liquidity
uint256[] memory minAmountsOut = [uint256(0), uint256(0)].toMemoryArray();
uint256[] memory amountsOut = new uint256[]();
vm.startPrank(bob);
amountsOut = upliftOnlyRouter.removeLiquidityProportional(bptAmount, minAmountsOut, false, pool);
vm.stopPrank();
// Calculate raw amountsOut in proportional manner when remove liquidity
// (refer to the function `computeProportionalAmountsOut` of the contract `BasePoolMath`, #L106)
uint256[] memory amountsOutRaw = new uint256[]();
for (uint256 i = 0; i < 2; ++i) {
amountsOutRaw[i] = poolBalancesBeforeRemoveLiquidity[i] * bptAmount / bptTotalSupplyBeforeRemoveLiquidity;
}
// Calculate fee amounts and percentages
uint256[] memory feeAmounts = new uint256[]();
uint256[] memory feePercentagesActualBps = new uint256[]();
for (uint256 i = 0; i < 2; ++i) {
feeAmounts[i] = amountsOutRaw[i] - amountsOut[i];
feePercentagesActualBps[i] = feeAmounts[i] * 10000 / amountsOutRaw[i];
console2.log("feeAmounts[", i, "]: ", feeAmounts[i]);
console2.log("feePercentagesActualBps[", i, "]: ", feePercentagesActualBps[i]);
}
// Check if the fee percentages are matched with the designed rates
for (uint256 i = 0; i < 2; ++i) {
assertTrue(
feePercentagesActualBps[i] != feePercentageExpectedBps,
"fee percentage is not based on the uplift fee rate due to the integer division rounding vulnerability"
);
assertTrue(
feePercentagesActualBps[i] == upliftOnlyRouter.minWithdrawalFeeBps(),
"fee percentage is the minimum fee rate due to the integer division rounding vulnerability"
);
}
}
}

Test Command

forge test --mc UpliftOnlyExampleTest --mt testRemoveLiquiditySmallPositivePriceChangeWrongFee -vv

Test Result

Ran 1 test for test/foundry/UpliftExample.t.sol:UpliftOnlyExampleTest
[PASS] testRemoveLiquiditySmallPositivePriceChangeWrongFee() (gas: 637610)
Logs:
upliftOnlyRouter.upliftFeeBps: 200
upliftOnlyRouter.minWithdrawalFeeBps: 5
lpTokenDepositValueChange: 500000000000000000
feePercentageExpectedBps: 100
feeAmounts[ 0 ]: 500000000000000000
feePercentagesActualBps[ 0 ]: 5
feeAmounts[ 1 ]: 500000000000000000
feePercentagesActualBps[ 1 ]: 5
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 28.03ms (2.82ms CPU time)
Ran 1 test suite in 127.25ms (28.03ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Tools Used

Manual Review

Recommendations

To fix the integer‐division issue, scale up the difference by 1e18 before dividing by the original deposit value. This ensures fractional parts are not truncated to zero. Specifically:

  1. Modify the computation of lpTokenDepositValueChange:

    localData.lpTokenDepositValueChange =
    - (int256(localData.lpTokenDepositValueNow) - int256(localData.lpTokenDepositValue)) /
    + (int256(localData.lpTokenDepositValueNow) - int256(localData.lpTokenDepositValue)) * 1e18 /
    int256(localData.lpTokenDepositValue);
  2. Remove the extra * 1e18 factor (or equivalently, do not multiply by 1e18 a second time) when computing feePerLP:

    if (localData.lpTokenDepositValueChange > 0) {
    feePerLP =
    - (uint256(localData.lpTokenDepositValueChange) * (uint256(feeDataArray[i].upliftFeeBps) * 1e18))
    + uint256(localData.lpTokenDepositValueChange) * uint256(feeDataArray[i].upliftFeeBps)
    / 10000;
    } else {
    feePerLP = (uint256(minWithdrawalFeeBps) * 1e18) / 10000;
    }

With these changes, lpTokenDepositValueChange accurately represents a scaled fraction of growth (e.g., 0.5e18 for a 50% gain). The subsequent fee logic then compares it against zero as intended, preventing the protocol from under‐charging fees on modest or moderate gains. This aligns the computed fee with the pool’s actual value appreciation.

Updates

Lead Judging Commences

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

Give us feedback!