Missing staleness check in BenqiDualOracle::getUnderlyingPrice when oracleMode is EDGE_ONLY or CL_ONLY
Description
BenqiDualOracle implements a dual oracle system with Edge as primary and Chainlink as backup. It has three different modes:
/// @notice Enum representing different oracle modes
enum OracleMode {
EDGE_ONLY, // Use only Edge oracle
CL_ONLY, // Use only Chainlink oracle
DUAL // Use both oracles (default)
}In the comments of the BenqiDualOracle contract, it is mentioned that staleness detection is one of the responsibilities of this contract:
* @dev Dual oracle system with mechanisms for:
* - Staleness detectionHowever, in the BenqiDualOracle::getUnderlyingPrice implementation, when the oracleMode is EDGE_ONLY or CL_ONLY, no staleness detection is performed:
// If in Edge-only mode, return Edge price without checks
if (oracleMode == OracleMode.EDGE_ONLY) {
(uint256 edgePriceResult,) = edgeOracle.getUnderlyingPriceWithTimestamp(qiToken);
if (edgePriceResult == 0) revert EdgeOracleZeroPrice();
return edgePriceResult;
}
// If in Chainlink-only mode, return Chainlink price without checks
if (oracleMode == OracleMode.CL_ONLY) {
(uint256 chainlinkPriceResult,) = chainlinkOracle.getUnderlyingPriceWithTimestamp(qiToken);
if (chainlinkPriceResult == 0) revert ChainlinkOracleZeroPrice();
return chainlinkPriceResult;
}For example, when oracleMode == OracleMode.EDGE_ONLY, the BenqiPriceOracle::getUnderlyingPriceWithTimestamp function will be called to obtain price information:
function getUnderlyingPriceWithTimestamp(IQiToken qiToken) external view override returns (uint256, uint256) {
address qiTokenAddress = address(qiToken);
IAggregatorV2V3Interface feed;
if (qiTokenAddress == qiAvaxAddress) {
feed = getFeed(AVAX_UNDERLYING);
} else {
address underlying = address(IQiErc20(address(qiToken)).underlying());
feed = getFeed(underlying);
}
return getPriceAndTimestamp(feed, qiToken);
}Finally, the BenqiPriceOracle::getPriceAndTimestamp function is called. In the BenqiEdgeOracle::getPriceAndTimestamp function, although updatedAt is obtained, no stale price check is performed based on it:
function getPriceAndTimestamp(IAggregatorV2V3Interface feed, IQiToken qiToken)
internal
view
returns (uint256, uint256)
{
address tokenAddress =
address(qiToken) == qiAvaxAddress ? AVAX_UNDERLYING : address(IQiErc20(address(qiToken)).underlying());
IEIP20Interface token = IEIP20Interface(tokenAddress);
// Check for manually set price first
if (prices[tokenAddress] != 0) {
uint256 manualPrice = prices[tokenAddress];
// Only adjust for decimals if it's not AVAX
if (address(qiToken) != qiAvaxAddress) {
uint256 tokenDecimalDelta = 18 - token.decimals();
if (tokenDecimalDelta > 0) {
manualPrice = manualPrice * (10 ** tokenDecimalDelta);
}
}
return (manualPrice, block.timestamp);
}
// If no manual price, proceed with feed data
(, int256 answer,, uint256 updatedAt,) = feed.latestRoundData();
require(answer > 0, "negative or zero price not allowed");
uint256 feedPrice = uint256(answer);
// If it's not AVAX, adjust for token decimals
if (address(qiToken) != qiAvaxAddress) {
uint256 tokenDecimalDelta = 18 - token.decimals();
if (tokenDecimalDelta > 0) {
feedPrice = feedPrice * (10 ** tokenDecimalDelta);
}
}
// Convert to 18 decimals if needed
uint256 feedDecimalDelta = 18 - feed.decimals();
if (feedDecimalDelta > 0) {
feedPrice = feedPrice * (10 ** feedDecimalDelta);
}
return (feedPrice, updatedAt);
}Impact
When oracleMode is EDGE_ONLY or CL_ONLY, code will execute with prices that do not reflect the current pricing, resulting in a potential loss of funds for users.
Recommendations
Consider adding a staleness check when OracleMode is EDGE_ONLY or CL_ONLY.
Remediation
This issue has been acknowledged by Chaos Labs, and a fix was implemented in commit aa97b6fd↗.