QuantAMM

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

If main oracle is removed from approved list it will keep returning not stale but invalid data (0 value)

Summary

Because stale data is preferred over a revert - if a primary oracle is disabled without backups getData would keep providing invalid data (0 value) to the rules until oracle is re-enabled.

Vulnerability Details

As the stated in the cyfrin report 7.2.4 in regards to oracles

A stale update is better than none because of the way the estimators encapsulate a weighted price and a certain level of smoothing

It currently returns optimized (main) oracle data even if it is stale. This makes the below comment left by the sponsors invalid

Return empty timestamp if oracle is no longer approved, result will be discarded

The result would not be discarded if the oracle is the main one and there are no backups.

function _getOracleData(OracleWrapper _oracle) private view returns (OracleData memory oracleResult) {
if (!approvedOracles[address(_oracle)]) return oracleResult; // Return empty timestamp if oracle is no longer approved, result will be discarded
(int216 data, uint40 timestamp) = _oracle.getData();
oracleResult.data = data;
oracleResult.timestamp = timestamp;
}

The consequence of this would be that 0 value data would be returned as a sucessful fetch and is not checked anywhere down the line apart from it's previously mentioned staleness.

Inside _getData oracle loop

for (uint i; i < oracleLength; ) {
// Asset is base asset
OracleData memory oracleResult;
// @audit oracle without approval returns 0 values
oracleResult = _getOracleData(OracleWrapper(optimisedOracles[i]));
// @audit oracleResult.timestamp is zero - skip setting, but no revert
if (oracleResult.timestamp > block.timestamp - oracleStalenessThreshold) {
outputData[i] = oracleResult.data;
} else {
unchecked {
numAssetOracles = poolBackupOracles[_pool][i].length;
}
// @audit loop is never entered if there are no backup oracles
for (uint j = 1 /*0 already done via optimised poolOracles*/; j < numAssetOracles; ) {
oracleResult = _getOracleData(
OracleWrapper(poolBackupOracles[_pool][i][j])
);
if (oracleResult.timestamp > block.timestamp - oracleStalenessThreshold) {
// Oracle has fresh values
break;
} else if (j == numAssetOracles - 1) {
// All oracle results for this data point are stale. Should rarely happen in practice with proper backup oracles.
revert("No fresh oracle values available");
}
unchecked {
++j;
}
}
// @audit oraceResult has zero value, but is still set and returned
outputData[i] = oracleResult.data;
}
unchecked {
++i;
}
}

After _getData call finishes:

function _getUpdatedWeightsAndOracleData (...) {
data = _getData(_pool, true); // @audit returns zeroes
updatedWeights = rules[_pool].CalculateNewWeights(
_currentWeights,
data, // @audit zeroes passed directly to rule weight calculation
_pool,
_ruleSettings.ruleParameters,
_ruleSettings.lambda,
_ruleSettings.epsilonMax,
_ruleSettings.absoluteWeightGuardRail
);
...

After the above CalculateNewWeights call finishes the data is not used anywhere else.

The oracle approvals approvedOracles are fully controlled by the admins. But they can't control the oracle output. And in a case when a single main oracle goes rouge it would create a dillema where getData will always return invalid data - either by the rogue oracle returned data or by the functionality of a disabled oracle _getOracleData (0 value).

It would be a rare occurance where the main oracle of a pool to be purposefully turned off (removed from approvedOracles) but is possible.

A situation like this could also be introduced by aiming to disable a backup oracle of one pool, but missing that the same oracle is used as the main in another.

After the oracle is disabled and the performUpdate is called the weights will be updated to invalid ones.

Impact

getaData should never return invalid data. Only up to date data / stale data (special cases) / revert.

Any invalid weights would create new arbitrage opportunities as longs as invalid weights (0 data values) are continuesly submitted. And again once weights are fixed. But due to special conditions required - Medium.

Tools Used

Manual review + modified foundry MinVarianceUpdateRuleTest test, where the test passes with zero data value as the new data.

function testCalculateNewWeightsWith0NewData() public {
// Set the number of assets to 2
mockPool.setNumberOfAssets(2);
// Define parameters and inputs
int256[][] memory parameters = new int256[][]();
parameters[0] = new int256[]();
parameters[0][0] = 0.5e18;
int256[] memory prevWeights = new int256[]();
prevWeights[0] = 0.5e18;
prevWeights[1] = 0.5e18;
int256[] memory data = new int256[]();
data[0] = 0; // @audit original test value PRBMathSD59x18.fromInt(3)
data[1] = 0; // @audit original test value PRBMathSD59x18.fromInt(4)
int256[] memory variances = new int256[]();
variances[0] = PRBMathSD59x18.fromInt(1);
variances[1] = PRBMathSD59x18.fromInt(1);
int256[] memory prevMovingAverages = new int256[]();
prevMovingAverages[0] = PRBMathSD59x18.fromInt(1);
prevMovingAverages[1] = PRBMathSD59x18.fromInt(1) + 0.5e18;
prevMovingAverages[2] = PRBMathSD59x18.fromInt(1);
prevMovingAverages[3] = PRBMathSD59x18.fromInt(1) + 0.5e18;
int128[] memory lambda = new int128[]();
lambda[0] = 0.7e18;
// Initialize pool rule intermediate values
rule.initialisePoolRuleIntermediateValues(
address(mockPool),
prevMovingAverages,
variances,
mockPool.numAssets()
);
// Calculate unguarded weights
rule.CalculateUnguardedWeights(prevWeights, data, address(mockPool), parameters, lambda, prevMovingAverages);
// Get and check result weights
int256[] memory res = rule.GetResultWeights();
int256[] memory ex = new int256[]();
ex[0] = 0.548283261802575107e18;
ex[1] = 0.451716738197424892e18;
// @audit with zero - 567204301075268817
// @audit original - 548283261802575107
console2.log("with zero weights", res[0]);
// @audit with zero data - 432795698924731182
// @audit original - 451716738197424892
console2.log("with zero weights", res[1]);
checkResult(res, ex);
// Expected result: [0.5482832618025751, 0.4517167381974248]
}

Recommendations

Oracles that are disabled should not return any result and be immediately discarded. A 0 value may be rare, but in theory is possible for extremly low value coins. So we check both data and timestamp for validity:

A solution would be to add an extra check at the end of the loop to see if the final result is not empty.

function _getData(address _pool, bool internalCall) private view returns (int256[] memory outputData) {
require(internalCall || (approvedPoolActions[_pool] & MASK_POOL_GET_DATA > 0), "Not allowed to get data");
//optimised == happy path, optimised into a different array to save gas
address[] memory optimisedOracles = poolOracles[_pool];
uint oracleLength = optimisedOracles.length;
uint numAssetOracles;
outputData = new int256[](oracleLength);
uint oracleStalenessThreshold = IQuantAMMWeightedPool(_pool).getOracleStalenessThreshold();
for (uint i; i < oracleLength; ) {
// Asset is base asset
OracleData memory oracleResult;
oracleResult = _getOracleData(OracleWrapper(optimisedOracles[i]));
if (oracleResult.timestamp > block.timestamp - oracleStalenessThreshold) {
outputData[i] = oracleResult.data;
} else {
unchecked {
numAssetOracles = poolBackupOracles[_pool][i].length;
}
for (uint j = 1 /*0 already done via optimised poolOracles*/; j < numAssetOracles;) {
oracleResult = _getOracleData(
OracleWrapper(poolBackupOracles[_pool][i][j])
);
if (oracleResult.timestamp > block.timestamp - oracleStalenessThreshold) {
// Oracle has fresh values
break;
} else if (j == numAssetOracles - 1) {
// All oracle results for this data point are stale. Should rarely happen in practice with proper backup oracles.
revert("No fresh oracle values available");
}
unchecked {
++j;
}
}
// NEW CHECK
// @audit add a new revert in case all are disapproved
if (oracleResult.data == 0 && oracleResult.timestamp == 0) {
revert("No oracles approved or returned no values");
}
outputData[i] = oracleResult.data;
}
unchecked {
++i;
}
}
}

This is similar to what OracleWrapper already checks inside if the oracle is approved:

function getData() public view returns (int216 data, uint40 timestamp) {
(data, timestamp) = _getData();
require(timestamp > 0, "INVORCLVAL"); // Sanity check in case oracle returns invalid values
}
Updates

Lead Judging Commences

n0kto Lead Judge 10 months ago
Submission Judgement Published
Validated
Assigned finding tags:

finding_removing_oracle_will_prevent_pools_to_update

Likelihood: Low, when an oracle is removed. Impact: High, Pools using the removed oracle will corrupt the gradient and moving average calculation.

Support

FAQs

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

Give us feedback!