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.
The issue resides in onAfterRemoveLiquidity within UpliftOnlyExample.sol. Specifically, the call:
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.
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";
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;
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");
poolHooksContract = address(upliftOnlyRouter);
(pool, ) = createPool();
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));
}
initPool();
(daiIdx, usdcIdx) = getSortedIndexes(address(dai), address(usdc));
}
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);
}
}
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();
}
}
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();
}
}
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: