QuantAMM

QuantAMM
49,600 OP
View results
Submission Details
Severity: low
Invalid

Incorrect minBptAmountOut Calculation in onAfterRemoveLiquidity Can Cause Permanent Fund Lock

01. Relevant GitHub Links

02. Summary

When the UpliftOnlyExample contract calls onAfterRemoveLiquidity, it attempts to send fees to the admin by re-adding them as liquidity using _vault.addLiquidity(). However, the minBptAmountOut parameter is incorrectly set to the total tokens allocated for fees instead of the actual amount of BPT tokens that will be minted. This design error can lead to reverts in pools that use a different invariant mechanism (i.e., a scenario where a larger amount of tokens would be required compared to maxAmountsIn), preventing users from removing liquidity and effectively locking their funds.

03. Vulnerability Details

The issue resides in onAfterRemoveLiquidity within UpliftOnlyExample.sol. Specifically, the call:

_vault.addLiquidity(
AddLiquidityParams({
pool: localData.pool,
to: IUpdateWeightRunner(_updateWeightRunner).getQuantAMMAdmin(),
maxAmountsIn: localData.accruedQuantAMMFees,
minBptAmountOut: localData.feeAmount.mulDown(localData.adminFeePercent) / 1e18,
kind: AddLiquidityKind.PROPORTIONAL,
userData: bytes("")
})
);

Here, minBptAmountOut is being calculated using feeAmount.mulDown(adminFeePercent) / 1e18, which corresponds to the sum of tokens (in base units), not the amount of BPT tokens that will actually be minted.

When a pool uses a different invariant strategy (i.e., not a simple sum-invariant approach), it might require more tokens than the maxAmountsIn provided to mint the calculated BPT. If minBptAmountOut is higher than the actual mintable BPT, the operation reverts, preventing any removal of liquidity.

In the worst case, this leads to a scenario in which users cannot withdraw their liquidity at all, effectively locking their assets in the pool.

Since removing liquidity is a fundamental operation, any error preventing it can severely impact usability and capital safety. A revert during the withdrawal process—especially if triggered unconditionally—leads to an inability to recover funds.

04. Impact

  • Locked Funds: Users who have contributed liquidity to pools that use invariants requiring different token-to-BPT calculations will be unable to remove liquidity because of continuous reverts.

  • Loss of Functionality: The contract’s intended fee distribution mechanism becomes ineffective.

05. Proof of Concept

  • Because this issue arises when transferring fees to the admin, you must set the admin fee by calling updateWeightRunner.setQuantAMMSwapFeeTake(1e18); before running the PoC.

// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol";
import { IVaultErrors } from "@balancer-labs/v3-interfaces/contracts/vault/IVaultErrors.sol";
import {
LiquidityManagement,
PoolRoleAccounts,
RemoveLiquidityKind,
AfterSwapParams,
SwapKind,
AddLiquidityKind
} from "@balancer-labs/v3-interfaces/contracts/vault/VaultTypes.sol";
import { CastingHelpers } from "@balancer-labs/v3-solidity-utils/contracts/helpers/CastingHelpers.sol";
import { ArrayHelpers } from "@balancer-labs/v3-solidity-utils/contracts/test/ArrayHelpers.sol";
import { BalancerPoolToken } from "@balancer-labs/v3-vault/contracts/BalancerPoolToken.sol";
import { BaseVaultTest } from "@balancer-labs/v3-vault/test/foundry/utils/BaseVaultTest.sol";
import { QuantAMMWeightedPool } from "../../contracts/QuantAMMWeightedPool.sol";
import { QuantAMMWeightedPoolFactory } from "../../contracts/QuantAMMWeightedPoolFactory.sol";
import { QuantAMMWeightedPoolContractsDeployer } from "./utils/QuantAMMWeightedPoolContractsDeployer.sol";
import { PoolSwapParams, SwapKind } from "@balancer-labs/v3-interfaces/contracts/vault/VaultTypes.sol";
import { OracleWrapper } from "@balancer-labs/v3-interfaces/contracts/pool-quantamm/OracleWrapper.sol";
import { MockUpdateWeightRunner } from "../../contracts/mock/MockUpdateWeightRunner.sol";
import { MockMomentumRule } from "../../contracts/mock/mockRules/MockMomentumRule.sol";
import { MockChainlinkOracle } from "../../contracts/mock/MockChainlinkOracles.sol";
import "@balancer-labs/v3-interfaces/contracts/pool-quantamm/IQuantAMMWeightedPool.sol";
// @note : add 'pool-hooks/=../pool-hooks/', remapping
import { UpliftOnlyExample } from "pool-hooks/contracts/hooks-quantamm/UpliftOnlyExample.sol";
import { LPNFT } from "pool-hooks/contracts/hooks-quantamm/LPNFT.sol";
import { BaseTest } from "@balancer-labs/v3-solidity-utils/test/foundry/utils/BaseTest.sol";
import { IVaultExtension } from "@balancer-labs/v3-interfaces/contracts/vault/IVaultExtension.sol";
import { IVaultAdmin } from "@balancer-labs/v3-interfaces/contracts/vault/IVaultAdmin.sol";
import { IVaultMock } from "@balancer-labs/v3-interfaces/contracts/test/IVaultMock.sol";
import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol";
import { IHooks } from "@balancer-labs/v3-interfaces/contracts/vault/IHooks.sol";
import { BasicAuthorizerMock } from "@balancer-labs/v3-vault/contracts/test/BasicAuthorizerMock.sol";
import { PoolFactoryMock } from "@balancer-labs/v3-vault/contracts/test/PoolFactoryMock.sol";
contract TestQuantAMMWeightedPoolUpliftOnly is QuantAMMWeightedPoolContractsDeployer, BaseVaultTest {
using CastingHelpers for address[];
using ArrayHelpers for *;
uint256 internal daiIdx;
uint256 internal usdcIdx;
// Maximum swap fee of 10%
uint64 public constant MAX_SWAP_FEE_PERCENTAGE = 10e16;
QuantAMMWeightedPoolFactory internal quantAMMWeightedPoolFactory;
UpliftOnlyExample internal upliftOnlyRouter;
function setUp() public override {
BaseTest.setUp();
(address ownerLocal, address addr1Local, address addr2Local) = (vm.addr(1), vm.addr(2), vm.addr(3));
owner = ownerLocal;
addr1 = addr1Local;
addr2 = addr2Local;
int216 fixedValue = 1000;
uint delay = 3600;
vault = deployVaultMock();
vm.label(address(vault), "vault");
vaultExtension = IVaultExtension(vault.getVaultExtension());
vm.label(address(vaultExtension), "vaultExtension");
vaultAdmin = IVaultAdmin(vault.getVaultAdmin());
vm.label(address(vaultAdmin), "vaultAdmin");
authorizer = BasicAuthorizerMock(address(vault.getAuthorizer()));
vm.label(address(authorizer), "authorizer");
factoryMock = PoolFactoryMock(address(vault.getPoolFactoryMock()));
vm.label(address(factoryMock), "factory");
router = deployRouterMock(IVault(address(vault)), weth, permit2);
vm.label(address(router), "router");
batchRouter = deployBatchRouterMock(IVault(address(vault)), weth, permit2);
vm.label(address(batchRouter), "batch router");
feeController = vault.getProtocolFeeController();
vm.label(address(feeController), "fee controller");
vm.startPrank(address(vaultAdmin));
updateWeightRunner = new MockUpdateWeightRunner(address(vaultAdmin), address(addr2), true);
vm.label(address(updateWeightRunner), "updateWeightRunner");
updateWeightRunner.setQuantAMMSwapFeeTake(1e18);
chainlinkOracle = _deployOracle(fixedValue, delay);
updateWeightRunner.addOracle(OracleWrapper(chainlinkOracle));
vm.stopPrank();
vm.startPrank(owner);
upliftOnlyRouter = new UpliftOnlyExample(
IVault(address(vault)),
weth,
permit2,
200,
5,
address(updateWeightRunner),
"Uplift LiquidityPosition v1",
"Uplift LiquidityPosition v1",
"Uplift LiquidityPosition v1"
);
vm.stopPrank();
vm.label(address(upliftOnlyRouter), "upliftOnlyRouter");
// Here the Router is also the hook.
poolHooksContract = address(upliftOnlyRouter);
(pool, ) = createPool();
// Approve vault allowances.
for (uint256 i = 0; i < users.length; ++i) {
address user = users[i];
vm.startPrank(user);
approveForSender();
vm.stopPrank();
}
if (pool != address(0)) {
approveForPool(IERC20(pool));
}
// Add initial liquidity.
initPool();
(daiIdx, usdcIdx) = getSortedIndexes(address(dai), address(usdc));
}
// Overrides approval to include upliftOnlyRouter.
function approveForSender() internal override {
for (uint256 i = 0; i < tokens.length; ++i) {
tokens[i].approve(address(permit2), type(uint256).max);
permit2.approve(address(tokens[i]), address(router), type(uint160).max, type(uint48).max);
permit2.approve(address(tokens[i]), address(batchRouter), type(uint160).max, type(uint48).max);
permit2.approve(address(tokens[i]), address(upliftOnlyRouter), type(uint160).max, type(uint48).max);
}
}
// Overrides approval to include upliftOnlyRouter.
function approveForPool(IERC20 bpt) internal override {
for (uint256 i = 0; i < users.length; ++i) {
vm.startPrank(users[i]);
bpt.approve(address(router), type(uint256).max);
bpt.approve(address(batchRouter), type(uint256).max);
bpt.approve(address(upliftOnlyRouter), type(uint256).max);
IERC20(bpt).approve(address(permit2), type(uint256).max);
permit2.approve(address(bpt), address(router), type(uint160).max, type(uint48).max);
permit2.approve(address(bpt), address(batchRouter), type(uint160).max, type(uint48).max);
permit2.approve(address(bpt), address(upliftOnlyRouter), type(uint160).max, type(uint48).max);
vm.stopPrank();
}
}
// Overrides pool creation to set liquidityManagement (disables unbalanced liquidity).
function _createPool(
address[] memory tokens,
string memory label
) internal override returns (address newPool, bytes memory poolArgs) {
quantAMMWeightedPoolFactory = deployQuantAMMWeightedPoolFactory(
IVault(address(vault)),
365 days,
"Factory v1",
"Pool v1"
);
vm.label(address(quantAMMWeightedPoolFactory), "quantamm weighted pool factory");
QuantAMMWeightedPoolFactory.NewPoolParams memory params = _createPoolParams();
params.poolHooksContract = poolHooksContract;
params.disableUnbalancedLiquidity = true;
(address quantAMMWeightedPool, ) = quantAMMWeightedPoolFactory.create(params);
newPool = quantAMMWeightedPool;
vm.label(newPool, label);
int256[] memory prices = new int256[]();
for (uint256 i = 0; i < tokens.length; ++i) {
prices[i] = int256(i) * 1e18;
}
updateWeightRunner.setMockPrices(address(newPool), prices);
PoolRoleAccounts memory roleAccounts;
roleAccounts.poolCreator = lp;
LiquidityManagement memory liquidityManagement;
liquidityManagement.disableUnbalancedLiquidity = true;
liquidityManagement.enableDonation = true;
}
function _createPoolParams() internal returns (QuantAMMWeightedPoolFactory.NewPoolParams memory retParams) {
PoolRoleAccounts memory roleAccounts;
IERC20[] memory tokens = [address(dai), address(usdc)].toMemoryArray().asIERC20();
MockMomentumRule momentumRule = new MockMomentumRule(owner);
uint32[] memory weights = new uint32[]();
weights[0] = uint32(uint256(0.5e18));
weights[1] = uint32(uint256(0.5e18));
int256[] memory initialWeights = new int256[]();
initialWeights[0] = 0.5e18;
initialWeights[1] = 0.5e18;
uint256[] memory initialWeightsUint = new uint256[]();
initialWeightsUint[0] = 0.5e18;
initialWeightsUint[1] = 0.5e18;
uint64[] memory lambdas = new uint64[]();
lambdas[0] = 0.2e18;
int256[][] memory parameters = new int256[][]();
parameters[0] = new int256[]();
parameters[0][0] = 0.2e18;
address[][] memory oracles = new address[][]();
oracles[0] = new address[]();
oracles[0][0] = address(chainlinkOracle);
retParams = QuantAMMWeightedPoolFactory.NewPoolParams(
"Pool With Donation",
"PwD",
vault.buildTokenConfig(tokens),
initialWeightsUint,
roleAccounts,
MAX_SWAP_FEE_PERCENTAGE,
address(0),
true,
false,
ZERO_BYTES32,
initialWeights,
IQuantAMMWeightedPool.PoolSettings(
new IERC20[](2),
IUpdateRule(momentumRule),
oracles,
60,
lambdas,
0.2e18,
0.2e18,
0.2e18,
parameters,
address(0)
),
initialWeights,
initialWeights,
3600,
0,
new string[][]()
);
}
function test_AddandRemoveLiquidity() public {
uint256[] memory maxAmountsIn = [dai.balanceOf(bob), usdc.balanceOf(bob)].toMemoryArray();
vm.prank(bob);
uint256[] memory amountsIn = upliftOnlyRouter.addLiquidityProportional(
pool,
maxAmountsIn,
bptAmount,
false,
bytes("")
);
vm.stopPrank();
uint256[] memory minAmountsOut = [uint256(0), uint256(0)].toMemoryArray();
vm.expectRevert();
vm.startPrank(bob);
upliftOnlyRouter.removeLiquidityProportional(bptAmount, minAmountsOut, false, pool);
vm.stopPrank();
}
}

You can reproduce the PoC by placing the test script in the pkg/pool-quantamm/test/foundry directory, adding 'pool-hooks/=../pool-hooks/' to the remapping section of pkg/pool-quantamm/foundry.toml, and then running:

bshyuunn@hyuunn-MacBook-Air pool-quantamm % forge test --mt test_AddandRemoveLiquidity --mc TestQuantAMMWeightedPoolUpliftOnly -vv
[⠢] Compiling...
No files changed, compilation skipped
test/foundry/mytest.t.sol:TestQuantAMMWeightedPoolUpliftOnly
↪ Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 44.28ms (3.45ms CPU time)
Ran 1 test for test/foundry/mytest.t.sol:TestQuantAMMWeightedPoolUpliftOnly
[PASS] test_AddandRemoveLiquidity() (gas: 730952)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 44.28ms (3.45ms CPU time)
Ran 1 test suite in 300.57ms (44.28ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

06. Tools Used

Manual Code Review and Foundry

07. Recommended Mitigation

Instead of setting minBptAmountOut to the sum of tokens intended for the fee, compute the actual BPT tokens that will be minted based on the pool’s invariant. Use that correct minted BPT value as the minBptAmountOut parameter. For example:

_vault.addLiquidity(
AddLiquidityParams({
pool: localData.pool,
to: IUpdateWeightRunner(_updateWeightRunner).getQuantAMMAdmin(),
maxAmountsIn: localData.accruedQuantAMMFees,
- minBptAmountOut: localData.feeAmount.mulDown(localData.adminFeePercent) / 1e18,
+ minBptAmountOut: acutalBPTAmount,
kind: AddLiquidityKind.PROPORTIONAL,
userData: bytes("")
})
);
Updates

Lead Judging Commences

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

finding_onAfterRemoveLiquidity_vault.addLiquidity_rounding_precision_DoS

Likelihood: High, multiple rounding down and little value can trigger the bug Impact: High, DoS the removal.

Appeal created

bshyuunn Submitter
11 months ago
huntoor Auditor
11 months ago
n0kto Lead Judge
11 months ago
n0kto Lead Judge 10 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
Assigned finding tags:

Informational or Gas / Admin is trusted / Pool creation is trusted / User mistake / Suppositions

Please read the CodeHawks documentation to know which submissions are valid. If you disagree, provide a coded PoC and explain the real likelyhood and the detailed impact on the mainnet without any supposition (if, it could, etc) to prove your point.

Support

FAQs

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

Give us feedback!