QuantAMM

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

Unprotected weight calculation in Single-Kappa Mode lead to loss of funds

Summary

In BalancerV3 WeightedPools, weights represent each asset’s share of total liquidity. However, in the single-κ\kappaκ branch of ChannelFollowingUpdateRule no check enforces newWeight >= 0. Under certain parameters and market conditions, the calculated weight can go negative—and affect how pool allocate assets which can lead to loss of funds to LPers.

Vulnerability Details

The contract has two branches for applying weight updates:

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

if (locals.kappa.length == 1) {
// SINGLE-KAPPA BRANCH
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 {
// MULTI-KAPPA BRANCH
...
newWeightsConverted[locals.i] =
_prevWeights[locals.i] + weightUpdate;
require(newWeightsConverted[locals.i] >= 0, "Invalid weight");
}
  • In the multi-κ branch, weights are checked to ensure >= 0.

  • In the single-κ branch, no such require(...) exists, allowing negative results to slip through if the negative gradient and large κ\kappaκ push the update far enough below zero.

Negative weights have no practical meaning in a WeightedPool. They can break invariants, create nonsensical asset ratios, and potentially enable unintended outcomes (e.g., swaps at distorted prices or forced rebalances that harm liquidity providers).

PoC

Add the following test to QuantAMMChannelFollowing.t.sol:

// import "forge-std/console.sol";
function testPoCNegativeWeightSingleKappa_Force() public {
/**
* --------------------------------------------------------------
* 1) Single-\kappa\ parameters => triggers the single-\kappa\ path
* We'll use an extremely high \kappa\ plus conditions that
* strongly push a negative gradient on one asset.
* --------------------------------------------------------------
*/
int256[][] memory parameters = new int256[][]();
// [0] kappa array with length=1
parameters[0] = new int256[]();
parameters[0][0] = 2000e18; // Very large kappa to amplify negative signals
// [1] width
parameters[1] = new int256[]();
parameters[1][0] = 0.1e18; // Small width => envelope is narrower
// [2] amplitude
parameters[2] = new int256[]();
parameters[2][0] = 1e18; // Larger amplitude => big negative channel portion
// [3] exponents
// Choose exponent=1 to keep sign alignment straightforward (trend = sign(gradient)*|gradient|^1)
parameters[3] = new int256[]();
parameters[3][0] = 1e18;
// [4] inverseScaling
parameters[4] = new int256[]();
parameters[4][0] = 1e18;
// [5] preExpScaling
parameters[5] = new int256[]();
parameters[5][0] = 1e18; // Minimally damping the gradient
// [6] useRawPrice (scalar)
parameters[6] = new int256[]();
parameters[6][0] = 1e18; // Use raw price directly => no smoothing from moving avg
/**
* --------------------------------------------------------------
* 2) Two assets => single-kappa branch is still used,
* but we only have one \kappa\ for both assets.
* --------------------------------------------------------------
*/
mockPool.setNumberOfAssets(2);
// 3) Previous weights - let's reduce one asset to 0.1 to make it easier to go negative
int256[] memory prevWeights = new int256[]();
prevWeights[0] = 0.1e18; // more easily pushed below 0
prevWeights[1] = 0.9e18;
// 4) Initialize alpha/movingAverages (not used in rawPrice=1 mode, but needed for the function signature)
int256[] memory prevAlphas = new int256[]();
prevAlphas[0] = 1e18;
prevAlphas[1] = 1e18;
int256[] memory prevMovingAverages = new int256[]();
prevMovingAverages[0] = 5e18;
prevMovingAverages[1] = 5e18;
int256[] memory movingAverages = new int256[]();
movingAverages[0] = 5e18;
movingAverages[1] = 5e18;
/**
* --------------------------------------------------------------
* 5) Create new raw prices that strongly drop for the first asset
* => a negative gradient. The second asset moves slightly
* => might be negative or positive, but less dramatic.
* --------------------------------------------------------------
*/
int256[] memory data = new int256[]();
// Asset0: from 5 -> 0.1 => gradient ~ -4.9
data[0] = 0.1e18;
// Asset1: from 5 -> 4.9 => smaller negative gradient
data[1] = 4.9e18;
// Single lambda
int128[] memory lambdas = new int128[]();
lambdas[0] = int128(0.9e18);
// 6) Initialize intermediate values
rule.initialisePoolRuleIntermediateValues(
address(mockPool),
prevMovingAverages,
prevAlphas,
mockPool.numAssets()
);
/**
* --------------------------------------------------------------
* 7) Calculate new weights in single-kappa mode
* --------------------------------------------------------------
*/
rule.CalculateUnguardedWeights(
prevWeights,
data,
address(mockPool),
parameters,
lambdas,
movingAverages
);
int256[] memory resultWeights = rule.GetResultWeights();
/**
* --------------------------------------------------------------
* 8) Check if Asset0 or Asset1 ended up negative
* --------------------------------------------------------------
*/
console.log("Asset0 final weight:");
console.logInt(resultWeights[0]);
console.log("");
console.log("Asset1 final weight:");
console.logInt(resultWeights[1]);
console.log("");
if (resultWeights[0] < 0) {
console.log("BUG: negative weight for Asset0 in single-kappa scenario");
}
if (resultWeights[1] < 0) {
console.log("BUG: negative weight for Asset1 in single-kappa scenario");
}

run: forge test --match-test testPoCNegativeWeightSingleKappa_Force -vv

result:

Ran 1 test for test/foundry/rules/QuantAMMChannelFollowing.t.sol:ChannelFollowingUpdateRuleTest
[PASS] testPoCNegativeWeightSingleKappa_Force() (gas: 293667)
Logs:
Asset0 final weight:
-267374336012669108000
Asset1 final weight:
268374336012669108000
BUG: negative weight for Asset0 in single-kappa scenario
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 5.89ms (1.70ms CPU time)

Impact

A negative weight can undermine Balancer’s WeightedPool integrity, enabling asset mispricing, liquidity drain, and direct financial losses for users.

Tools Used

Manual Review & Foundry

Recommendations

in the single-κ path, mirror the multi-κ flow, add a require(newWeight >= 0, "Invalid weight") to ensure no negative weight can persist.

Updates

Lead Judging Commences

n0kto Lead Judge 10 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement
Assigned finding tags:

invalid_weights_can_be_negative_or_extreme_values

_clampWeights will check that these weights are positive and in the boundaries before writing them in storage.

Support

FAQs

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

Give us feedback!