Summary
Vault.removeLiquidity() with shouldCallAfterRemoveLiquidity = true will be reverted.
https://github.com/Cyfrin/2024-12-quantamm/blob/a775db4273eb36e7b4536c5b60207c9f17541b92/pkg/pool-hooks/contracts/hooks-quantamm/UpliftOnlyExample.sol#L431-L570
Vulnerability Details
UpliftOnlyExample.sol#onAfterRemoveLiquidity() function is as follows.
function onAfterRemoveLiquidity(
address router,
address pool,
RemoveLiquidityKind,
uint256 bptAmountIn,
uint256[] memory,
uint256[] memory amountsOutRaw,
uint256[] memory,
bytes memory userData
) public override onlySelfRouter(router) returns (bool, uint256[] memory hookAdjustedAmountsOutRaw) {
address userAddress = address(bytes20(userData));
...
514@> localData.feePercentage = (localData.feeAmount) / bptAmountIn;
hookAdjustedAmountsOutRaw = localData.amountsOutRaw;
localData.tokens = _vault.getPoolTokens(localData.pool);
localData.adminFeePercent = IUpdateWeightRunner(_updateWeightRunner).getQuantAMMUpliftFeeTake();
for (uint256 i = 0; i < localData.amountsOutRaw.length; i++) {
523@> uint256 exitFee = localData.amountsOutRaw[i].mulDown(localData.feePercentage);
if (localData.adminFeePercent > 0) {
526@> localData.accruedQuantAMMFees[i] = exitFee.mulDown(localData.adminFeePercent);
}
localData.accruedFees[i] = exitFee - localData.accruedQuantAMMFees[i];
hookAdjustedAmountsOutRaw[i] -= exitFee;
}
if (localData.adminFeePercent > 0) {
_vault.addLiquidity(
AddLiquidityParams({
pool: localData.pool,
to: IUpdateWeightRunner(_updateWeightRunner).getQuantAMMAdmin(),
541@> maxAmountsIn: localData.accruedQuantAMMFees,
542@> minBptAmountOut: localData.feeAmount.mulDown(localData.adminFeePercent) / 1e18,
kind: AddLiquidityKind.PROPORTIONAL,
userData: bytes("")
})
);
...
}
...
return (true, hookAdjustedAmountsOutRaw);
}
On L541~542, the protocol mints bpt with maxAmountIn = localData.accruedQuantAMMFees.
The localData.accruedQuantAMMFees is calculated on L526 with rounding down.
And output data are all calculated with rounding down.
Because of rounding down, maxAmountIn can be less than amount needed for minting bptAmount.
Proof of Concept
We say that there are 2 tokens in a pool. In onAfterRemoveLiquidity(), And bptAmountIn = 100.00...200e18 and localData.feeAmount = 1.00...2e18(* 1e18). So feePercentage is 1%(0.01e18).
For output tokens [token0, token1], amountsOutRaw[0] = 100.00...199e18, amountsOutRaw[1] = 100e18.
We say that adminFeePercent = 50%(=0.5e18).
Then, on L541, accruedQuantAMMFees becomes [0.5e18, 0.5e18].
And on L542, minBptAmountOut is 1.00...2e18 * 0.5 = 0.50...1e18.
We say that decimals of token0 is 18 and is standard token.
Then, its decimalScalingFactor and tokenRate are all 1.
In this case, the amount of token0 to mint bpt is ceil(0.50...1e18 * 100.00...199e18 / 100.00...200e18) = 0.50...1e18 > 0.5e10(maxAmountsIn).
So this call is reverted.
We can see this fact from following codes.
Vault.addLiquidity() function is as follows.
function addLiquidity(
AddLiquidityParams memory params
)
external
onlyWhenUnlocked
withInitializedPool(params.pool)
returns (uint256[] memory amountsIn, uint256 bptAmountOut, bytes memory returnData)
{
...
uint256[] memory amountsInScaled18;
@> (amountsIn, amountsInScaled18, bptAmountOut, returnData) = _addLiquidity(
poolData,
params,
maxAmountsInScaled18
);
...
}
_addLiquidity() function is as follows.
function _addLiquidity(
PoolData memory poolData,
AddLiquidityParams memory params,
uint256[] memory maxAmountsInScaled18
)
internal
nonReentrant
returns (
uint256[] memory amountsInRaw,
uint256[] memory amountsInScaled18,
uint256 bptAmountOut,
bytes memory returnData
)
{
LiquidityLocals memory locals;
locals.numTokens = poolData.tokens.length;
amountsInRaw = new uint256[](locals.numTokens);
uint256[] memory swapFeeAmounts;
if (params.kind == AddLiquidityKind.PROPORTIONAL) {
@> bptAmountOut = params.minBptAmountOut;
swapFeeAmounts = new uint256[](locals.numTokens);
@> amountsInScaled18 = BasePoolMath.computeProportionalAmountsIn(
poolData.balancesLiveScaled18,
_totalSupply(params.pool),
bptAmountOut
);
} else if (params.kind == AddLiquidityKind.DONATION) {
poolData.poolConfigBits.requireDonationEnabled();
swapFeeAmounts = new uint256[](maxAmountsInScaled18.length);
bptAmountOut = 0;
amountsInScaled18 = maxAmountsInScaled18;
} ...
if (bptAmountOut < params.minBptAmountOut) {
revert BptAmountOutBelowMin(bptAmountOut, params.minBptAmountOut);
}
_ensureValidTradeAmount(bptAmountOut);
for (uint256 i = 0; i < locals.numTokens; ++i) {
uint256 amountInRaw;
{
uint256 amountInScaled18 = amountsInScaled18[i];
_ensureValidTradeAmount(amountInScaled18);
if (amountsInRaw[i] == 0) {
@> amountInRaw = amountInScaled18.toRawUndoRateRoundUp(
poolData.decimalScalingFactors[i],
poolData.tokenRates[i]
);
amountsInRaw[i] = amountInRaw;
} else {
amountInRaw = amountsInRaw[i];
}
}
IERC20 token = poolData.tokens[i];
728@> if (amountInRaw > params.maxAmountsIn[i]) {
revert AmountInAboveMax(token, amountInRaw, params.maxAmountsIn[i]);
}
...
}
...
}
By L728, the call can be reverted.
Impact
From this vulnerability, a user cannot remove liquidity from protocol.
Tools Used
Manual review
Recommendations
We have to modify UpliftOnlyExample.sol#onAfterRemoveLiquidity() function as follows.
function onAfterRemoveLiquidity(
address router,
address pool,
RemoveLiquidityKind,
uint256 bptAmountIn,
uint256[] memory,
uint256[] memory amountsOutRaw,
uint256[] memory,
bytes memory userData
) public override onlySelfRouter(router) returns (bool, uint256[] memory hookAdjustedAmountsOutRaw) {
address userAddress = address(bytes20(userData));
...
localData.feePercentage = (localData.feeAmount) / bptAmountIn;
hookAdjustedAmountsOutRaw = localData.amountsOutRaw;
localData.tokens = _vault.getPoolTokens(localData.pool);
localData.adminFeePercent = IUpdateWeightRunner(_updateWeightRunner).getQuantAMMUpliftFeeTake();
for (uint256 i = 0; i < localData.amountsOutRaw.length; i++) {
-- uint256 exitFee = localData.amountsOutRaw[i].mulDown(localData.feePercentage);
++ uint256 exitFee = localData.amountsOutRaw[i].mulUp(localData.feePercentage);
if (localData.adminFeePercent > 0) {
-- localData.accruedQuantAMMFees[i] = exitFee.mulDown(localData.adminFeePercent);
++ localData.accruedQuantAMMFees[i] = exitFee.mulUp(localData.adminFeePercent);
}
localData.accruedFees[i] = exitFee - localData.accruedQuantAMMFees[i];
hookAdjustedAmountsOutRaw[i] -= exitFee;
}
if (localData.adminFeePercent > 0) {
++ uint256 bptAmount = localData.feeAmount.mulDown(localData.adminFeePercent) / 1e18;
++ if(bptAmount > 100){
++ bptAmount -= 100;
++ }else{
++ bptAmount = 0;
++ }
++ if(bptAmount > 0){
++ _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("")
++ })
++ );
++ }
-- _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("")
-- })
-- );
...
}
...
return (true, hookAdjustedAmountsOutRaw);
}