Summary
In minimum variance, the previous moving average is stored/packed. However, due to incorrect unpacking, no performUpdate is executed after the first one.
Vulnerability Details
The WeightedPool using minimum variance also stores the previous moving average. During the deployment of the weighted pool, the initial moving average is stored as part of the pool's initialization.
contracts/rules/UpdateRule.sol:247
247: function initialisePoolRuleIntermediateValues(
248: address _poolAddress,
249: int256[] memory _newMovingAverages,
250: int256[] memory _newInitialValues,
251: uint _numberOfAssets
252: ) external {
253:
254:
255: require(msg.sender == _poolAddress || msg.sender == updateWeightRunner, "UNAUTH");
256: _setInitialMovingAverages(_poolAddress, _newMovingAverages, _numberOfAssets);
257: _setInitialIntermediateValues(_poolAddress, _newInitialValues, _numberOfAssets);
258: }
259:
Here, at line 256, the _setInitialMovingAverages function is called.
contracts/rules/base/QuantammMathMovingAverage.sol:67
67: function _setInitialMovingAverages(
68: address _poolAddress,
69: int256[] memory _initialMovingAverages,
70: uint _numberOfAssets
71: ) internal {
72: uint movingAverageLength = movingAverages[_poolAddress].length;
73:
74: if (movingAverageLength == 0 || _initialMovingAverages.length == _numberOfAssets) {
75:
76: movingAverages[_poolAddress] = _quantAMMPack128Array(_initialMovingAverages);
77: } else {
78: revert("Invalid set moving avg");
79: }
80: }
The movingAverage values are packed into a 128 array and stored in movingAverages[_poolAddress]. These values are fetched when the performUpdate function is called, which in turn invokes CalculateNewWeights to determine the new weights for the weighted pool. The code below demonstrates how the data is unpacked to retrieve the exact moving average values:
/contracts/rules/UpdateRule.sol:78
78:
79: function CalculateNewWeights(
80: int256[] calldata _prevWeights,
81: int256[] calldata _data,
82: address _pool,
83: int256[][] calldata _parameters,
84: uint64[] calldata _lambdaStore,
85: uint64 _epsilonMax,
86: uint64 _absoluteWeightGuardRail
87: ) external returns (int256[] memory updatedWeights) {
88: require(msg.sender == updateWeightRunner, "UNAUTH_CALC");
89:
90: QuantAMMUpdateRuleLocals memory locals;
91:
92: locals.numberOfAssets = _prevWeights.length;
93: locals.nMinusOne = locals.numberOfAssets - 1;
94: locals.lambda = new int128[](_lambdaStore.length);
95:
96: for (locals.i; locals.i < locals.lambda.length; ) {
97: locals.lambda[locals.i] = int128(uint128(_lambdaStore[locals.i]));
98: unchecked {
99: ++locals.i;
100: }
101: }
102:
103: locals.requiresPrevAverage = _requiresPrevMovingAverage() == REQ_PREV_MAVG_VAL;
104: locals.intermediateMovingAverageStateLength = locals.numberOfAssets;
105:
106: if (locals.requiresPrevAverage) {
107: unchecked {
108: locals.intermediateMovingAverageStateLength *= 2;
109: }
110: }
111:
112: locals.currMovingAverage = new int256[](locals.numberOfAssets);
113: locals.updatedMovingAverage = new int256[](locals.numberOfAssets);
114: @> locals.calculationMovingAverage = new int256[](locals.intermediateMovingAverageStateLength);
115:
116: @> locals.currMovingAverage = _quantAMMUnpack128Array(movingAverages[_pool], locals.numberOfAssets);
When the performUpdate function is called for the first time, it also stores the previous average if required. From the above code, at line 114, the new moving average will double in length if the previous average is needed. Then, at line 116, the current packed data is unpacked.
Assuming a two-asset weighted pool, the length of the packed moving average array becomes 2 due to the inclusion of previous average data, with the number of assets being 2. Let’s examine the unpacking code below.
contracts/QuantAMMStorage.sol:350
350: function _quantAMMUnpack128Array(
351: int256[] memory _sourceArray,
352: uint _targetArrayLength
353: ) internal pure returns (int256[] memory targetArray) {
354:
355: require(_sourceArray.length * 2 >= _targetArrayLength, "SRC!=TGT");
356: targetArray = new int256[](_targetArrayLength);
357: uint targetIndex;
358: uint sourceArrayLengthMinusOne = _sourceArray.length - 1;
359: bool divisibleByTwo = _targetArrayLength % 2 == 0;
360: for (uint i; i < _sourceArray.length; ) {
361: targetArray[targetIndex] = _sourceArray[i] >> 128;
362: unchecked {
363: ++targetIndex;
364: }
365: if ((!divisibleByTwo && i < sourceArrayLengthMinusOne) || divisibleByTwo) {
366: targetArray[targetIndex] = int256(int128(_sourceArray[i]));
367: }
368: unchecked {
369: ++i;
370: ++targetIndex;
371: }
372:
373: }
374:
375: if (!divisibleByTwo) {
376: targetArray[_targetArrayLength - 1] = int256(int128(_sourceArray[sourceArrayLengthMinusOne]));
378: }
379: }
380: }
This function is expected to return unpacked data, including both the current moving average and the previous moving average. Therefore, the final length of the array should be 4. In _targetArrayLength, the number of assets is passed, which is 2 in this case. However, this unpacking function will revert with [FAIL: panic: array out-of-bounds access (0x32)].
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 { MockMinimumVarianceRule } from "../../contracts/mock/mockRules/MockMinimumVarianceRule.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 Pool3TokenTest is QuantAMMWeightedPoolContractsDeployer, BaseVaultTest {
using CastingHelpers for address[];
using ArrayHelpers for *;
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;
vm.startPrank(owner);
updateWeightRunner = new MockUpdateWeightRunner(owner, addr2, false);
chainlinkOracle = _deployOracle(1e18, delay);
chainlinkOracle2 = _deployOracle(1e18, delay);
updateWeightRunner.addOracle(OracleWrapper(chainlinkOracle));
updateWeightRunner.addOracle(OracleWrapper(chainlinkOracle2));
vm.stopPrank();
quantAMMWeightedPoolFactory = deployQuantAMMWeightedPoolFactory(
IVault(address(vault)),
365 days,
"Factory v1",
"Pool v1"
);
}
function test_previous_average_revert_case() public {
QuantAMMWeightedPoolFactory.NewPoolParams memory params = _createPoolParams();
params._initialWeights[0] = 0.03e18;
params._initialWeights[1] = 0.97e18;
(address quantAMMWeightedPool, ) = quantAMMWeightedPoolFactory.create(params);
vm.prank(owner);
updateWeightRunner.setApprovedActionsForPool(quantAMMWeightedPool,1);
chainlinkOracle.updateData(1e18, uint40(block.timestamp));
chainlinkOracle2.updateData(1e18, uint40(block.timestamp));
updateWeightRunner.performUpdate(quantAMMWeightedPool);
vm.expectRevert();
vm.warp(block.timestamp+1000);
updateWeightRunner.performUpdate(quantAMMWeightedPool);
}
function _createPoolParams() internal returns (QuantAMMWeightedPoolFactory.NewPoolParams memory retParams) {
PoolRoleAccounts memory roleAccounts;
IERC20[] memory tokens = [address(dai), address(usdc)].toMemoryArray().asIERC20();
MockMinimumVarianceRule minimumvarianceRule = new MockMinimumVarianceRule(address(updateWeightRunner));
uint32[] memory weights = new uint32[]();
weights[0] = uint32(uint256(0.5e18));
weights[1] = uint32(uint256(0.4e18));
int256[] memory initialWeights = new int256[]();
initialWeights[0] = 0.5e18;
initialWeights[1] = 0.4e18;
uint256[] memory initialWeightsUint = new uint256[]();
initialWeightsUint[0] = 0.5e18;
initialWeightsUint[1] = 0.4e18;
uint64[] memory lambdas = new uint64[]();
lambdas[0] = 0.2e18;
int256[][] memory 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,
ZERO_BYTES32,
initialWeights,
IQuantAMMWeightedPool.PoolSettings(
new IERC20[](2),
IUpdateRule(minimumvarianceRule),
oracles,
60,
lambdas,
0.2e18,
0.02e18,
0.2e18,
parameters,
address(0)
),
initialWeights,
initialWeights,
3600,
0,
new string[][]()
);
}
}
Run with command : forge test --mt test_previous_average_revert_case -vvv
Impact
In the case of minimum variance, where the previous average is also stored, the unpacking process will revert after the first call to the performUpdate function. This results in a DoS for all pools that use this UpdateRule.
Tools Used
Manual Review, Unit Testing
Recommendations
One of the Recommended Fix could be to handle this case as follows:
@@ -7,7 +7,7 @@ import "./base/QuantammMathMovingAverage.sol";
import "../UpdateWeightRunner.sol";
import "./base/QuantammBasedRuleHelpers.sol";
import "@balancer-labs/v3-interfaces/contracts/pool-quantamm/IUpdateRule.sol";
@@ -31,6 +31,7 @@ abstract contract UpdateRule is QuantAMMMathGuard, QuantAMMMathMovingAverage, IU
string public name;
string[] public parameterDescriptions;
+ bool isFirstTime = true;
/// @dev struct to avoid stack too deep issues
/// @notice Struct to store local variables for the update rule
@@ -102,19 +103,24 @@ abstract contract UpdateRule is QuantAMMMathGuard, QuantAMMMathMovingAverage, IU
locals.requiresPrevAverage = _requiresPrevMovingAverage() == REQ_PREV_MAVG_VAL;
locals.intermediateMovingAverageStateLength = locals.numberOfAssets; // 2
+ locals.i =locals.numberOfAssets; // using locals.i for numberOfAssets
- if (locals.requiresPrevAverage) {
+ if (locals.requiresPrevAverage) {
unchecked {
locals.intermediateMovingAverageStateLength *= 2; // than we need to double the array size make it 10
}
+ // here need to check if this is not first call
+ if(!isFirstTime){
+ locals.i = locals.intermediateMovingAverageStateLength; // using local.i for number of assets
+ }
}
+ isFirstTime = false;
locals.currMovingAverage = new int256[](locals.numberOfAssets);
locals.updatedMovingAverage = new int256[](locals.numberOfAssets);
locals.calculationMovingAverage = new int256[](locals.intermediateMovingAverageStateLength);
- locals.currMovingAverage = _quantAMMUnpack128Array(movingAverages[_pool], locals.numberOfAssets)
-
+ locals.currMovingAverage = _quantAMMUnpack128Array(movingAverages[_pool], locals.i);