QuantAMM

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

Denial of Service Vulnerability in `UpdateWeightRunner` Contract

Summary

The UpdateWeightRunner contract includes logic that checks for stale oracle data during weight updates. When all oracles for an asset return data older than the stalenessThreshold, the contract reverts with "No fresh oracle values available", causing a Denial of Service (DoS) condition.

At the same time, in the current implementation, stale data is silently accepted if no backup oracles exist. This leads to potentially incorrect weight adjustments based on outdated price information.

Comprehensive tests confirm and illustrate both behaviors.

Vulnerability Details

  1. DoS Scenario

    • If every oracle (principal + backup) for a given asset is stale, the contract reverts with "No fresh oracle values available".

    • This blocks rebalancing indefinitely until at least one oracle provides fresh data.

  2. Accepting Stale Data Without Backups

    • If only one (primary) oracle is configured and it returns stale data, the contract previously used that outdated value instead of reverting.

    • This can lead to incorrect or manipulated weight updates since no fallback exists.

Root Cause

  • The _getData() function enforces a strict check for “fresh” oracle data. If the main oracle is stale, it iterates backups; if all fail, the contract reverts.

  • When no backups are configured, the revert condition never fires and stale data is used by default.

  • UpdateWeightRunner.sol - _getData

Impact

  1. Denial of Service (DoS)

    • If all oracles become stale, the pool’s update cycle is frozen. No further weight updates can occur until at least one oracle is refreshed.

    • Liquidity providers (LPs) may face potential losses if they cannot rebalance in a timely manner.

  2. Potentially Incorrect Updates

    • With a single oracle and no backups, stale data can slip through without a revert.

    • Weight recalculations may be based on old information, causing price misalignments and exposing the pool to economic inefficiencies or manipulations.

Proof of Concept

Test

  1. Stale Oracles Cause DoS

  • Add the following test to pkg/pool-quantamm/test/foundry/UpdateWeightRunner.t.sol:

function testOraclesStaleBlockDoS() public {
console2.log('=== TEST START: testOraclesStaleBlockDoS ===');
// 1) Set block.timestamp = 1000
console2.log('1) Setting block.timestamp to 1000 via vm.warp(1000).');
vm.warp(1000);
// 2) Deploy TWO oracles with different 'delay' -> both are stale
// => timestamp = block.timestamp - delay => 1000 - 20 = 980 (stale)
// => fallback timestamp = 1000 - 30 = 970 (stale)
console2.log('2) Deploying two stale oracles: delay=20 and delay=30.');
chainlinkOracle = deployOracle(1000, 20);
MockChainlinkOracle fallbackOracle = deployOracle(1000, 30);
console2.log(' Primary Oracle address:', address(chainlinkOracle));
console2.log(' Fallback Oracle address:', address(fallbackOracle));
// 3) Approve BOTH oracles in the updateWeightRunner
console2.log('3) Approving both oracles in updateWeightRunner...');
vm.startPrank(owner);
updateWeightRunner.addOracle(OracleWrapper(chainlinkOracle));
console2.log(' -> Primary oracle approved');
updateWeightRunner.addOracle(OracleWrapper(fallbackOracle));
console2.log(' -> Fallback oracle approved');
vm.stopPrank();
// 4) Configure the pool so weights.length=2 => getNormalizedWeights() has length=1
// meaning `_getData(...)` sees exactly one "asset"
console2.log('4) Setting initialWeights with 2 elements [0.5e18, 0.5e18].');
int256[] memory initWeights = new int256[]();
initWeights[0] = 0.5e18;
initWeights[1] = 0.5e18;
mockPool.setInitialWeights(initWeights);
// 5) Assign oracles[0] with 2 oracles => principal and backup (both stale)
// We do it while impersonating the pool
console2.log(
'5) Setting up oracles array (principal + backup), both stale...'
);
vm.startPrank(address(mockPool));
address[][] memory oracles = new address[][]();
oracles[0] = new address[]();
oracles[0][0] = address(chainlinkOracle); // principal
oracles[0][1] = address(fallbackOracle); // backup
console2.log(' Creating 1 asset so _getData(...) actually iterates.');
IERC20[] memory assetsArray = new IERC20[]();
assetsArray[0] = IERC20(address(0xDEAD)); // dummy token
uint64[] memory lambda = new uint64[]();
lambda[0] = 0.0000000005e18;
console2.log(' Calling setRuleForPool(...) on the updateWeightRunner.');
updateWeightRunner.setRuleForPool(
IQuantAMMWeightedPool.PoolSettings({
assets: assetsArray,
rule: mockRule,
oracles: oracles,
updateInterval: 1,
lambda: lambda,
epsilonMax: 0.2e18,
absoluteWeightGuardRail: 0.2e18,
maxTradeSizeRatio: 0.2e18,
ruleParameters: new int256[][](),
poolManager: addr2
})
);
vm.stopPrank();
// 6) Grant the pool permission to PERFORM_UPDATE (mask=1)
console2.log(
'6) Setting poolRegistry=1 and enabling performUpdate (mask=1) for the pool.'
);
vm.startPrank(owner);
mockPool.setPoolRegistry(1);
updateWeightRunner.setApprovedActionsForPool(address(mockPool), 1);
vm.stopPrank();
// 7) Expect revert => both oracles are stale => "No fresh oracle values available"
console2.log(
"7) Expecting revert: 'No fresh oracle values available' -> both oracles stale."
);
vm.expectRevert('No fresh oracle values available');
updateWeightRunner.performUpdate(address(mockPool));
console2.log('=== TEST END: testOraclesStaleBlockDoS ===');
}
Logs:
Logs:
=== TEST START: testOraclesStaleBlockDoS ===
1) Setting block.timestamp to 1000 via vm.warp(1000).
2) Deploying two stale oracles: delay=20 and delay=30.
Primary Oracle address: 0xF62849F9A0B5Bf2913b396098F7c7019b51A820a
Fallback Oracle address: 0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9
3) Approving both oracles in updateWeightRunner...
-> Primary oracle approved
-> Fallback oracle approved
4) Setting initialWeights with 2 elements [0.5e18, 0.5e18].
5) Setting up oracles array (principal + backup), both stale...
Creating 1 asset so _getData(...) actually iterates.
Calling setRuleForPool(...) on the updateWeightRunner.
6) Setting poolRegistry=1 and enabling performUpdate (mask=1) for the pool.
7) Expecting revert: 'No fresh oracle values available' -> both oracles stale.
=== TEST END: testOraclesStaleBlockDoS ===

Result
performUpdate(...) reverts with "No fresh oracle values available", confirming a DoS scenario if all oracles are outdated.

  1. Test: Single Oracle Stale

  • Add the following test to pkg/pool-quantamm/test/foundry/UpdateWeightRunner.t.sol:

function testSingleOracleStaleBug() public {
console2.log('=== TEST START: testSingleOracleStaleBug ===');
// 1) Set block.timestamp = 1000 to simulate "stale" data
console2.log('1) Setting block.timestamp to 1000.');
vm.warp(1000);
// 2) Deploy ONE oracle with delay = 20 => timestamp = 1000 - 20 = 980 (stale)
console2.log('2) Deploying a single stale oracle (delay=20).');
chainlinkOracle = deployOracle(/*fixedValue=*/ 1000, /*delay=*/ 20);
console2.log(' Oracle address:', address(chainlinkOracle));
// 3) Approve the oracle in updateWeightRunner
console2.log(
'3) Approving the single (stale) oracle in updateWeightRunner...'
);
vm.startPrank(owner);
updateWeightRunner.addOracle(OracleWrapper(chainlinkOracle));
console2.log(' -> Oracle approved');
vm.stopPrank();
// 4) Configure the Pool so weights.length = 2 => getNormalizedWeights() => length = 1
// But ONLY one oracle => no backups
console2.log(
'4) Setting initWeights [0.5e18, 0.5e18]. => getNormalizedWeights() has length=1.'
);
int256[] memory initWeights = new int256[]();
initWeights[0] = 0.5e18;
initWeights[1] = 0.5e18;
mockPool.setInitialWeights(initWeights);
// 5) Define an array with ONE single oracle, no backups
console2.log(
'5) Creating a single-oracle array => no backups => expect stale usage!'
);
vm.startPrank(address(mockPool));
address[][] memory oracles = new address[][]();
oracles[0] = new address[]();
oracles[0][0] = address(chainlinkOracle);
// Create 1 asset so _getData(...) actually iterates
IERC20[] memory assetsArray = new IERC20[]();
assetsArray[0] = IERC20(address(0xDEAD)); // dummy token
uint64[] memory lambda = new uint64[]();
lambda[0] = 0.0000000005e18;
console2.log(' Calling setRuleForPool(...) with only one oracle (stale).');
updateWeightRunner.setRuleForPool(
IQuantAMMWeightedPool.PoolSettings({
assets: assetsArray,
rule: mockRule,
oracles: oracles,
updateInterval: 1,
lambda: lambda,
epsilonMax: 0.2e18,
absoluteWeightGuardRail: 0.2e18,
maxTradeSizeRatio: 0.2e18,
ruleParameters: new int256[][](),
poolManager: addr2
})
);
vm.stopPrank();
// 6) Allow "performUpdate" (MASK_POOL_PERFORM_UPDATE = 1)
console2.log(
'6) Setting poolRegistry=1 (performUpdate) => expecting NO revert, but stale usage'
);
vm.startPrank(owner);
mockPool.setPoolRegistry(1);
updateWeightRunner.setApprovedActionsForPool(address(mockPool), 1);
vm.stopPrank();
// 7) Perform the update => we do NOT expect a revert. The bug is that it uses the stale data.
console2.log(
'7) Calling performUpdate(...) => should NOT revert => uses stale oracle data.'
);
// No vm.expectRevert() here, we actually expect it to pass (the bug).
updateWeightRunner.performUpdate(address(mockPool));
console2.log(
' -> performUpdate() completed => indicates stale data was accepted.'
);
// 8) (Optional) Check that stale data was indeed used
console2.log(
'8) Checking the final weights => should reflect usage of stale data (timestamp=980).'
);
{
// Retrieve the resulting normalized weights
uint256[] memory finalWeights = IWeightedPool(address(mockPool))
.getNormalizedWeights();
console2.log(
' Final weights[0] =',
finalWeights.length > 0 ? finalWeights[0] : 9999999
);
// ...Compare with whatever 'mockRule' might have calculated, if needed
}
console2.log('=== TEST END: testSingleOracleStaleBug ===');
}
Logs:
=== TEST START: testSingleOracleStaleBug ===
1) Setting block.timestamp to 1000.
2) Deploying a single stale oracle (delay=20).
Oracle address: 0xF62849F9A0B5Bf2913b396098F7c7019b51A820a
3) Approving the single (stale) oracle in updateWeightRunner...
-> Oracle approved
4) Setting initWeights [0.5e18, 0.5e18]. => getNormalizedWeights() has length=1.
5) Creating a single-oracle array => no backups => expect stale usage!
Calling setRuleForPool(...) with only one oracle (stale).
6) Setting poolRegistry=1 (performUpdate) => expecting NO revert, but stale usage
7) Calling performUpdate(...) => should NOT revert => uses stale oracle data.
-> performUpdate() completed => indicates stale data was accepted.
8) Checking the final weights => should reflect usage of stale data (timestamp=980).
Final weights[0] = 500000000000000000
=== TEST END: testSingleOracleStaleBug ===

Result
No revert occurs, and the update completes using a stale oracle. This confirms a bug in which the contract silently accepts outdated data if no backups exist.

Tools Used

  • Foundry: Used to compile, test, and validate the vulnerability scenarios and the fix.

  • Manual Code Review: Verified that these conditions remain in the contract, confirming the presence of stale-data acceptance and DoS behavior.

Recommendations

To balance security (avoid stale data) and availability (prevent DoS), consider:

  1. Multiple, Robust Backup Oracles

    • Ensure each asset has more than one oracle.

    • Make it unlikely for every oracle to become stale simultaneously.

  2. “Manual Override” / Breakglass

    • Allow an admin or pool manager to force an update if all oracles are stale, preventing indefinite DoS.

    • This centralizes control and must be carefully restricted, but it can save a pool from remaining frozen.

  3. Fallback to “Last Known Good” Data

    • Rather than reverting entirely, store the last valid price and continue using it (with a penalty or alert).

    • This avoids total DoS at the cost of potentially using older data.

  4. Monitor Oracle Health Externally

    • Have off-chain scripts or services that detect stale oracles and trigger manual intervention or reconfiguration.

    • This ensures stale oracles are quickly replaced or fixed.

  5. Adjust oracleStalenessThreshold

    • Tune the threshold to an appropriate window so normal delays don’t cause false positives, yet truly stale data is excluded.

By implementing these measures, the protocol can significantly reduce the likelihood of both a DoS situation and the acceptance of stale data when no backups are present.

Conclusion

  • Cause: _getData() triggers a revert if all oracles are stale, but silently accepts stale values when there is only one oracle.

  • Impact: Potential indefinite DoS or inaccurate weight updates.

  • Solution: Add robust fallback/backup oracles, consider manual overrides, monitor oracle health, and/or adjust thresholds.

The tests confirm both scenarios: a complete freeze if all oracles are stale, and the accidental acceptance of stale data when no backups are configured. Taking the recommended steps will mitigate these issues, ensuring both security against manipulated data and continued availability of the pool.

Updates

Lead Judging Commences

n0kto Lead Judge 7 months ago
Submission Judgement Published
Invalidated
Reason: Known issue
Assigned finding tags:

invalid_stale_price_when_no_backup_oracles_set

Cyfrin audit: 7.2.4 Stale Oracle prices accepted when no backup oracles available

Support

FAQs

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