QuantAMM

QuantAMM
49,600 OP
View results
Submission Details
Severity: low
Invalid

Unhandled negative base in fractional exponent calculation lead to loss of funds

Summary

The ChannelFollowingUpdateRule contract calculates new token weights based on a price gradient. However, when the gradient is negative and the exponent is non-integer, the contract raises a negative base to a fractional power. In real arithmetic, (−x)e(-x)^{e}(−x)e for non-integer eee is undefined. The code sidesteps this by:

  1. Taking the absolute value of the gradient,

  2. Exponentiating the positive number, and

  3. Reintroducing a negative sign.

This incorrectly computes a real result from what should either be a complex number or a disallowed scenario, causing the WeightedPool’s weight updates to diverge from intended logic.

Vulnerability Details

note how the code forcibly re-signs the result after exponentiation:

https://github.com/Cyfrin/2024-12-quantamm/blob/a775db4273eb36e7b4536c5b60207c9f17541b92/pkg/pool-quantamm/contracts/rules/ChannelFollowingUpdateRule.sol#L166-L200C13

// Calculate trend portion with absolute value
int256 absGradient = locals.newWeights[locals.i] >= 0
? locals.newWeights[locals.i]
: -locals.newWeights[locals.i];
int256 scaledAbsGradient = absGradient.div(locals.preExpScaling[locals.i].mul(TWO));
// This is x^e, valid only if x >= 0
trendPortion = _pow(scaledAbsGradient, locals.exponents[i]);
// Reapply sign if original gradient is negative
if (locals.newWeights[i] < 0) {
trendPortion = -trendPortion;
}

In many AMM or WeightedPool designs, exponents are meant to capture real “trend” dynamics. The chosen approach discards that domain issue by forcibly re-signing the result.

Mathematically speaking:

For non-integer exponents, ( − 𝑥 ) 𝑒 (−x) e is not simply − ( 𝑥 𝑒 ) −(x e ) if 𝑒 e has a fractional part.

PoC

Add the following test to ChannelFollowingUpdateRuleTest:

// import "forge-std/console.sol"; <- do not forget to import
function testPoCNegativeGradientNonIntegerExponentMultiAsset() public {
/**
* --------------------------------------------------------------
* 1) Set up parameters for two assets
* - Both have the same fractional exponent=1.5
* - Keep other parameters fairly standard
* --------------------------------------------------------------
*/
int256[][] memory parameters = new int256[][]();
// [0]: Kappa
parameters[0] = new int256[]();
parameters[0][0] = 200e18; // Asset0 kappa
parameters[0][1] = 200e18; // Asset1 kappa
// [1]: width
parameters[1] = new int256[]();
parameters[1][0] = 1e18; // Asset0 width
parameters[1][1] = 1e18; // Asset1 width
// [2]: amplitude
parameters[2] = new int256[]();
parameters[2][0] = 0.1e18; // Asset0 amplitude
parameters[2][1] = 0.1e18; // Asset1 amplitude
// [3]: exponents -> non-integer (1.5), triggers the bug for negative base
parameters[3] = new int256[]();
parameters[3][0] = 1.5e18; // Asset0 exponent
parameters[3][1] = 1.5e18; // Asset1 exponent
// [4]: inverseScaling
parameters[4] = new int256[]();
parameters[4][0] = 1e18; // Asset0
parameters[4][1] = 1e18; // Asset1
// [5]: preExpScaling
parameters[5] = new int256[]();
parameters[5][0] = 0.5e18; // Asset0
parameters[5][1] = 0.5e18; // Asset1
// [6]: useRawPrice (scalar)
parameters[6] = new int256[]();
parameters[6][0] = 1e18; // =1 => use the raw price directly, no movingAverage
/**
* --------------------------------------------------------------
* 2) Pool setup for two assets
* --------------------------------------------------------------
*/
mockPool.setNumberOfAssets(2);
// 3) Previous weights: 50% each
int256[] memory prevWeights = new int256[]();
prevWeights[0] = 0.5e18;
prevWeights[1] = 0.5e18;
// 4) Initialize alpha & moving averages
int256[] memory prevAlphas = new int256[]();
prevAlphas[0] = 1e18;
prevAlphas[1] = 1e18;
int256[] memory prevMovingAverages = new int256[]();
prevMovingAverages[0] = 5e18; // old price for asset0
prevMovingAverages[1] = 5e18; // old price for asset1
// Not actually used if useRawPrice=1, but required by the function signature
int256[] memory movingAverages = new int256[]();
movingAverages[0] = 5e18;
movingAverages[1] = 5e18;
/**
* --------------------------------------------------------------
* 5) New "raw" prices data:
* Asset0: from 5 => 1 => big negative slope
* Asset1: from 5 => 8 => positive slope
* --------------------------------------------------------------
*/
int256[] memory data = new int256[]();
data[0] = 1e18; // negative gradient for asset0
data[1] = 8e18; // positive gradient for asset1
// 6) Single lambda
int128[] memory lambdas = new int128[]();
lambdas[0] = int128(0.9e18);
// 7) Initialize the pool's intermediate data
rule.initialisePoolRuleIntermediateValues(
address(mockPool),
prevMovingAverages,
prevAlphas,
mockPool.numAssets()
);
/**
* --------------------------------------------------------------
* 8) Calculate new weights
* --------------------------------------------------------------
*/
rule.CalculateUnguardedWeights(
prevWeights,
data,
address(mockPool),
parameters,
lambdas,
movingAverages
);
int256[] memory resultWeights = rule.GetResultWeights();
/**
* --------------------------------------------------------------
* 9) Demonstrate the bug
* - The exponent=1.5 on a negative base (Asset0) is
* mathematically invalid in real numbers. The contract's
* sign-flip logic will produce a *seemingly valid* negative
* real number. That leads to an incorrect final weight
* for Asset0.
*
* - We show that the result is suspicious by forcing the test
* to fail if it remains in a plausible 0..1 range.
* --------------------------------------------------------------
*/
console.log("Asset0 (negative gradient) final weight: %e", uint256(resultWeights[0]));
console.log("Asset1 (positive gradient) final weight: %e", uint256(resultWeights[1]));
/**
* In a mathematically correct system, sqrt(negativeNumber)
* should be imaginary, or the logic would need to revert/fallback
* to a real-valued alternative. If the code just flips the sign,
* it yields a real negative partial value that can push the weight
* around incorrectly.
*
* Here, we demonstrate a naive check that simply fails if
* Asset0 remains in the standard [0..1] range, implying the
* code quietly computed an impossible real number.
*/
bool isAsset0InRange = (resultWeights[0] >= 0 && resultWeights[0] <= 1e18);
assertFalse(
isAsset0InRange,
"BUG DEMO: Asset0 final weight incorrectly ended up in normal range despite sqrt of negative base"
);
}

run: forge test --match-test testPoCNegativeGradientNonIntegerExponentMultiAsset -vv

Output:

Asset0 (negative gradient) final weight: 9.984504801044372e17
Asset1 (positive gradient) final weight: 1.5495198955628e15
[FAIL: BUG DEMO: Asset0 final weight incorrectly ended up in normal range despite sqrt of negative base] testPoCNegativeGradientNonIntegerExponentMultiAsset() (gas: 298441)

Impact

Due to incorrectly exponentiating negative price gradients, WeightedPool rebalances can misallocate liquidity and inflict large impermanent losses on liquidity providers, eroding user confidence and capital.

Tools Used

Manual Review & Foundry

Recommendations

Either enforce integer exponents only, ensuring that negative bases remain mathematically valid (e.g.,(−𝑥)2(−x)2=𝑥2x2).

Or adopt a domain-specific piecewise approach for negative gradients if fractional exponents are needed.

Updates

Lead Judging Commences

n0kto Lead Judge 10 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
Assigned finding tags:

invalid_ChannelFollowingUpdateRule_whitepaper_incorrect_implementation

The formula here is the one in Whitepaper page 11, which is right and the division is explained on the last line : "Finally note …".

Support

FAQs

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

Give us feedback!