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
-
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.
-
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
-
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.
-
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
Stale Oracles Cause DoS
function testOraclesStaleBlockDoS() public {
console2.log('=== TEST START: testOraclesStaleBlockDoS ===');
console2.log('1) Setting block.timestamp to 1000 via vm.warp(1000).');
vm.warp(1000);
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));
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();
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);
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);
oracles[0][1] = address(fallbackOracle);
console2.log(' Creating 1 asset so _getData(...) actually iterates.');
IERC20[] memory assetsArray = new IERC20[]();
assetsArray[0] = IERC20(address(0xDEAD));
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();
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();
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.
Test: Single Oracle Stale
function testSingleOracleStaleBug() public {
console2.log('=== TEST START: testSingleOracleStaleBug ===');
console2.log('1) Setting block.timestamp to 1000.');
vm.warp(1000);
console2.log('2) Deploying a single stale oracle (delay=20).');
chainlinkOracle = deployOracle( 1000, 20);
console2.log(' Oracle address:', address(chainlinkOracle));
console2.log(
'3) Approving the single (stale) oracle in updateWeightRunner...'
);
vm.startPrank(owner);
updateWeightRunner.addOracle(OracleWrapper(chainlinkOracle));
console2.log(' -> Oracle approved');
vm.stopPrank();
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);
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);
IERC20[] memory assetsArray = new IERC20[]();
assetsArray[0] = IERC20(address(0xDEAD));
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();
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();
console2.log(
'7) Calling performUpdate(...) => should NOT revert => uses stale oracle data.'
);
updateWeightRunner.performUpdate(address(mockPool));
console2.log(
' -> performUpdate() completed => indicates stale data was accepted.'
);
console2.log(
'8) Checking the final weights => should reflect usage of stale data (timestamp=980).'
);
{
uint256[] memory finalWeights = IWeightedPool(address(mockPool))
.getNormalizedWeights();
console2.log(
' Final weights[0] =',
finalWeights.length > 0 ? finalWeights[0] : 9999999
);
}
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:
-
Multiple, Robust Backup Oracles
-
“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.
-
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.
-
Monitor Oracle Health Externally
-
Adjust oracleStalenessThreshold
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.