Summary
In the calculation of feePerLP, the contract determines lpTokenDepositValueChange. However, due to rounding errors in this calculation, small price increases (less than double) are not accounted for correctly. As a result, users pay the minimum withdrawal fee (minWithdrawalFeeBps) instead of the higher uplift fee (upliftFeeBps) that should apply.
Vulnerability Details
In UpliftOnlyExample::onAfterRemoveLiquidity, the following calculation is performed:
localData.lpTokenDepositValueChange = (
int256(localData.lpTokenDepositValueNow) - int256(localData.lpTokenDepositValue)
) / int256(localData.lpTokenDepositValue);
uint256 feePerLP;
if (localData.lpTokenDepositValueChange > 0) {
feePerLP = (
uint256(localData.lpTokenDepositValueChange) * (uint256(feeDataArray[i].upliftFeeBps) * 1e18)
) / 10000;
}
Issue
The calculation divides lpTokenDepositValueNow - lpTokenDepositValue by lpTokenDepositValue before multiplying.
Solidity truncates the result of integer division, which can lead to the lpTokenDepositValueChange rounding down to zero for small price increases.
If lpTokenDepositValueChange is zero, users pay the minimum fee (minWithdrawalFeeBps) instead of the uplift fee (upliftFeeBps).
Impact
Users withdrawing after small price increases (e.g., 1.5x) bypass the correct uplift fee and pay only the minimum withdrawal fee. This results in reduced fees collected by the protocol and allows users to exploit the rounding issue.
Proof of Concept
The provided test demonstrates the issue:
function testRounding() 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[]();
for (uint256 i = 0; i < tokens.length; ++i) {
prices[i] = int256(i) * 15e17;
}
updateWeightRunner.setMockPrices(pool, prices);
uint256[] memory minAmountsOut = [uint256(0), uint256(0)].toMemoryArray();
BaseVaultTest.Balances memory balancesBefore = getBalances(bob);
vm.startPrank(bob);
upliftOnlyRouter.removeLiquidityProportional(bptAmount, minAmountsOut, false, pool);
vm.stopPrank();
BaseVaultTest.Balances memory balancesAfter = getBalances(bob);
uint256 feeAmountPercent =
((bptAmount / 2) * ((uint256(upliftOnlyRouter.minWithdrawalFeeBps()) * 1e18) / 10000)) / (bptAmount / 2);
uint256 amountOut = (bptAmount / 2).mulDown((1e18 - feeAmountPercent));
assertEq(
balancesAfter.bobTokens[daiIdx] - balancesBefore.bobTokens[daiIdx], amountOut, "Bob's DAI amount is wrong"
);
}
Tools Used
Manual review and unit testing.
Recommendations
To mitigate rounding errors, perform multiplication before division:
- 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 (localData.lpTokenDepositValueChange > 0) {
- feePerLP = (
- uint256(localData.lpTokenDepositValueChange) * (uint256(feeDataArray[i].upliftFeeBps) * 1e18)
- ) / 10000;
+ feePerLP = (
+ uint256(localData.lpTokenDepositValueChange) * uint256(feeDataArray[i].upliftFeeBps)
+ ) / 10000;
}
Benefits of the Fix
Ensures accurate fee calculations for small price increases.
Prevents users from bypassing uplift fees, preserving protocol revenue integrity.