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 (localData.lpTokenDepositValueChange > 0) {
feePerLP =
(uint256(localData.lpTokenDepositValueChange) * (uint256(feeDataArray[i].upliftFeeBps) * 1e18)) /
10000;
}
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 "forge-std/console2.sol";
import {IVaultExplorer} from "@balancer-labs/v3-interfaces/contracts/vault/IVaultExplorer.sol";
contract UpliftOnlyExampleTest is BaseVaultTest {
function testRemoveLiquiditySmallPositivePriceChangeWrongFee() public {
uint256[] memory maxAmountsIn = [dai.balanceOf(bob), usdc.balanceOf(bob)].toMemoryArray();
vm.prank(bob);
upliftOnlyRouter.addLiquidityProportional(pool, maxAmountsIn, bptAmount, false, bytes(""));
vm.stopPrank();
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);
console2.log("upliftOnlyRouter.upliftFeeBps: ", upliftOnlyRouter.upliftFeeBps());
console2.log("upliftOnlyRouter.minWithdrawalFeeBps: ", upliftOnlyRouter.minWithdrawalFeeBps());
int256 lpTokenDepositValueChange = (priceMultiplier - 1e18) * 1e18 / 1e18;
console2.log("lpTokenDepositValueChange: ", lpTokenDepositValueChange);
assertTrue(lpTokenDepositValueChange > 0, "lpTokenDepositValueChange should be positive");
uint256 feePerLPExpected = uint256(lpTokenDepositValueChange) * uint256(upliftOnlyRouter.upliftFeeBps()) / 10000;
uint256 feePercentageExpectedBps = (bptAmount * feePerLPExpected) / bptAmount * 10000 / 1e18;
console2.log("feePercentageExpectedBps: ", feePercentageExpectedBps);
uint256 bptTotalSupplyBeforeRemoveLiquidity = IVaultExplorer(address(vault)).totalSupply(pool);
uint256[] memory poolBalancesBeforeRemoveLiquidity =
IVaultExplorer(address(vault)).getPoolData(pool).balancesLiveScaled18;
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();
uint256[] memory amountsOutRaw = new uint256[]();
for (uint256 i = 0; i < 2; ++i) {
amountsOutRaw[i] = poolBalancesBeforeRemoveLiquidity[i] * bptAmount / bptTotalSupplyBeforeRemoveLiquidity;
}
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]);
}
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:
-
Modify the computation of lpTokenDepositValueChange:
localData.lpTokenDepositValueChange =
- (int256(localData.lpTokenDepositValueNow) - int256(localData.lpTokenDepositValue)) /
+ (int256(localData.lpTokenDepositValueNow) - int256(localData.lpTokenDepositValue)) * 1e18 /
int256(localData.lpTokenDepositValue);
-
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.