QuantAMM

QuantAMM
49,600 OP
View results
Submission Details
Severity: medium
Valid

formula Deviation from White Paper and Weighted Pool `performUpdate` unintended revert

Summary

The White Paper states that there should be no difference in the calculation of scaler and vector values across formulas. Additionally, during the unguarded weights stage, the protocol should allow negative weights, as the guard weight ensures final weights validity.

However, for vector kappa values, the performUpdate function reverts when it theoretically should not. While a valid revert for a single performUpdate is expected behavior, this particular revert should not be treated as default/valid behavior.

Vulnerability Details

The White Paper mentions that a strategy can utilize either scalar or vector kappa values. The primary difference lies in implementation complexity, as vector kappa values require an additional SLOAD operation and a nested loop for processing.
White Paper reference
The same formula is applied for both scaler and vector kappa values, ensuring uniformity in calculations regardless of the type of kappa value used.
Formula
The current strategy algorithm supports both short and long positions. However, the additional check in the implementation, as shown in the code below, prevents the weighted pool from functioning with long/short positions if the unguarded weights return negative values after a price change.

contracts/rules/AntimomentumUpdateRule.sol:100
100: newWeightsConverted = new int256[](_prevWeights.length);
101: if (locals.kappa.length == 1) {
102: locals.normalizationFactor /= int256(_prevWeights.length);
103: // w(t − 1) + κ ·(ℓp(t) − 1/p(t) · ∂p(t)/∂t)
104:
105: for (locals.i = 0; locals.i < _prevWeights.length; ) {
106: int256 res = int256(_prevWeights[locals.i]) +
107: int256(locals.kappa[0]).mul(locals.normalizationFactor - locals.newWeights[locals.i]);
108: newWeightsConverted[locals.i] = res;
110: unchecked {
111: ++locals.i;
112: }
113: }
114: } else {
115: for (locals.i = 0; locals.i < locals.kappa.length; ) {
116: locals.sumKappa += locals.kappa[locals.i];
117: unchecked {
118: ++locals.i;
119: }
120: }
121:
122: locals.normalizationFactor = locals.normalizationFactor.div(locals.sumKappa);
123:
124: for (locals.i = 0; locals.i < _prevWeights.length; ) {
125: // w(t − 1) + κ ·(ℓp(t) − 1/p(t) · ∂p(t)/∂t)
126: int256 res = int256(_prevWeights[locals.i]) +
127: int256(locals.kappa[locals.i]).mul(locals.normalizationFactor - locals.newWeights[locals.i]);
128: require(res >= 0, "Invalid weight"); // @audit : no valid revert
129: newWeightsConverted[locals.i] = res;
130: unchecked {
131: ++locals.i;
132: }
133: }
134: }
135:
136: return newWeightsConverted;

The following POC demonstrates how the algorithm behaves differently when using scaler versus vector kappa values.

// 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 { PoolRoleAccounts } 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 { MockAntiMomentumRule } from "../../contracts/mock/mockRules/MockAntiMomentumRule.sol";
import { MockChainlinkOracle } from "../../contracts/mock/MockChainlinkOracles.sol";
import "@balancer-labs/v3-interfaces/contracts/pool-quantamm/IQuantAMMWeightedPool.sol";
contract QuantAMMWeightedPoolRevertCase 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;
function setUp() public override {
uint delay = 3600;
super.setUp();
(address ownerLocal, address addr1Local, address addr2Local) = (vm.addr(1), vm.addr(2), vm.addr(3));
owner = ownerLocal;
addr1 = addr1Local;
addr2 = addr2Local;
// Deploy UpdateWeightRunner contract
vm.startPrank(owner);
updateWeightRunner = new MockUpdateWeightRunner(owner, addr2, false);
chainlinkOracle = _deployOracle(1e18, delay);
chainlinkOracle2 = _deployOracle(1e18, delay); // initial Price
updateWeightRunner.addOracle(OracleWrapper(chainlinkOracle));
updateWeightRunner.addOracle(OracleWrapper(chainlinkOracle2));
vm.stopPrank();
quantAMMWeightedPoolFactory = deployQuantAMMWeightedPoolFactory(
IVault(address(vault)),
365 days,
"Factory v1",
"Pool v1"
);
vm.label(address(quantAMMWeightedPoolFactory), "quantamm weighted pool factory");
(daiIdx, usdcIdx) = getSortedIndexes(address(dai), address(usdc));
}
function testQuantAMMWeightedPoolGetNormalizedWeightsInitial() public {
//===================================Works Fine=============================================================//
QuantAMMWeightedPoolFactory.NewPoolParams memory params = _createPoolParams(true,ZERO_BYTES32);
params._initialWeights[0] = 0.80e18; // 80%
params._initialWeights[1] = 0.20e18; // 20%
(address quantAMMWeightedPool, ) = quantAMMWeightedPoolFactory.create(params);
uint256[] memory weights = QuantAMMWeightedPool(quantAMMWeightedPool).getNormalizedWeights();
vm.prank(owner);
updateWeightRunner.setApprovedActionsForPool(quantAMMWeightedPool,1);
updateWeightRunner.performUpdate(quantAMMWeightedPool);
chainlinkOracle.updateData(0.9e18, uint40(block.timestamp));
chainlinkOracle2.updateData(2.5e18, uint40(block.timestamp)); // price increase by 150% which is valid price change but it works fine
// Move forward 10 blocks
vm.warp(block.timestamp+1000);
updateWeightRunner.performUpdate(quantAMMWeightedPool);
uint256[] memory weightsData = QuantAMMWeightedPool(quantAMMWeightedPool).getNormalizedWeights();
assertNotEq(weightsData[0],uint256(params._initialWeights[0]));
assertNotEq(weightsData[1],uint256(params._initialWeights[1]));
// =========================================Unintended Revert========================================================
params = _createPoolParams(false,bytes32("TheKhans"));
params._initialWeights[0] = 0.80e18;
params._initialWeights[1] = 0.20e18;
( quantAMMWeightedPool, ) = quantAMMWeightedPoolFactory.create(params);
weights = QuantAMMWeightedPool(quantAMMWeightedPool).getNormalizedWeights();
vm.prank(owner);
updateWeightRunner.setApprovedActionsForPool(quantAMMWeightedPool,1);
updateWeightRunner.performUpdate(quantAMMWeightedPool);
chainlinkOracle.updateData(0.9e18, uint40(block.timestamp));
chainlinkOracle2.updateData(2.5e18, uint40(block.timestamp));// price increase by 150% which is valid price change here it will revert.
// Move forward 10 blocks
vm.warp(block.timestamp+1000);
vm.expectRevert();
updateWeightRunner.performUpdate(quantAMMWeightedPool);
}
function _createPoolParams(bool useScalerKappa,bytes32 salt) internal returns (QuantAMMWeightedPoolFactory.NewPoolParams memory retParams) {
PoolRoleAccounts memory roleAccounts;
IERC20[] memory tokens = [address(dai), address(usdc)].toMemoryArray().asIERC20();
MockAntiMomentumRule momentumRule = new MockAntiMomentumRule(address(updateWeightRunner));
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;
if(!useScalerKappa){
parameters = new int256[][]();
parameters[0] = new int256[]();
parameters[0][0] = 0.6e18;
parameters[0][1] = 0.6e18;
}else{
parameters = new int256[][]();
parameters[0] = new int256[]();
parameters[0][0] = 0.6e18;
}
address[][] memory oracles = new address[][]();
oracles[0] = new address[]();
oracles[1] = new address[]();
oracles[0][0] = address(chainlinkOracle);
oracles[1][0] = address(chainlinkOracle2);
retParams = QuantAMMWeightedPoolFactory.NewPoolParams(
"Pool With Donation",
"PwD",
vault.buildTokenConfig(tokens),
initialWeightsUint,
roleAccounts,
MAX_SWAP_FEE_PERCENTAGE,
address(0),
true,
false, // Do not disable unbalanced add/remove liquidity
salt, // salt
initialWeights,
IQuantAMMWeightedPool.PoolSettings(
new IERC20[](2),
IUpdateRule(momentumRule),
oracles,
60,
lambdas,
0.2e18,
0.02e18, // absolute guard rail
0.2e18,
parameters,
address(0)
),
initialWeights,
initialWeights,
3600,
0,
new string[][]()
);
}
}

Run it with forge test --mt testQuantAMMWeightedPoolGetNormalizedWeightsInitial -vvv

Impact

In the case of vector kappa, the weights are not updated and continue using the old values, which is incorrect given the latest price changes. However, with single kappa, the update proceeds as expected, reflecting the new prices.

Tools Used

Manual Review, Unit Testing

Recommendations

It is recommended to remove the check require(res >= 0, "Invalid weight"); from all currently implemented strategies/algorithms. This change will ensure compatibility with scenarios where unguarded weights may temporarily result in negative values, allowing the system to proceed as intended.

Updates

Lead Judging Commences

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

finding_kappa_revert_when_unguarded_negative_weights

Likelihood: Low, when kappa lead to negative weights. Impact: Medium/High, update mechanism is DoS.

Appeal created

rzizah Auditor
10 months ago
n0kto Lead Judge
10 months ago
n0kto Lead Judge 10 months ago
Submission Judgement Published
Validated
Assigned finding tags:

finding_kappa_revert_when_unguarded_negative_weights

Likelihood: Low, when kappa lead to negative weights. Impact: Medium/High, update mechanism is DoS.

Support

FAQs

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

Give us feedback!