Summary
Incorrect index management in QuantAMMStorage._quantAMMUnpack128Arraywill cause MinimumVarianceUpdateRulewill always revert with array out-of-bounds exception
Vulnerability Details
Root Cause
Bug in QuantAMMStorage._quantAMMUnpack128Array
We have the following functionality in QuantAMMStorage._quantAMMUnpack128Array
function _quantAMMUnpack128Array(
int256[] memory _sourceArray,
uint _targetArrayLength
) internal pure returns (int256[] memory targetArray) {
require(_sourceArray.length * 2 >= _targetArrayLength, "SRC!=TGT");
targetArray = new int256[](_targetArrayLength);
uint targetIndex;
uint sourceArrayLengthMinusOne = _sourceArray.length - 1;
bool divisibleByTwo = _targetArrayLength % 2 == 0;
for (uint i; i < _sourceArray.length; ) {
targetArray[targetIndex] = _sourceArray[i] >> 128;
unchecked {
++targetIndex;
}
if ((!divisibleByTwo && i < sourceArrayLengthMinusOne) || divisibleByTwo) {
targetArray[targetIndex] = int256(int128(_sourceArray[i]));
}
unchecked {
++i;
++targetIndex;
}
}
if (!divisibleByTwo) {
targetArray[_targetArrayLength - 1] = int256(int128(_sourceArray[sourceArrayLengthMinusOne]));
}
}
It can be observed that indices (i and targetIndex) have the following properties:
For each iteration, i is incremented by one until _sourceArray.length
targetIndex is incremented by two (i.e. i * 2) and should have upper-bound _targetArrayLength
However, the upper bound for targetIndex is breached when _targetArrayLength < _sourceArray.length * 2.
In the most cases, we encode/decode full array, so targetArrayLength == ceil(sourceArray.length / 2) and the above condition is not met.
But in MinimumVarianceUpdateRule, _targetArrayLength < _sourceArray.length * 2. Let's see:
Bug is triggered in MinimumVarianceUpdateRule.CalculateNewWeights
MinimumVarianceUpdateRule and other UpdateRules quantum-packs and stores movingAverages when calculating new weights:
locals.currMovingAverage = _quantAMMUnpack128Array(movingAverages[_pool], locals.numberOfAssets);
* For other rules that doesn't require prev average
* movingAverages[_pool][i] = updatedMovingAverages[i]
*/
if (locals.requiresPrevAverage) {
movingAverages[_pool] = _quantAMMPack128Array(locals.calculationMovingAverage);
}
For MinimumVarianceUpdateRule, we require prev average, so movingAverages length is numberOfAssets * 2
locals.intermediateMovingAverageStateLength = locals.numberOfAssets;
if (locals.requiresPrevAverage) {
unchecked {
locals.intermediateMovingAverageStateLength *= 2;
}
}
locals.calculationMovingAverage = new int256[](locals.intermediateMovingAverageStateLength);
So what's happening here is:
But when unpacking, targetArrayLength = numberOfAssets, so we have sourceArrayLength = numberOfAssets and targetArrayLength = numberOfAssets
So targetArrayLength < 2 * sourceArrayLength
This will cause index OOB error in weight calculation.
POC
Scenario:
MinimumVarianceUpdateRule for 2 assets and 4 initial moving averages will revert on first CalculateNewWeightsattempt
MinimumVarianceUpdateRule for 2 assets and 2 initial moving averages will revert on second CalculateNewWeights attempt
Note: According to QuantAMMMinVariance.t.sol, looks like 2 initial moving averages is not a standard practice, but this example will demonstrate how targetArrayLength < 2 * sourceArrayLengthis reached
Initially, sourceArrayLength = 1 (due to initial 2 moving averages), targetArrayLength = 2(numAssets)
After first calculation, sourceArrayLength = 2(unpacked movingAverageLength = 4 to record prev moving averages)
In the second calculation, sourceArrayLength = 2and targetArrayLength = 2
Create a file MinimumVarianceUpdateRule.t.solunder test/foundrydirectory and run forge test MinimumVarianceUpdateRule.t.sol -vvv
pragma solidity ^0.8.26;
import "forge-std/Test.sol";
import "../../contracts/rules/MinimumVarianceUpdateRule.sol";
contract MinimumVarianceUpdateRuleTesting is Test {
MinimumVarianceUpdateRule rule;
function setUp() external {
rule = new MinimumVarianceUpdateRule(address(this));
}
function testRevertWithOOBFourMovingAverages() external {
int256[][] memory parameters = new int256[][]();
parameters[0] = new int256[]();
parameters[0][0] = 0.9e18;
int256[] memory prevWeights = new int256[]();
prevWeights[0] = 0.5e18;
prevWeights[1] = 0.5e18;
int256[] memory data = new int256[]();
data[0] = PRBMathSD59x18.fromInt(3);
data[1] = PRBMathSD59x18.fromInt(4);
int256[] memory variances = new int256[]();
variances[0] = PRBMathSD59x18.fromInt(1);
variances[1] = PRBMathSD59x18.fromInt(1);
int256[] memory prevMovingAverages = new int256[]();
prevMovingAverages[0] = PRBMathSD59x18.fromInt(1);
prevMovingAverages[1] = PRBMathSD59x18.fromInt(1) + 0.5e18;
prevMovingAverages[2] = PRBMathSD59x18.fromInt(1);
prevMovingAverages[3] = PRBMathSD59x18.fromInt(1) + 0.5e18;
uint64[] memory lambda = new uint64[]();
lambda[0] = 0.95e18;
lambda[1] = 0.5e18;
uint64 epsilonMax = 0.2e18;
uint64 absoluteGuardRail = 0.2e18;
rule.initialisePoolRuleIntermediateValues(vm.addr(1), prevMovingAverages, variances, 2);
vm.expectRevert(stdError.indexOOBError);
rule.CalculateNewWeights(prevWeights, data, vm.addr(1), parameters, lambda, epsilonMax, absoluteGuardRail);
}
function testRevertWithOOBTwoMovingAverages() external {
int256[][] memory parameters = new int256[][]();
parameters[0] = new int256[]();
parameters[0][0] = 0.9e18;
int256[] memory prevWeights = new int256[]();
prevWeights[0] = 0.5e18;
prevWeights[1] = 0.5e18;
int256[] memory data = new int256[]();
data[0] = PRBMathSD59x18.fromInt(3);
data[1] = PRBMathSD59x18.fromInt(4);
int256[] memory variances = new int256[]();
variances[0] = PRBMathSD59x18.fromInt(1);
variances[1] = PRBMathSD59x18.fromInt(1);
int256[] memory prevMovingAverages = new int256[]();
prevMovingAverages[0] = PRBMathSD59x18.fromInt(1);
prevMovingAverages[1] = PRBMathSD59x18.fromInt(1) + 0.5e18;
uint64[] memory lambda = new uint64[]();
lambda[0] = 0.95e18;
lambda[1] = 0.5e18;
uint64 epsilonMax = 0.2e18;
uint64 absoluteGuardRail = 0.2e18;
rule.initialisePoolRuleIntermediateValues(vm.addr(1), prevMovingAverages, variances, 2);
rule.CalculateNewWeights(prevWeights, data, vm.addr(1), parameters, lambda, epsilonMax, absoluteGuardRail);
vm.expectRevert(stdError.indexOOBError);
rule.CalculateNewWeights(prevWeights, data, vm.addr(1), parameters, lambda, epsilonMax, absoluteGuardRail);
}
}
Impact
Pools with MinimumVarianceUpdateRule will not have their weights updated
Tools Used
Foundry, Python, Differential Testing
Recommendations
Two options:
In UpdateRule.sol, truncate movingAveragesup to numberOfAssetswhen requiresPrevAverageis true, or
In QuantAMMStorage._quantAMMUnpack128Array, use targetIndexas a loop variable, instead of i