QuantAMM

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

`UpdateWeightRunner.sol#performUpdate()` can be always reverted in some conditions.

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;
// the second parameter determines if antimomentum should use the price or the average price as the denominator
// using the average price has shown greater performance and resilience due to greater smoothing
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];
}
//1/p(t) · ∂p(t)/∂t used in both the main part of the equation and normalisation so saved to save gas
// used of new weights array allows reuse and saved gas
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;
}
}
// To avoid intermediate overflows (because of normalization), we only downcast in the end to an uint64
newWeightsConverted = new int256[](_prevWeights.length);
if (locals.kappa.length == 1) {
locals.normalizationFactor /= int256(_prevWeights.length);
// w(t − 1) + κ ·(ℓp(t) − 1/p(t) · ∂p(t)/∂t)
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; ) {
// w(t − 1) + κ ·(ℓp(t) − 1/p(t) · ∂p(t)/∂t)
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);
//Guard weights is done in the base contract so regardless of the rule the logic will always be executed
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) {
...
// To avoid intermediate overflows (because of normalization), we only downcast in the end to an uint64
newWeightsConverted = new int256[](_prevWeights.length);
if (locals.kappa.length == 1) {
locals.normalizationFactor /= int256(_prevWeights.length);
// w(t − 1) + κ ·(ℓp(t) − 1/p(t) · ∂p(t)/∂t)
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; ) {
// w(t − 1) + κ ·(ℓp(t) − 1/p(t) · ∂p(t)/∂t)
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;
}
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.

Support

FAQs

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