Summary
The purpose of the Router/hook contract is to charge a fee when a user removes liquidity if the price has increased after the deposit. However, due to incorrect scaling, the uplift fee will not be applied if the price increases by up to 99%.
Vulnerability Details
When a user deposits assets, the router/hook contract fetches the price from the oracle, calculates the deposit value, and stores it in the FeeData mapping.
contracts/hooks-quantamm/UpliftOnlyExample.sol:249
249:
250:
251: uint256 depositValue = getPoolLPTokenValue(
252: IUpdateWeightRunner(_updateWeightRunner).getData(pool),
253: pool,
254: MULDIRECTION.MULDOWN
255: );
256:
257: poolsFeeData[pool][msg.sender].push(
258: FeeData({
259: tokenID: tokenID,
260: amount: exactBptAmountOut,
261:
262: lpTokenDepositValue: depositValue,
263:
264: blockTimestampDeposit: uint40(block.timestamp),
265: upliftFeeBps: upliftFeeBps
266: })
267: );
268:
At line 262, the current USD value is stored. When a user removes liquidity, the asset's price is recalculated and compared with the stored price. If there is an increase in price, the uplift fee is applied.
contracts/hooks-quantamm/UpliftOnlyExample.sol:474
474: localData.lpTokenDepositValueNow = getPoolLPTokenValue(localData.prices, pool, MULDIRECTION.MULDOWN);
475:
476: FeeData[] storage feeDataArray = poolsFeeData[pool][userAddress];
477: localData.feeDataArrayLength = feeDataArray.length;
478: localData.amountLeft = bptAmountIn;
479: for (uint256 i = localData.feeDataArrayLength - 1; i >= 0; --i) {
480: localData.lpTokenDepositValue = feeDataArray[i].lpTokenDepositValue;
483: localData.lpTokenDepositValueChange =
484: ((int256(localData.lpTokenDepositValueNow) - int256(localData.lpTokenDepositValue)) *1e18) /
485: int256(localData.lpTokenDepositValue);
486: uint256 feePerLP;
487:
488: if (localData.lpTokenDepositValueChange > 0) {
491: feePerLP =
492: (uint256(localData.lpTokenDepositValueChange) * (uint256(feeDataArray[i].upliftFeeBps) * 1e18)) /
493: 10000;
494: }
495:
496: else {
497:
498:
499: feePerLP = (uint256(minWithdrawalFeeBps) * 1e18) / 10000;
500: }
The issue here is that if the price increases by 99%, no uplift fee will be applied.
Let’s suppose:
1. Deposit price at time of deposit = `1e18`, so deposit value = `1e18`.
2. Price at withdrawal time = `1.99e18`, so deposit value = `1.99e18`.
3. Calculation: `(1.99e18 - 1e18) / 1e18 = 0.99e18`.
Since Solidity downcasts `0.99e18` to `0`, no uplift fee will be applied.
Coded POC :
function test_price_increase_but_no_uplift_fee_applied() 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) * 1.99e18;
}
updateWeightRunner.setMockPrices(pool, prices);
uint256 nftTokenId = 0;
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 feeAmountAmountPercent = ((bptAmount / 2) *
((uint256(upliftOnlyRouter.minWithdrawalFeeBps()) * 1e18) / 10000)) / ((bptAmount / 2));
uint256 amountOut = (bptAmount / 2).mulDown((1e18 - feeAmountAmountPercent));
assertEq(
balancesAfter.bobTokens[daiIdx] - balancesBefore.bobTokens[daiIdx],
amountOut,
"bob's DAI amount is wrong"
);
assertEq(
balancesAfter.bobTokens[usdcIdx] - balancesBefore.bobTokens[usdcIdx],
amountOut,
"bob's USDC amount is wrong"
);
assertEq(
balancesBefore.poolTokens[daiIdx] - balancesAfter.poolTokens[daiIdx],
amountOut,
"Pool's DAI amount is wrong"
);
assertEq(
balancesBefore.poolTokens[usdcIdx] - balancesAfter.poolTokens[usdcIdx],
amountOut,
"Pool's USDC amount is wrong"
);
assertEq(
balancesBefore.vaultTokens[daiIdx] - balancesAfter.vaultTokens[daiIdx],
amountOut,
"Vault's DAI amount is wrong"
);
assertEq(
balancesBefore.vaultTokens[usdcIdx] - balancesAfter.vaultTokens[usdcIdx],
amountOut,
"Vault's USDC amount is wrong"
);
assertEq(balancesBefore.hookTokens[daiIdx], balancesAfter.hookTokens[daiIdx], "Hook's DAI amount is wrong");
assertEq(balancesBefore.hookTokens[usdcIdx], balancesAfter.hookTokens[usdcIdx], "Hook's USDC amount is wrong");
assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob).length, 0, "bptAmount mapping should be 0");
assertEq(
BalancerPoolToken(pool).balanceOf(address(upliftOnlyRouter)),
0,
"upliftOnlyRouter should hold no BPT"
);
assertEq(balancesAfter.bobBpt, 0, "bob should not hold any BPT");
}
run with command : forge test --mt test_price_increase_but_no_uplift_fee_applied
Impact
Due to the incorrect calculation, no uplift fee will be applied even if the price increases by up to 99%.
Tools Used
Manual Review
Recommendations
The best solution for this issue could be to scale the value by 1e18 and then divide by 1e18 at the final step, as shown below:
@@ -474,17 +479,17 @@ contract UpliftOnlyExample is MinimalRouter, BaseHooks, Ownable {
for (uint256 i = localData.feeDataArrayLength - 1; i >= 0; --i) {
localData.lpTokenDepositValue = feeDataArray[i].lpTokenDepositValue;
console.log("localData.lpTokenDepositValue" , uint256(localData.lpTokenDepositValue));
-
+ //i.e => (1.99e18 - 1e18)*1e18/1e18 =>0.99e18
localData.lpTokenDepositValueChange =
- (int256(localData.lpTokenDepositValueNow) - int256(localData.lpTokenDepositValue)) /
- int256(localData.lpTokenDepositValue);
+ ((int256(localData.lpTokenDepositValueNow) - int256(localData.lpTokenDepositValue)) * 1e18) /
+ int256(localData.lpTokenDepositValue); // now lpTokenDepositValueChange in 1e18 scale
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) { // @note : it will not work with bps less than 1
- console.log("UpLift Fee Applied");
+ // i.e (990000000000000000 * 5000 * 1e18 / 10000) / 1e18 => 0.45e18 which is exaclty 50% per profit
feePerLP =
- (uint256(localData.lpTokenDepositValueChange) * (uint256(feeDataArray[i].upliftFeeBps) * 1e18)) /
- 10000;
+ ((uint256(localData.lpTokenDepositValueChange) * (uint256(feeDataArray[i].upliftFeeBps) * 1e18)) /
+ 10000)/1e18;