Summary
Due to the lack of a try-catch in the primary oracle call, the system will not call the backup oracles and can experience a DDos if the primary oracle reverts.
Vulnerability Details
QuantAMM has a primary oracle plus a set of backup oracles. If the response from the primary oracle is stale, the system will proceed by attempting to retrieve responses from the backup oracles.
https://github.com/Cyfrin/2024-12-quantamm/blob/main/pkg/pool-quantamm/contracts/UpdateWeightRunner.sol#L360-L365
However, a reasonable assumption would be that the primary oracle call can revert (momentarily or permanently). For example chainlink could deprecated certain oracles in the future and set them to return price equal to zero, which would correctly revert in ChainlinkOracle.
https://github.com/Cyfrin/2024-12-quantamm/blob/main/pkg/pool-quantamm/contracts/ChainlinkOracle.sol#L30
Since the system contains these sets of backup oracles, the backups should be called when the primary oracle reverts.
The following POC shows that if the primary oracle reverts, the backup oracles will not be called and the TX will revert.
To run the POC, we'll add a failing oracle that reverts when calling on _getData().
1: Simulate an oracle that will revert when retrieving data.
Path: pkg/pool-quantamm/contracts/mock/MockFailingOracle.sol
pragma solidity >=0.8.24;
import "@balancer-labs/v3-interfaces/contracts/pool-quantamm/OracleWrapper.sol";
contract MockFailingOracle is OracleWrapper {
int216 private fixedReply;
uint private immutable delay;
uint40 public oracleTimestamp;
bool shouldRevert = true;
constructor(int216 _fixedReply, uint _delay) {
fixedReply = _fixedReply;
delay = _delay;
oracleTimestamp = uint40(block.timestamp);
}
function updateData(int216 _fixedReply, uint40 _timestamp) public {
fixedReply = _fixedReply;
oracleTimestamp = _timestamp;
}
function _getData() internal view override returns (int216 data, uint40 timestamp) {
if (shouldRevert) {
revert("primary oracle is offline");
}
data = fixedReply;
timestamp = uint40(block.timestamp - delay);
}
}
2: Add the failing oracle on pkg/pool-quantamm/test/foundry/UpdateWeightRunner.t.sol.
Path: pkg/pool-quantamm/test/foundry/UpdateWeightRunner.t.sol
pragma solidity ^0.8.26;
import "../../contracts/mock/MockFailingOracle.sol";
contract UpdateWeightRunnerTest is Test, QuantAMMTestUtils {
function deployFailingOracle(int216 fixedValue, uint delay) internal returns (MockFailingOracle) {
MockFailingOracle oracle = new MockFailingOracle(fixedValue, delay);
return oracle;
}
}
3: Final POC function to demonstrate a primary oracle revert will result in full TX revert:
Path: pkg/pool-quantamm/test/foundry/UpdateWeightRunner.t.sol
function testPrimaryOracleIsOfflineBackupsAreNotCalled() public {
vm.startPrank(owner);
updateWeightRunner.setApprovedActionsForPool(address(mockPool), 3);
MockFailingOracle primaryOracle = deployFailingOracle(1000, 0);
MockChainlinkOracle backupOracle1 = deployOracle(1001, 0);
MockChainlinkOracle backupOracle2 = deployOracle(1002, 0);
updateWeightRunner.addOracle(primaryOracle);
updateWeightRunner.addOracle(backupOracle1);
updateWeightRunner.addOracle(backupOracle2);
mockRule = new MockIdentityRule();
vm.stopPrank();
vm.startPrank(address(mockPool));
address[][] memory oracles = new address[][]();
oracles[0] = new address[]();
oracles[0][0] = address(primaryOracle);
oracles[1] = new address[]();
oracles[1][0] = address(backupOracle1);
oracles[2] = new address[]();
oracles[2][0] = address(backupOracle2);
uint64[] memory lambda = new uint64[]();
lambda[0] = 0.0000000005e18;
updateWeightRunner.setRuleForPool(
IQuantAMMWeightedPool.PoolSettings({
assets: new IERC20[](0),
rule: IUpdateRule(mockRule),
oracles: oracles,
updateInterval: 1,
lambda: lambda,
epsilonMax: 0.2e18,
absoluteWeightGuardRail: 0.2e18,
maxTradeSizeRatio: 0.2e18,
ruleParameters: new int256[][](),
poolManager: addr2
})
);
vm.stopPrank();
vm.warp(block.timestamp + UPDATE_INTERVAL);
vm.expectRevert("primary oracle is offline");
updateWeightRunner.performUpdate(address(mockPool));
}
Impact
In the worst case scenario a primary oracle gets deprecated permanently while the backup oracles continue to work without issues.
In this case, adding liquidity, removing liquidity and transfering LP tokens on UpliftOnlyExample would revert and be unavailable.
https://github.com/Cyfrin/2024-12-quantamm/blob/main/pkg/pool-hooks/contracts/hooks-quantamm/UpliftOnlyExample.sol#L245
https://github.com/Cyfrin/2024-12-quantamm/blob/main/pkg/pool-hooks/contracts/hooks-quantamm/UpliftOnlyExample.sol#L452
https://github.com/Cyfrin/2024-12-quantamm/blob/main/pkg/pool-hooks/contracts/hooks-quantamm/UpliftOnlyExample.sol#L587
The variable _updateWeightRunner is immutable in UpliftOnlyExample and the oracle data of UpdateWeightRunner can be added only while the pool rule is not initialized, presumably it will be called by QuantAMMWeightedPool.initialize() -> QuantAmmWeightedPool._setRule(). This means UpliftOnlyExample would need a migration or re-deployment to use an UpdateWeightRunner where the price retrieval works.
https://github.com/Cyfrin/2024-12-quantamm/blob/main/pkg/pool-hooks/contracts/hooks-quantamm/UpliftOnlyExample.sol#L91
https://github.com/Cyfrin/2024-12-quantamm/blob/main/pkg/pool-quantamm/contracts/UpdateWeightRunner.sol#L236
Tools Used
Manual review.
Recommendations
Consider adding a try-catch on the primary oracle call. If there's more than 1 backup oracle, a try-catch could also be added on the backup calls with the exception of the last one. With this approach, the price retrieval would only revert if all oracles revert.