Summary
UpdateWeightRunner.sol#performUpdate() can be always reverted in some conditions.
https://github.com/Cyfrin/2024-12-quantamm/blob/a775db4273eb36e7b4536c5b60207c9f17541b92/pkg/pool-quantamm/contracts/rules/AntimomentumUpdateRule.sol#L120
Vulnerability Details
AntimomentumUpdateRule.sol#_getWeights() function is as follows.
function _getWeights(
int256[] calldata _prevWeights,
int256[] memory _data,
int256[][] calldata _parameters,
QuantAMMPoolParameters memory _poolParameters
) internal override returns (int256[] memory newWeightsConverted) {
QuantAMMAntiMomentumLocals memory locals;
locals.kappa = _parameters[0];
locals.useRawPrice = false;
if (_parameters.length > 1) {
locals.useRawPrice = _parameters[1][0] == ONE;
}
_poolParameters.numberOfAssets = _prevWeights.length;
locals.newWeights = _calculateQuantAMMGradient(_data, _poolParameters);
for (locals.i = 0; locals.i < _prevWeights.length; ) {
locals.denominator = _poolParameters.movingAverage[locals.i];
if (locals.useRawPrice) {
locals.denominator = _data[locals.i];
}
locals.newWeights[locals.i] = ONE.div(locals.denominator).mul(int256(locals.newWeights[locals.i]));
if (locals.kappa.length == 1) {
locals.normalizationFactor += locals.newWeights[locals.i];
} else {
locals.normalizationFactor += (locals.newWeights[locals.i].mul(locals.kappa[locals.i]));
}
unchecked {
++locals.i;
}
}
newWeightsConverted = new int256[](_prevWeights.length);
if (locals.kappa.length == 1) {
locals.normalizationFactor /= int256(_prevWeights.length);
for (locals.i = 0; locals.i < _prevWeights.length; ) {
99 int256 res = int256(_prevWeights[locals.i]) +
int256(locals.kappa[0]).mul(locals.normalizationFactor - locals.newWeights[locals.i]);
newWeightsConverted[locals.i] = res;
unchecked {
++locals.i;
104 }
}
} 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 < _prevWeights.length; ) {
int256 res = int256(_prevWeights[locals.i]) +
119 int256(locals.kappa[locals.i]).mul(locals.normalizationFactor - locals.newWeights[locals.i]);
@> require(res >= 0, "Invalid weight");
newWeightsConverted[locals.i] = res;
unchecked {
++locals.i;
}
}
}
return newWeightsConverted;
}
On L119, locals.normalizationFactor is weighted average of locals.newWeights[locals.i] by kappa. So locals.normalizationFactor - locals.newWeights[locals.i] can be less than zero.
At boundary case, int256(locals.kappa[locals.i]).mul(locals.normalizationFactor - locals.newWeights[locals.i]) can undergo guardRail as leading to res < 0.
At this case, UpdateWeightRunner.sol#performUpdate() is always reverted.
On the other hand, on L99~104 there is no underflow check.
And UpdateRule.sol#L195~203 is as follows.
locals.unGuardedUpdatedWeights = _getWeights(_prevWeights, _data, _parameters, poolParameters);
updatedWeights = _guardQuantAMMWeights(
locals.unGuardedUpdatedWeights,
_prevWeights,
int128(uint128(_epsilonMax)),
int128(uint128(_absoluteWeightGuardRail))
);
As we can see above, the protocol gets weights unguarded from _getWeights() function and then guards the weights such as clamping.
Here, there is no assumption that weights are always bigger than zero.
But real implementation in some cases causes revert in case of underflow when kappa is provided with vector, not scalar.
Impact
Once this boundary case is caused, protocol is freezed for updating weights.
This problem exists in ChannelFollowingUpdateRule.sol, DifferenceMomentumUpdateRule.sol, MomentumUpdateRule.sol and PowerChannelUpdateRule.sol, too.
Tools Used
Manual review
Recommendations
We have to modify _getWeights() functions so that the call is not reverted in case that the weights go under zero.
For example, we have to modify AntimomentumUpdateRule.sol#_getWeights() function as follows.
function _getWeights(
int256[] calldata _prevWeights,
int256[] memory _data,
int256[][] calldata _parameters,
QuantAMMPoolParameters memory _poolParameters
) internal override returns (int256[] memory newWeightsConverted) {
...
newWeightsConverted = new int256[](_prevWeights.length);
if (locals.kappa.length == 1) {
locals.normalizationFactor /= int256(_prevWeights.length);
for (locals.i = 0; locals.i < _prevWeights.length; ) {
int256 res = int256(_prevWeights[locals.i]) +
int256(locals.kappa[0]).mul(locals.normalizationFactor - locals.newWeights[locals.i]);
newWeightsConverted[locals.i] = res;
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 < _prevWeights.length; ) {
int256 res = int256(_prevWeights[locals.i]) +
int256(locals.kappa[locals.i]).mul(locals.normalizationFactor - locals.newWeights[locals.i]);
-- require(res >= 0, "Invalid weight");
newWeightsConverted[locals.i] = res;
unchecked {
++locals.i;
}
}
}
return newWeightsConverted;
}