Summary
In ChannelFollowingUpdateRule.sol rule weights can go below zero under certain circumstances due to insufficient validation during weight computation. (this issue occurs in all rules, but this rule is taken as an example)
Vulnerability Details
The root cause lies in the _getWeights() function of the ChannelFollowingUpdateRule.sol contract. The function checks whether weights are positive only if locals.kappa.length != 1 this can lead to negative weights computation if locals.kappa.length = 1:
function _getWeights(
int256[] calldata _prevWeights,
int256[] memory _data,
int256[][] calldata _parameters,
QuantAMMPoolParameters memory _poolParameters
) internal override returns (int256[] memory newWeightsConverted) {
...
if (locals.kappa.length == 1) {
locals.normalizationFactor /= int256(locals.prevWeightLength);
for (locals.i = 0; locals.i < locals.prevWeightLength; ) {
newWeightsConverted[locals.i] =
_prevWeights[locals.i] +
locals.kappa[0].mul(locals.signal[locals.i] - locals.normalizationFactor);
unchecked {
++locals.i;
}
}
} else {
for (locals.i = 0; locals.i < locals.kappa.length; ) {
locals.sumKappa += locals.kappa[locals.i];
unchecked {
++locals.i;
}
}
locals.normalizationFactor = locals.normalizationFactor.div(locals.sumKappa);
for (locals.i = 0; locals.i < locals.prevWeightLength; ) {
int256 weightUpdate = locals.kappa[locals.i].mul(locals.signal[locals.i] - locals.normalizationFactor);
newWeightsConverted[locals.i] = _prevWeights[locals.i] + weightUpdate;
require(newWeightsConverted[locals.i] >= 0, "Invalid weight");
unchecked {
++locals.i;
}
}
}
}
The issue appears from the lack of checks for negative weights before the final update.
PoC:
Update QuantAMMChannelUpdateRuleTest.t.sol with this test (codehawks site somehow deletes length of array in initialization so it well be errors like this Wrong argument count for function call: 0 arguments given but expected 1 , and to run this test you have to add arrays length manualy sorry):
function testWeightsBelowZero() public {
int256[][] memory parameters = new int256[][]();
parameters[0] = new int256[]();
parameters[0][0] = PRBMathSD59x18.fromInt(200);
parameters[1] = new int256[]();
parameters[1][0] = 0.5e18;
parameters[2] = new int256[]();
parameters[2][0] = 0.1e18;
parameters[3] = new int256[]();
parameters[3][0] = PRBMathSD59x18.fromInt(3);
parameters[4] = new int256[]();
parameters[4][0] = PRBMathSD59x18.fromInt(1);
parameters[5] = new int256[]();
parameters[5][0] = 0.5e18;
parameters[6] = new int256[]();
parameters[6][0] = PRBMathSD59x18.fromInt(0);
int256[] memory prevAlphas = new int256[]();
prevAlphas[0] = PRBMathSD59x18.fromInt(1);
prevAlphas[1] = PRBMathSD59x18.fromInt(2);
int256[] memory prevMovingAverages = new int256[]();
prevMovingAverages[0] = PRBMathSD59x18.fromInt(3);
prevMovingAverages[1] = PRBMathSD59x18.fromInt(4);
int256[] memory movingAverages = new int256[]();
movingAverages[0] = PRBMathSD59x18.fromInt(3);
movingAverages[1] = PRBMathSD59x18.fromInt(4);
int128[] memory lambdas = new int128[]();
lambdas[0] = int128(0.9e18);
int256[] memory prevWeights = new int256[]();
prevWeights[0] = 0.5e18;
prevWeights[1] = 0.5e18;
int256[] memory data = new int256[]();
data[0] = PRBMathSD59x18.fromInt(5);
data[1] = PRBMathSD59x18.fromInt(6);
int256[] memory expectedResults = new int256[]();
expectedResults[0] = 0.464719395233870000e18;
expectedResults[1] = 0.535280604766130000e18;
mockPool.setNumberOfAssets(2);
rule.initialisePoolRuleIntermediateValues(
address(mockPool),
prevMovingAverages,
prevAlphas,
mockPool.numAssets()
);
rule.CalculateUnguardedWeights(prevWeights, data, address(mockPool), parameters, lambdas, movingAverages);
int256[] memory resultWeights = rule.GetResultWeights();
checkResult(resultWeights, expectedResults);
for(uint i = 0; i < 5; i++) {
rule.CalculateUnguardedWeights(resultWeights, data, address(mockPool), parameters, lambdas, movingAverages);
resultWeights = rule.GetResultWeights();
if (i == 4) {
assert(resultWeights[0] < 0);
}
}
}
In cmd run following command:
forge test --mt testWeightsBelowZero
Output:
Ran 1 test for test/foundry/rules/QuantAMMChannelFollowing.t.sol:ChannelFollowingUpdateRuleTest
[PASS] testWeightsBelowZero() (gas: 692690)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 16.13ms (12.24ms CPU time)
Impact
Negative weights can break normal allocation of liquidity or capital in the pool, leading to inefficiencies or losses for users.
Tools Used
Manual Review
Recommendations
Add final check for weights without depending on kappa value (also after this check optionally you can set weights by default to MIN amount and MAX amount but just requirement is probably enough):
if (locals.kappa.length == 1) {
locals.normalizationFactor /= int256(locals.prevWeightLength);
for (locals.i = 0; locals.i < locals.prevWeightLength; ) {
newWeightsConverted[locals.i] =
_prevWeights[locals.i] +
locals.kappa[0].mul(locals.signal[locals.i] - locals.normalizationFactor);
unchecked {
++locals.i;
}
}
} else {
for (locals.i = 0; locals.i < locals.kappa.length; ) {
locals.sumKappa += locals.kappa[locals.i];
unchecked {
++locals.i;
}
}
locals.normalizationFactor = locals.normalizationFactor.div(locals.sumKappa);
for (locals.i = 0; locals.i < locals.prevWeightLength; ) {
int256 weightUpdate = locals.kappa[locals.i].mul(locals.signal[locals.i] - locals.normalizationFactor);
newWeightsConverted[locals.i] = _prevWeights[locals.i] + weightUpdate;
unchecked {
++locals.i;
}
}
}
for (locals.i = 0; locals.i < locals.prevWeightLength; locals.i += 1) {
require(newWeightsConverted[locals.i] >= 0, "Invalid weight");
}