diff --git a/contracts/src/BorrowerOperations.sol b/contracts/src/BorrowerOperations.sol index d6fe301d0..e4f5073a3 100644 --- a/contracts/src/BorrowerOperations.sol +++ b/contracts/src/BorrowerOperations.sol @@ -157,7 +157,6 @@ contract BorrowerOperations is LiquityBase, AddRemoveManagers, IBorrowerOperatio event BoldTokenAddressChanged(address _boldTokenAddress); event ShutDown(uint256 _tcr); - event ShutDownFromOracleFailure(address _oracleAddress); constructor(IAddressesRegistry _addressesRegistry) AddRemoveManagers(_addressesRegistry) @@ -1163,8 +1162,11 @@ contract BorrowerOperations is LiquityBase, AddRemoveManagers, IBorrowerOperatio uint256 totalColl = getEntireSystemColl(); uint256 totalDebt = getEntireSystemDebt(); - (uint256 price,) = priceFeed.fetchPrice(); + (uint256 price, bool newOracleFailureDetected) = priceFeed.fetchPrice(); + // If the oracle failed, the above call to PriceFeed will have shut this branch down + if (newOracleFailureDetected) {return;} + // Otherwise, proceed with the TCR check: uint256 TCR = LiquityMath._computeCR(totalColl, totalDebt, price); if (TCR >= SCR) revert TCRNotBelowSCR(); @@ -1174,7 +1176,7 @@ contract BorrowerOperations is LiquityBase, AddRemoveManagers, IBorrowerOperatio } // Not technically a "Borrower op", but seems best placed here given current shutdown logic. - function shutdownFromOracleFailure(address _failedOracleAddr) external { + function shutdownFromOracleFailure() external { _requireCallerIsPriceFeed(); // No-op rather than revert here, so that the outer function call which fetches the price does not revert @@ -1182,8 +1184,6 @@ contract BorrowerOperations is LiquityBase, AddRemoveManagers, IBorrowerOperatio if (hasBeenShutDown) return; _applyShutdown(); - - emit ShutDownFromOracleFailure(_failedOracleAddr); } function _applyShutdown() internal { diff --git a/contracts/src/Interfaces/IBorrowerOperations.sol b/contracts/src/Interfaces/IBorrowerOperations.sol index 96df29199..ffb134904 100644 --- a/contracts/src/Interfaces/IBorrowerOperations.sol +++ b/contracts/src/Interfaces/IBorrowerOperations.sol @@ -94,7 +94,7 @@ interface IBorrowerOperations is ILiquityBase, IAddRemoveManagers { function hasBeenShutDown() external view returns (bool); function shutdown() external; - function shutdownFromOracleFailure(address _failedOracleAddr) external; + function shutdownFromOracleFailure() external; function checkBatchManagerExists(address _batchMananger) external view returns (bool); diff --git a/contracts/src/Interfaces/ICompositePriceFeed.sol b/contracts/src/Interfaces/IMainnetPriceFeed.sol similarity index 54% rename from contracts/src/Interfaces/ICompositePriceFeed.sol rename to contracts/src/Interfaces/IMainnetPriceFeed.sol index 2a6143eb2..ee8580236 100644 --- a/contracts/src/Interfaces/ICompositePriceFeed.sol +++ b/contracts/src/Interfaces/IMainnetPriceFeed.sol @@ -4,7 +4,13 @@ import "../Dependencies/AggregatorV3Interface.sol"; pragma solidity ^0.8.0; -interface ICompositePriceFeed is IPriceFeed { +interface IMainnetPriceFeed is IPriceFeed { + enum PriceSource { + primary, + ETHUSDxCanonical, + lastGoodPrice + } + function ethUsdOracle() external view returns (AggregatorV3Interface, uint256, uint8); - function lstEthOracle() external view returns (AggregatorV3Interface, uint256, uint8); + function priceSource() external view returns (PriceSource); } diff --git a/contracts/src/Interfaces/IPriceFeed.sol b/contracts/src/Interfaces/IPriceFeed.sol index 2c8133b03..bb95c93c7 100644 --- a/contracts/src/Interfaces/IPriceFeed.sol +++ b/contracts/src/Interfaces/IPriceFeed.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.0; interface IPriceFeed { function fetchPrice() external returns (uint256, bool); + function fetchRedemptionPrice() external returns (uint256, bool); function lastGoodPrice() external view returns (uint256); function setAddresses(address _borrowerOperationsAddress) external; } diff --git a/contracts/src/Interfaces/IRETHPriceFeed.sol b/contracts/src/Interfaces/IRETHPriceFeed.sol new file mode 100644 index 000000000..862bf6874 --- /dev/null +++ b/contracts/src/Interfaces/IRETHPriceFeed.sol @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: MIT +import "./IMainnetPriceFeed.sol"; +import "../Dependencies/AggregatorV3Interface.sol"; + +pragma solidity ^0.8.0; + +interface IRETHPriceFeed is IMainnetPriceFeed { + function rEthEthOracle() external view returns (AggregatorV3Interface, uint256, uint8); +} diff --git a/contracts/src/Dependencies/IRETHToken.sol b/contracts/src/Interfaces/IRETHToken.sol similarity index 100% rename from contracts/src/Dependencies/IRETHToken.sol rename to contracts/src/Interfaces/IRETHToken.sol diff --git a/contracts/src/Interfaces/IWETHPriceFeed.sol b/contracts/src/Interfaces/IWETHPriceFeed.sol deleted file mode 100644 index 9739be3c7..000000000 --- a/contracts/src/Interfaces/IWETHPriceFeed.sol +++ /dev/null @@ -1,9 +0,0 @@ -// SPDX-License-Identifier: MIT -import "./IPriceFeed.sol"; -import "../Dependencies/AggregatorV3Interface.sol"; - -pragma solidity ^0.8.0; - -interface IWETHPriceFeed is IPriceFeed { - function ethUsdOracle() external view returns (AggregatorV3Interface, uint256, uint8); -} diff --git a/contracts/src/Interfaces/IWSTETHPriceFeed.sol b/contracts/src/Interfaces/IWSTETHPriceFeed.sol index 88e6cd8f7..cfb20934e 100644 --- a/contracts/src/Interfaces/IWSTETHPriceFeed.sol +++ b/contracts/src/Interfaces/IWSTETHPriceFeed.sol @@ -1,9 +1,9 @@ // SPDX-License-Identifier: MIT -import "./IPriceFeed.sol"; +import "./IMainnetPriceFeed.sol"; import "../Dependencies/AggregatorV3Interface.sol"; pragma solidity ^0.8.0; -interface IWSTETHPriceFeed is IPriceFeed { +interface IWSTETHPriceFeed is IMainnetPriceFeed { function stEthUsdOracle() external view returns (AggregatorV3Interface, uint256, uint8); } diff --git a/contracts/src/PriceFeeds/CompositePriceFeed.sol b/contracts/src/PriceFeeds/CompositePriceFeed.sol index 1fc040862..1278a0f24 100644 --- a/contracts/src/PriceFeeds/CompositePriceFeed.sol +++ b/contracts/src/PriceFeeds/CompositePriceFeed.sol @@ -4,71 +4,112 @@ pragma solidity 0.8.24; import "../Dependencies/LiquityMath.sol"; import "./MainnetPriceFeedBase.sol"; -import "../Interfaces/ICompositePriceFeed.sol"; -// Composite PriceFeed: outputs an LST-USD price derived from two external price Oracles: LST-ETH, and ETH-USD. -// Used where the LST token is non-rebasing (as per rETH, osETH, ETHx, etc). -contract CompositePriceFeed is MainnetPriceFeedBase, ICompositePriceFeed { - Oracle public lstEthOracle; - Oracle public ethUsdOracle; +// import "forge-std/console2.sol"; +// The CompositePriceFeed is used for feeds that incorporate both a market price oracle (e.g. STETH-USD, or RETH-ETH) +// and an LST canonical rate (e.g. WSTETH:STETH, or RETH:ETH). +abstract contract CompositePriceFeed is MainnetPriceFeedBase { address public rateProviderAddress; constructor( address _owner, address _ethUsdOracleAddress, - address _lstEthOracleAddress, address _rateProviderAddress, - uint256 _ethUsdStalenessThreshold, - uint256 _lstEthStalenessThreshold - ) MainnetPriceFeedBase(_owner) { - // Store ETH-USD oracle - ethUsdOracle.aggregator = AggregatorV3Interface(_ethUsdOracleAddress); - ethUsdOracle.stalenessThreshold = _ethUsdStalenessThreshold; - ethUsdOracle.decimals = ethUsdOracle.aggregator.decimals(); - assert(ethUsdOracle.decimals == 8); - - // Store LST-ETH oracle - lstEthOracle.aggregator = AggregatorV3Interface(_lstEthOracleAddress); - lstEthOracle.stalenessThreshold = _lstEthStalenessThreshold; - lstEthOracle.decimals = lstEthOracle.aggregator.decimals(); - + uint256 _ethUsdStalenessThreshold + ) MainnetPriceFeedBase(_owner, _ethUsdOracleAddress, _ethUsdStalenessThreshold) { // Store rate provider rateProviderAddress = _rateProviderAddress; + } - _fetchPrice(); + // Returns: + // - The price, using the current price calculation + // - A bool that is true if: + // --- a) the system was not shut down prior to this call, and + // --- b) an oracle or exchange rate contract failed during this call. + function fetchPrice() public returns (uint256, bool) { + // If branch is live and the primary oracle setup has been working, try to use it + if (priceSource == PriceSource.primary) return _fetchPricePrimary(false); - // Check an oracle didn't already fail - assert(priceFeedDisabled == false); + return _fetchPriceDuringShutdown(); } - function _fetchPrice() internal override returns (uint256, bool) { - (uint256 ethUsdPrice, bool ethUsdOracleDown) = _getOracleAnswer(ethUsdOracle); - (uint256 lstEthPrice, bool lstEthOracleDown) = _getOracleAnswer(lstEthOracle); + function fetchRedemptionPrice() external returns (uint256, bool) { + // If branch is live and the primary oracle setup has been working, try to use it + if (priceSource == PriceSource.primary) return _fetchPricePrimary(true); + + return _fetchPriceDuringShutdown(); + } - // If one of Chainlink's responses was invalid in this transaction, disable this PriceFeed and - // return the last good LST-USD price calculated - if (ethUsdOracleDown) return (_disableFeedAndShutDown(address(ethUsdOracle.aggregator)), true); - if (lstEthOracleDown) return (_disableFeedAndShutDown(address(lstEthOracle.aggregator)), true); + function _shutDownAndSwitchToETHUSDxCanonical(address _failedOracleAddr, uint256 _ethUsdPrice) + internal + returns (uint256) + { + // Shut down the branch + borrowerOperations.shutdownFromOracleFailure(); - // Calculate the market LST-USD price: USD_per_LST = USD_per_ETH * ETH_per_LST - uint256 lstUsdMarketPrice = ethUsdPrice * lstEthPrice / 1e18; + priceSource = PriceSource.ETHUSDxCanonical; - // Get the ETH_per_LST canonical rate directly from the LST contract - // TODO: Should we also shutdown if the call to the canonical rate reverts, or returns 0? - uint256 lstEthRate = _getCanonicalRate(); + emit ShutDownFromOracleFailure(_failedOracleAddr); + return _fetchPriceETHUSDxCanonical(_ethUsdPrice); + } + + function _fetchPriceDuringShutdown() internal returns (uint256, bool) { + // When branch is already shut down and using ETH-USD * canonical_rate, try to use that + if (priceSource == PriceSource.ETHUSDxCanonical) { + (uint256 ethUsdPrice, bool ethUsdOracleDown) = _getOracleAnswer(ethUsdOracle); + //... but if the ETH-USD oracle *also* fails here, switch to using the lastGoodPrice + if (ethUsdOracleDown) { + // No need to shut down, since branch already is shut down + priceSource = PriceSource.lastGoodPrice; + return (lastGoodPrice, false); + } else { + return (_fetchPriceETHUSDxCanonical(ethUsdPrice), false); + } + } + + // Otherwise when branch is shut down and already using the lastGoodPrice, continue with it + assert(priceSource == PriceSource.lastGoodPrice); + return (lastGoodPrice, false); + } - // Calculate the canonical LST-USD price: USD_per_LST = USD_per_ETH * ETH_per_LST - uint256 lstUsdCanonicalPrice = ethUsdPrice * lstEthRate / 1e18; + // Only called if the primary LST oracle has failed, branch has shut down, + // and we've switched to using: ETH-USD * canonical_rate. + function _fetchPriceETHUSDxCanonical(uint256 _ethUsdPrice) internal returns (uint256) { + assert(priceSource == PriceSource.ETHUSDxCanonical); + // Get the underlying_per_LST canonical rate directly from the LST contract + (uint256 lstRate, bool exchangeRateIsDown) = _getCanonicalRate(); - // Take the minimum of (market, canonical) in order to mitigate against upward market price manipulation - uint256 lstUsdPrice = LiquityMath._min(lstUsdMarketPrice, lstUsdCanonicalPrice); + // If the exchange rate contract is down, switch to (and return) lastGoodPrice. + if (exchangeRateIsDown) { + priceSource = PriceSource.lastGoodPrice; + return lastGoodPrice; + } - lastGoodPrice = lstUsdPrice; + // Calculate the canonical LST-USD price: USD_per_LST = USD_per_ETH * underlying_per_LST + uint256 lstUsdCanonicalPrice = _ethUsdPrice * lstRate / 1e18; - return (lstUsdPrice, false); + uint256 bestPrice = LiquityMath._min(lstUsdCanonicalPrice, lastGoodPrice); + + lastGoodPrice = bestPrice; + + return bestPrice; + } + + function _withinDeviationThreshold(uint256 _priceToCheck, uint256 _referencePrice, uint256 _deviationThreshold) internal pure returns (bool) { + // Calculate the price deviation of the oracle market price relative to the canonical price + uint256 max = _referencePrice * (DECIMAL_PRECISION + _deviationThreshold) / 1e18; + uint256 min = _referencePrice * (DECIMAL_PRECISION - _deviationThreshold) / 1e18; + + return _priceToCheck >= min && _priceToCheck <= max; } - // Returns the ETH_per_LST as from the LST smart contract. Implementation depends on the specific LST. - function _getCanonicalRate() internal view virtual returns (uint256) {} + // An individual Pricefeed instance implements _fetchPricePrimary according to the data sources it uses. Returns: + // - The price + // - A bool indicating whether a new oracle failure or exchange rate failure was detected in the call + function _fetchPricePrimary(bool _isRedemption) internal virtual returns (uint256, bool) {} + + // Returns the LST exchange rate and a bool indicating whether the exchange rate failed to return a valid rate. + // Implementation depends on the specific LST. + function _getCanonicalRate() internal view virtual returns (uint256, bool) {} } diff --git a/contracts/src/PriceFeeds/ETHXPriceFeed.sol b/contracts/src/PriceFeeds/ETHXPriceFeed.sol deleted file mode 100644 index 891b048c5..000000000 --- a/contracts/src/PriceFeeds/ETHXPriceFeed.sol +++ /dev/null @@ -1,38 +0,0 @@ -// SPDX-License-Identifier: MIT - -pragma solidity 0.8.24; - -import "./CompositePriceFeed.sol"; -import "../Dependencies/IStaderOracle.sol"; - -contract ETHXPriceFeed is CompositePriceFeed { - constructor( - address _owner, - address _ethUsdOracleAddress, - address _lstEthOracleAddress, - address _rateProviderAddress, - uint256 _ethUsdStalenessThreshold, - uint256 _lstEthStalenessThreshold - ) - CompositePriceFeed( - _owner, - _ethUsdOracleAddress, - _lstEthOracleAddress, - _rateProviderAddress, - _ethUsdStalenessThreshold, - _lstEthStalenessThreshold - ) - {} - - function _getCanonicalRate() internal view override returns (uint256) { - // StaderOracle returns ETH balance and ETHX supply each with 18 digit decimal precision - - ( - , // uint256 reportingBlockNumber - uint256 ethBalance, - uint256 ethXSupply - ) = IStaderOracle(rateProviderAddress).exchangeRate(); - - return ethBalance * 1e18 / ethXSupply; - } -} diff --git a/contracts/src/PriceFeeds/MainnetPriceFeedBase.sol b/contracts/src/PriceFeeds/MainnetPriceFeedBase.sol index abd03778f..fcb8beee8 100644 --- a/contracts/src/PriceFeeds/MainnetPriceFeedBase.sol +++ b/contracts/src/PriceFeeds/MainnetPriceFeedBase.sol @@ -4,12 +4,18 @@ pragma solidity 0.8.24; import "../Dependencies/Ownable.sol"; import "../Dependencies/AggregatorV3Interface.sol"; -import "../Interfaces/IPriceFeed.sol"; +import "../Interfaces/IMainnetPriceFeed.sol"; import "../BorrowerOperations.sol"; -abstract contract MainnetPriceFeedBase is IPriceFeed, Ownable { - // Flag raised when the collateral branch gets shut down. - bool priceFeedDisabled; +// import "forge-std/console2.sol"; + +abstract contract MainnetPriceFeedBase is IMainnetPriceFeed, Ownable { + + // Determines where the PriceFeed sources data from. Possible states: + // - primary: Uses the primary price calcuation, which depends on the specific feed + // - ETHUSDxCanonical: Uses Chainlink's ETH-USD multiplied by the LST' canonical rate + // - lastGoodPrice: the last good price recorded by this PriceFeed. + PriceSource public priceSource; // Last good price tracker for the derived USD price uint256 public lastGoodPrice; @@ -27,9 +33,21 @@ abstract contract MainnetPriceFeedBase is IPriceFeed, Ownable { bool success; } + error InsufficientGasForExternalCall(); + event ShutDownFromOracleFailure(address _failedOracleAddr); + + Oracle public ethUsdOracle; + IBorrowerOperations borrowerOperations; - constructor(address _owner) Ownable(_owner) {} + constructor(address _owner, address _ethUsdOracleAddress, uint256 _ethUsdStalenessThreshold) Ownable(_owner) { + // Store ETH-USD oracle + ethUsdOracle.aggregator = AggregatorV3Interface(_ethUsdOracleAddress); + ethUsdOracle.stalenessThreshold = _ethUsdStalenessThreshold; + ethUsdOracle.decimals = ethUsdOracle.aggregator.decimals(); + + assert(ethUsdOracle.decimals == 8); + } // TODO: remove this and set address in constructor, since we'll use CREATE2 function setAddresses(address _borrowOperationsAddress) external onlyOwner { @@ -38,19 +56,6 @@ abstract contract MainnetPriceFeedBase is IPriceFeed, Ownable { _renounceOwnership(); } - // fetchPrice returns: - // - The price - // - A bool indicating whether a new oracle failure was detected in the call - function fetchPrice() public returns (uint256, bool) { - if (priceFeedDisabled) return (lastGoodPrice, false); - - return _fetchPrice(); - } - - // An individual Pricefeed instance implements _fetchPrice according to the data sources it uses. Returns: - // - The price - // - A bool indicating whether a new oracle failure was detected in the call - function _fetchPrice() internal virtual returns (uint256, bool) {} function _getOracleAnswer(Oracle memory _oracle) internal view returns (uint256, bool) { ChainlinkResponse memory chainlinkResponse = _getCurrentChainlinkResponse(_oracle.aggregator); @@ -67,11 +72,13 @@ abstract contract MainnetPriceFeedBase is IPriceFeed, Ownable { return (scaledPrice, oracleIsDown); } - function _disableFeedAndShutDown(address _failedOracleAddr) internal returns (uint256) { + function _shutDownAndSwitchToLastGoodPrice(address _failedOracleAddr) internal returns (uint256) { // Shut down the branch - borrowerOperations.shutdownFromOracleFailure(_failedOracleAddr); + borrowerOperations.shutdownFromOracleFailure(); + + priceSource = PriceSource.lastGoodPrice; - priceFeedDisabled = true; + emit ShutDownFromOracleFailure(_failedOracleAddr); return lastGoodPrice; } @@ -80,7 +87,9 @@ abstract contract MainnetPriceFeedBase is IPriceFeed, Ownable { view returns (ChainlinkResponse memory chainlinkResponse) { - // Secondly, try to get latest price data: + uint256 gasBefore = gasleft(); + + // Try to get latest price data: try _aggregator.latestRoundData() returns ( uint80 roundId, int256 answer, uint256, /* startedAt */ uint256 updatedAt, uint80 /* answeredInRound */ ) { @@ -92,6 +101,11 @@ abstract contract MainnetPriceFeedBase is IPriceFeed, Ownable { return chainlinkResponse; } catch { + // Require that enough gas was provided to prevent an OOG revert in the call to Chainlink + // causing a shutdown. Instead, just revert. Slightly conservative, as it includes gas used + // in the check itself. + if (gasleft() <= gasBefore / 64) {revert InsufficientGasForExternalCall();} + // If call to Chainlink aggregator reverts, return a zero response with success = false return chainlinkResponse; } diff --git a/contracts/src/PriceFeeds/OSETHPriceFeed.sol b/contracts/src/PriceFeeds/OSETHPriceFeed.sol deleted file mode 100644 index ef3fb2a7b..000000000 --- a/contracts/src/PriceFeeds/OSETHPriceFeed.sol +++ /dev/null @@ -1,31 +0,0 @@ -// SPDX-License-Identifier: MIT - -pragma solidity 0.8.24; - -import "./CompositePriceFeed.sol"; -import "../Dependencies/IOsTokenVaultController.sol"; - -contract OSETHPriceFeed is CompositePriceFeed { - constructor( - address _owner, - address _ethUsdOracleAddress, - address _lstEthOracleAddress, - address _rateProviderAddress, - uint256 _ethUsdStalenessThreshold, - uint256 _lstEthStalenessThreshold - ) - CompositePriceFeed( - _owner, - _ethUsdOracleAddress, - _lstEthOracleAddress, - _rateProviderAddress, - _ethUsdStalenessThreshold, - _lstEthStalenessThreshold - ) - {} - - function _getCanonicalRate() internal view override returns (uint256) { - // OsTokenVaultController returns rate with 18 digit decimal precision - return IOsTokenVaultController(rateProviderAddress).convertToAssets(1e18); - } -} diff --git a/contracts/src/PriceFeeds/RETHPriceFeed.sol b/contracts/src/PriceFeeds/RETHPriceFeed.sol index 2b0d3a169..9fe803829 100644 --- a/contracts/src/PriceFeeds/RETHPriceFeed.sol +++ b/contracts/src/PriceFeeds/RETHPriceFeed.sol @@ -3,29 +3,91 @@ pragma solidity 0.8.24; import "./CompositePriceFeed.sol"; -import "../Dependencies/IRETHToken.sol"; +import "../Interfaces/IRETHToken.sol"; +import "../Interfaces/IRETHPriceFeed.sol"; -contract RETHPriceFeed is CompositePriceFeed { +// import "forge-std/console2.sol"; + +contract RETHPriceFeed is CompositePriceFeed, IRETHPriceFeed { constructor( address _owner, address _ethUsdOracleAddress, - address _lstEthOracleAddress, - address _rateProviderAddress, + address _rEthEthOracleAddress, + address _rEthTokenAddress, uint256 _ethUsdStalenessThreshold, - uint256 _lstEthStalenessThreshold - ) - CompositePriceFeed( - _owner, - _ethUsdOracleAddress, - _lstEthOracleAddress, - _rateProviderAddress, - _ethUsdStalenessThreshold, - _lstEthStalenessThreshold - ) - {} - - function _getCanonicalRate() internal view override returns (uint256) { - // RETHToken returns exchange rate with 18 digit decimal precision - return IRETHToken(rateProviderAddress).getExchangeRate(); + uint256 _rEthEthStalenessThreshold + ) CompositePriceFeed(_owner, _ethUsdOracleAddress, _rEthTokenAddress, _ethUsdStalenessThreshold) { + // Store RETH-ETH oracle + rEthEthOracle.aggregator = AggregatorV3Interface(_rEthEthOracleAddress); + rEthEthOracle.stalenessThreshold = _rEthEthStalenessThreshold; + rEthEthOracle.decimals = rEthEthOracle.aggregator.decimals(); + + _fetchPricePrimary(false); + + // Check the oracle didn't already fail + assert(priceSource == PriceSource.primary); } + + Oracle public rEthEthOracle; + + uint256 constant public RETH_ETH_DEVIATION_THRESHOLD = 2e16; // 2% + + function _fetchPricePrimary(bool _isRedemption) internal override returns (uint256, bool) { + assert(priceSource == PriceSource.primary); + (uint256 ethUsdPrice, bool ethUsdOracleDown) = _getOracleAnswer(ethUsdOracle); + (uint256 rEthEthPrice, bool rEthEthOracleDown) = _getOracleAnswer(rEthEthOracle); + (uint256 rEthPerEth, bool exchangeRateIsDown) = _getCanonicalRate(); + + // If the ETH-USD feed is down, shut down and switch to the last good price seen by the system + // since we need both ETH-USD and canonical for primary and fallback price calcs + if (ethUsdOracleDown || exchangeRateIsDown) { + return (_shutDownAndSwitchToLastGoodPrice(address(ethUsdOracle.aggregator)), true); + } + // If the ETH-USD feed is live but the RETH-ETH oracle is down, shutdown and substitute RETH-ETH with the canonical rate + if (rEthEthOracleDown) { + return (_shutDownAndSwitchToETHUSDxCanonical(address(rEthEthOracle.aggregator), ethUsdPrice), true); + } + + // Otherwise, use the primary price calculation: + + // Calculate the market RETH-USD price: USD_per_RETH = USD_per_ETH * ETH_per_RETH + uint256 rEthUsdMarketPrice = ethUsdPrice * rEthEthPrice / 1e18; + + // Calculate the canonical LST-USD price: USD_per_RETH = USD_per_ETH * ETH_per_RETH + uint256 rEthUsdCanonicalPrice = ethUsdPrice * rEthPerEth / 1e18; + + uint256 rEthUsdPrice; + + // If it's a redemption and canonical is within 2% of market, use the max to mitigate unwanted redemption oracle arb + if (_isRedemption && _withinDeviationThreshold(rEthUsdMarketPrice, rEthUsdCanonicalPrice, RETH_ETH_DEVIATION_THRESHOLD)) { + rEthUsdPrice = LiquityMath._max(rEthUsdMarketPrice, rEthUsdCanonicalPrice); + } else { + // Take the minimum of (market, canonical) in order to mitigate against upward market price manipulation. + // Assumes a deviation between market <> canonical of >2% represents a legitimate market price difference. + rEthUsdPrice = LiquityMath._min(rEthUsdMarketPrice, rEthUsdCanonicalPrice); + } + + lastGoodPrice = rEthUsdPrice; + + return (rEthUsdPrice, false); + } + + function _getCanonicalRate() internal view override returns (uint256, bool) { + uint256 gasBefore = gasleft(); + + try IRETHToken(rateProviderAddress).getExchangeRate() returns (uint256 ethPerReth) { + // If rate is 0, return true + if (ethPerReth == 0) return (0, true); + + return (ethPerReth, false); + } catch { + // Require that enough gas was provided to prevent an OOG revert in the external call + // causing a shutdown. Instead, just revert. Slightly conservative, as it includes gas used + // in the check itself. + if (gasleft() <= gasBefore / 64) {revert InsufficientGasForExternalCall();} + + // If call to exchange rate reverts, return true + return (0, true); + } + } } diff --git a/contracts/src/PriceFeeds/WETHPriceFeed.sol b/contracts/src/PriceFeeds/WETHPriceFeed.sol index 3c5955836..8576cc5d8 100644 --- a/contracts/src/PriceFeeds/WETHPriceFeed.sol +++ b/contracts/src/PriceFeeds/WETHPriceFeed.sol @@ -3,35 +3,45 @@ pragma solidity 0.8.24; import "./MainnetPriceFeedBase.sol"; -import "../Interfaces/IWETHPriceFeed.sol"; -contract WETHPriceFeed is MainnetPriceFeedBase, IWETHPriceFeed { - Oracle public ethUsdOracle; +// import "forge-std/console2.sol"; +contract WETHPriceFeed is MainnetPriceFeedBase { constructor(address _owner, address _ethUsdOracleAddress, uint256 _ethUsdStalenessThreshold) - MainnetPriceFeedBase(_owner) + MainnetPriceFeedBase(_owner, _ethUsdOracleAddress, _ethUsdStalenessThreshold) { - ethUsdOracle.aggregator = AggregatorV3Interface(_ethUsdOracleAddress); - ethUsdOracle.stalenessThreshold = _ethUsdStalenessThreshold; - ethUsdOracle.decimals = ethUsdOracle.aggregator.decimals(); + _fetchPricePrimary(false); - // Check ETH-USD aggregator has the expected 8 decimals - assert(ethUsdOracle.decimals == 8); + // Check the oracle didn't already fail + assert(priceSource == PriceSource.primary); + } - _fetchPrice(); + function fetchPrice() public returns (uint256, bool) { + // If branch is live and the primary oracle setup has been working, try to use it + if (priceSource == PriceSource.primary) return _fetchPricePrimary(); - // Check the oracle didn't already fail - assert(priceFeedDisabled == false); + // Otherwise if branch is shut down and already using the lastGoodPrice, continue with it + assert(priceSource == PriceSource.lastGoodPrice); + return (lastGoodPrice, false); } - function _fetchPrice() internal override returns (uint256, bool) { - (uint256 ethUsdPrice, bool ethUsdOracleDown) = _getOracleAnswer(ethUsdOracle); + function fetchRedemptionPrice() external returns (uint256, bool) { + // Use same price for redemption as all other ops in WETH branch + return fetchPrice(); + } - // If the Chainlink response was invalid in this transaction, return the last good ETH-USD price calculated - if (ethUsdOracleDown) return (_disableFeedAndShutDown(address(ethUsdOracle.aggregator)), true); + // _fetchPricePrimary returns: + // - The price + // - A bool indicating whether a new oracle failure was detected in the call + function _fetchPricePrimary(bool _isRedemption) internal virtual returns (uint256, bool) {} + function _fetchPricePrimary() internal returns (uint256, bool) { + assert(priceSource == PriceSource.primary); + (uint256 ethUsdPrice, bool ethUsdOracleDown) = _getOracleAnswer(ethUsdOracle); + // If the ETH-USD Chainlink response was invalid in this transaction, return the last good ETH-USD price calculated + if (ethUsdOracleDown) return (_shutDownAndSwitchToLastGoodPrice(address(ethUsdOracle.aggregator)), true); + lastGoodPrice = ethUsdPrice; - return (ethUsdPrice, false); } } diff --git a/contracts/src/PriceFeeds/WSTETHPriceFeed.sol b/contracts/src/PriceFeeds/WSTETHPriceFeed.sol index 027920ad8..2e08ba847 100644 --- a/contracts/src/PriceFeeds/WSTETHPriceFeed.sol +++ b/contracts/src/PriceFeeds/WSTETHPriceFeed.sol @@ -2,47 +2,86 @@ pragma solidity 0.8.24; -import "./MainnetPriceFeedBase.sol"; +import "./CompositePriceFeed.sol"; import "../Interfaces/IWSTETH.sol"; import "../Interfaces/IWSTETHPriceFeed.sol"; -contract WSTETHPriceFeed is MainnetPriceFeedBase, IWSTETHPriceFeed { +// import "forge-std/console2.sol"; + +contract WSTETHPriceFeed is CompositePriceFeed, IWSTETHPriceFeed { Oracle public stEthUsdOracle; - IWSTETH public wstETH; + + uint256 constant public STETH_USD_DEVIATION_THRESHOLD = 1e16; // 1% constructor( address _owner, + address _ethUsdOracleAddress, address _stEthUsdOracleAddress, - uint256 _stEthUsdStalenessThreshold, - address _wstETHAddress - ) MainnetPriceFeedBase(_owner) { + address _wstEthTokenAddress, + uint256 _ethUsdStalenessThreshold, + uint256 _stEthUsdStalenessThreshold + ) CompositePriceFeed(_owner, _ethUsdOracleAddress, _wstEthTokenAddress, _ethUsdStalenessThreshold) { stEthUsdOracle.aggregator = AggregatorV3Interface(_stEthUsdOracleAddress); stEthUsdOracle.stalenessThreshold = _stEthUsdStalenessThreshold; stEthUsdOracle.decimals = stEthUsdOracle.aggregator.decimals(); - wstETH = IWSTETH(_wstETHAddress); - - // Check the STETH-USD aggregator has the expected 8 decimals - assert(stEthUsdOracle.decimals == 8); - - _fetchPrice(); + _fetchPricePrimary(false); // Check the oracle didn't already fail - assert(priceFeedDisabled == false); + assert(priceSource == PriceSource.primary); } - function _fetchPrice() internal override returns (uint256, bool) { + function _fetchPricePrimary(bool _isRedemption) internal override returns (uint256, bool) { + assert(priceSource == PriceSource.primary); (uint256 stEthUsdPrice, bool stEthUsdOracleDown) = _getOracleAnswer(stEthUsdOracle); + (uint256 stEthPerWstEth, bool exchangeRateIsDown) = _getCanonicalRate(); + (uint256 ethUsdPrice, bool ethUsdOracleDown) = _getOracleAnswer(ethUsdOracle); + + // - If exchange rate or ETH-USD is down, shut down and switch to last good price. Reasoning: + // - Exchange rate is used in all price calcs + // - ETH-USD is used in the fallback calc, and for redemptions in the primary price calc + if (exchangeRateIsDown || ethUsdOracleDown) { + return (_shutDownAndSwitchToLastGoodPrice(address(stEthUsdOracle.aggregator)), true); + } - // If one of Chainlink's responses was invalid in this transaction, disable this PriceFeed and - // return the last good WSTETH-USD price calculated - if (stEthUsdOracleDown) return (_disableFeedAndShutDown(address(stEthUsdOracle.aggregator)), true); + // If the STETH-USD feed is down, shut down and try to substitute it with the ETH-USD price + if (stEthUsdOracleDown) { + return (_shutDownAndSwitchToETHUSDxCanonical(address(stEthUsdOracle.aggregator), ethUsdPrice), true); + } - // Calculate WSTETH-USD price: USD_per_WSTETH = USD_per_STETH * STETH_per_WSTETH - uint256 wstEthUsdPrice = stEthUsdPrice * wstETH.stEthPerToken() / 1e18; + // Otherwise, use the primary price calculation: + uint256 wstEthUsdPrice; + + if (_isRedemption && _withinDeviationThreshold(stEthUsdPrice, ethUsdPrice, STETH_USD_DEVIATION_THRESHOLD)) { + // If it's a redemption and within 1%, take the max of (STETH-USD, ETH-USD) to mitigate unwanted redemption arb and convert to WSTETH-USD + wstEthUsdPrice = LiquityMath._max(stEthUsdPrice, ethUsdPrice) * stEthPerWstEth / 1e18; + } else { + // Otherwise, just calculate WSTETH-USD price: USD_per_WSTETH = USD_per_STETH * STETH_per_WSTETH + wstEthUsdPrice = stEthUsdPrice * stEthPerWstEth / 1e18; + } lastGoodPrice = wstEthUsdPrice; return (wstEthUsdPrice, false); } + + function _getCanonicalRate() internal view override returns (uint256, bool) { + uint256 gasBefore = gasleft(); + + try IWSTETH(rateProviderAddress).stEthPerToken() returns (uint256 stEthPerWstEth) { + // If rate is 0, return true + if (stEthPerWstEth == 0) return (0, true); + + return (stEthPerWstEth, false); + } catch { + // Require that enough gas was provided to prevent an OOG revert in the external call + // causing a shutdown. Instead, just revert. Slightly conservative, as it includes gas used + // in the check itself. + if (gasleft() <= gasBefore / 64) {revert InsufficientGasForExternalCall();} + + // If call to exchange rate reverted for another reason, return true + return (0, true); + } + + } } diff --git a/contracts/src/TroveManager.sol b/contracts/src/TroveManager.sol index 86e6b528f..66a61ecee 100644 --- a/contracts/src/TroveManager.sol +++ b/contracts/src/TroveManager.sol @@ -846,6 +846,7 @@ contract TroveManager is LiquityBase, ITroveManager, ITroveEvents { IActivePool activePoolCached = activePool; TroveChange memory totalsTroveChange; + // Use the standard fetchPrice here, since if branch has shut down we don't worry about small redemption arbs (uint256 price,) = priceFeed.fetchPrice(); uint256 remainingBold = _boldAmount; @@ -1179,7 +1180,7 @@ contract TroveManager is LiquityBase, ITroveManager, ITroveEvents { uint256 spSize = stabilityPool.getTotalBoldDeposits(); uint256 unbackedPortion = totalDebt > spSize ? totalDebt - spSize : 0; - (uint256 price,) = priceFeed.fetchPrice(); + (uint256 price,) = priceFeed.fetchRedemptionPrice(); // It's redeemable if the TCR is above the shutdown threshold, and branch has not been shut down bool redeemable = _getTCR(price) >= SCR && shutdownTime == 0; @@ -1769,7 +1770,7 @@ contract TroveManager is LiquityBase, ITroveManager, ITroveEvents { uint256 _batchDebt, // entire (with interest, batch fee), but without trove change, nor upfront fee nor redist bool _checkBatchSharesRatio // whether we do the check on the resulting ratio inside the func call ) internal { - // Debt + // Debt uint256 currentBatchDebtShares = batches[_batchAddress].totalDebtShares; uint256 batchDebtSharesDelta; uint256 debtIncrease = diff --git a/contracts/src/test/OracleMainnet.t.sol b/contracts/src/test/OracleMainnet.t.sol index e7f25eaab..3a62dd1e0 100644 --- a/contracts/src/test/OracleMainnet.t.sol +++ b/contracts/src/test/OracleMainnet.t.sol @@ -2,42 +2,43 @@ pragma solidity 0.8.24; +import "../PriceFeeds/WSTETHPriceFeed.sol"; +import "../PriceFeeds/MainnetPriceFeedBase.sol"; +import "../PriceFeeds/RETHPriceFeed.sol"; +import "../PriceFeeds/WETHPriceFeed.sol"; + import "./TestContracts/Accounts.sol"; import "./TestContracts/ChainlinkOracleMock.sol"; +import "./TestContracts/RETHTokenMock.sol"; +import "./TestContracts/WSTETHTokenMock.sol"; import "./TestContracts/Deployment.t.sol"; import "../Dependencies/AggregatorV3Interface.sol"; -import "../Interfaces/IWSTETH.sol"; -import "../Interfaces/ICompositePriceFeed.sol"; -import "../Interfaces/IWETHPriceFeed.sol"; +import "../Interfaces/IRETHPriceFeed.sol"; +import "../Interfaces/IWSTETHPriceFeed.sol"; -import "../Dependencies/IRETHToken.sol"; -import "../Dependencies/IOsTokenVaultController.sol"; -import "../Dependencies/IStaderOracle.sol"; +import "../Interfaces/IRETHToken.sol"; +import "../Interfaces/IWSTETH.sol"; import "forge-std/Test.sol"; -import "forge-std/console2.sol"; +import "lib/forge-std/src/console2.sol"; contract OraclesMainnet is TestAccounts { AggregatorV3Interface ethOracle; AggregatorV3Interface stethOracle; AggregatorV3Interface rethOracle; - AggregatorV3Interface ethXOracle; - AggregatorV3Interface osEthOracle; ChainlinkOracleMock mockOracle; - IWSTETH wstETH; - - IWETHPriceFeed wethPriceFeed; - ICompositePriceFeed rethPriceFeed; + IMainnetPriceFeed wethPriceFeed; + IRETHPriceFeed rethPriceFeed; IWSTETHPriceFeed wstethPriceFeed; - ICompositePriceFeed ethXPriceFeed; - ICompositePriceFeed osEthPriceFeed; - IRETHToken rETHToken; - IOsTokenVaultController osTokenVaultController; - IStaderOracle staderOracle; + IRETHToken rethToken; + IWSTETH wstETH; + + RETHTokenMock mockRethToken; + WSTETHTokenMock mockWstethToken; TestDeployer.LiquityContracts[] contractsArray; ICollateralRegistry collateralRegistry; @@ -49,8 +50,17 @@ contract OraclesMainnet is TestAccounts { uint256 decimals; } + struct Vars { + uint256 numCollaterals; + uint256 initialColl; + uint256 price; + uint256 coll; + uint256 debtRequest; + } + function setUp() public { vm.createSelectFork(vm.rpcUrl("mainnet")); + Vars memory vars; accounts = new Accounts(); createAccounts(); @@ -58,11 +68,11 @@ contract OraclesMainnet is TestAccounts { (A, B, C, D, E, F) = (accountsList[0], accountsList[1], accountsList[2], accountsList[3], accountsList[4], accountsList[5]); - uint256 numCollaterals = 5; + vars.numCollaterals = 3; TestDeployer.TroveManagerParams memory tmParams = TestDeployer.TroveManagerParams(150e16, 110e16, 110e16, 5e16, 10e16); TestDeployer.TroveManagerParams[] memory troveManagerParamsArray = - new TestDeployer.TroveManagerParams[](numCollaterals); + new TestDeployer.TroveManagerParams[](vars.numCollaterals); for (uint256 i = 0; i < troveManagerParamsArray.length; i++) { troveManagerParamsArray[i] = tmParams; } @@ -76,27 +86,26 @@ contract OraclesMainnet is TestAccounts { ethOracle = AggregatorV3Interface(result.externalAddresses.ETHOracle); rethOracle = AggregatorV3Interface(result.externalAddresses.RETHOracle); stethOracle = AggregatorV3Interface(result.externalAddresses.STETHOracle); - ethXOracle = AggregatorV3Interface(result.externalAddresses.ETHXOracle); - osEthOracle = AggregatorV3Interface(result.externalAddresses.OSETHOracle); mockOracle = new ChainlinkOracleMock(); - rETHToken = IRETHToken(result.externalAddresses.RETHToken); - staderOracle = IStaderOracle(result.externalAddresses.StaderOracle); - osTokenVaultController = IOsTokenVaultController(result.externalAddresses.OsTokenVaultController); + rethToken = IRETHToken(result.externalAddresses.RETHToken); wstETH = IWSTETH(result.externalAddresses.WSTETHToken); + mockRethToken = new RETHTokenMock(); + mockWstethToken = new WSTETHTokenMock(); + // Record contracts - for (uint256 c = 0; c < numCollaterals; c++) { + for (uint256 c = 0; c < vars.numCollaterals; c++) { contractsArray.push(result.contractsArray[c]); } // Give all users all collaterals - uint256 initialColl = 1000_000e18; + vars.initialColl = 1000_000e18; for (uint256 i = 0; i < 6; i++) { - for (uint256 j = 0; j < numCollaterals; j++) { - deal(address(contractsArray[j].collToken), accountsList[i], initialColl); + for (uint256 j = 0; j < vars.numCollaterals; j++) { + deal(address(contractsArray[j].collToken), accountsList[i], vars.initialColl); vm.startPrank(accountsList[i]); // Approve all Borrower Ops to use the user's WETH funds contractsArray[0].collToken.approve(address(contractsArray[j].borrowerOperations), type(uint256).max); @@ -108,18 +117,15 @@ contract OraclesMainnet is TestAccounts { vm.startPrank(accountsList[i]); } - wethPriceFeed = IWETHPriceFeed(address(contractsArray[0].priceFeed)); - rethPriceFeed = ICompositePriceFeed(address(contractsArray[1].priceFeed)); + wethPriceFeed = IMainnetPriceFeed(address(contractsArray[0].priceFeed)); + rethPriceFeed = IRETHPriceFeed(address(contractsArray[1].priceFeed)); wstethPriceFeed = IWSTETHPriceFeed(address(contractsArray[2].priceFeed)); - ethXPriceFeed = ICompositePriceFeed(address(contractsArray[3].priceFeed)); - osEthPriceFeed = ICompositePriceFeed(address(contractsArray[4].priceFeed)); // log some current blockchain state // console2.log(block.timestamp, "block.timestamp"); // console2.log(block.number, "block.number"); // console2.log(ethOracle.decimals(), "ETHUSD decimals"); // console2.log(rethOracle.decimals(), "RETHETH decimals"); - // console2.log(ethXOracle.decimals(), "ETHXETH decimals"); // console2.log(stethOracle.decimals(), "STETHETH decimals"); } @@ -132,6 +138,47 @@ contract OraclesMainnet is TestAccounts { return uint256(answer) * 10 ** (18 - decimals); } + function redeem(address _from, uint256 _boldAmount) public { + vm.startPrank(_from); + collateralRegistry.redeemCollateral(_boldAmount, MAX_UINT256, 1e18); + vm.stopPrank(); + } + + function etchStaleMockToEthOracle(bytes memory _mockOracleCode) internal { + // Etch the mock code to the ETH-USD oracle address + vm.etch(address(ethOracle), _mockOracleCode); + ChainlinkOracleMock mock = ChainlinkOracleMock(address(ethOracle)); + mock.setDecimals(8); + // Fake ETH-USD price of 2000 USD + mock.setPrice(2000e8); + // Make it stale + mock.setUpdatedAt(block.timestamp - 7 days); + } + + function etchStaleMockToRethOracle(bytes memory _mockOracleCode) internal { + // Etch the mock code to the RETH-ETH oracle address + vm.etch(address(rethOracle), _mockOracleCode); + // Wrap so we can use the mock's setters + ChainlinkOracleMock mock = ChainlinkOracleMock(address(rethOracle)); + mock.setDecimals(18); + // Set 1 RETH = 1 ETH + mock.setPrice(1e18); + // Make it stale + mock.setUpdatedAt(block.timestamp - 7 days); + } + + function etchStaleMockToStethOracle(bytes memory _mockOracleCode) internal { + // Etch the mock code to the STETH-USD oracle address + vm.etch(address(stethOracle), _mockOracleCode); + // Wrap so we can use the mock's setters + ChainlinkOracleMock mock = ChainlinkOracleMock(address(stethOracle)); + mock.setDecimals(8); + // Set 1 STETH = 2000 USD + mock.setPrice(2000e8); + // Make it stale + mock.setUpdatedAt(block.timestamp - 7 days); + } + // --- lastGoodPrice set on deployment --- function testSetLastGoodPriceOnDeploymentWETH() public view { @@ -152,7 +199,7 @@ contract OraclesMainnet is TestAccounts { uint256 expectedMarketPrice = latestAnswerREthEth * latestAnswerEthUsd / 1e18; - uint256 rate = rETHToken.getExchangeRate(); + uint256 rate = rethToken.getExchangeRate(); assertGt(rate, 1e18); uint256 expectedCanonicalPrice = rate * latestAnswerEthUsd / 1e18; @@ -162,45 +209,6 @@ contract OraclesMainnet is TestAccounts { assertEq(lastGoodPriceReth, expectedPrice); } - function testSetLastGoodPriceOnDeploymentETHX() public view { - uint256 lastGoodPriceEthX = ethXPriceFeed.lastGoodPrice(); - assertGt(lastGoodPriceEthX, 0); - - uint256 latestAnswerEthXEth = _getLatestAnswerFromOracle(ethXOracle); - uint256 latestAnswerEthUsd = _getLatestAnswerFromOracle(ethOracle); - - uint256 expectedMarketPrice = latestAnswerEthXEth * latestAnswerEthUsd / 1e18; - - (, uint256 ethBalance, uint256 ethXSupply) = staderOracle.exchangeRate(); - uint256 rate = ethBalance * 1e18 / ethXSupply; - assertGt(rate, 1e18); - - uint256 expectedCanonicalPrice = rate * latestAnswerEthUsd / 1e18; - - uint256 expectedPrice = LiquityMath._min(expectedMarketPrice, expectedCanonicalPrice); - - assertEq(lastGoodPriceEthX, expectedPrice); - } - - function testSetLastGoodPriceOnDeploymentOSETH() public view { - uint256 lastGoodPriceOsUsd = osEthPriceFeed.lastGoodPrice(); - assertGt(lastGoodPriceOsUsd, 0); - - uint256 latestAnswerOsEthEth = _getLatestAnswerFromOracle(osEthOracle); - uint256 latestAnswerEthUsd = _getLatestAnswerFromOracle(ethOracle); - - uint256 expectedMarketPrice = latestAnswerOsEthEth * latestAnswerEthUsd / 1e18; - - uint256 rate = osTokenVaultController.convertToAssets(1e18); - assertGt(rate, 1e18); - - uint256 expectedCanonicalPrice = rate * latestAnswerEthUsd / 1e18; - - uint256 expectedPrice = LiquityMath._min(expectedMarketPrice, expectedCanonicalPrice); - - assertEq(lastGoodPriceOsUsd, expectedPrice); - } - function testSetLastGoodPriceOnDeploymentWSTETH() public view { uint256 lastGoodPriceWsteth = wstethPriceFeed.lastGoodPrice(); assertGt(lastGoodPriceWsteth, 0); @@ -233,7 +241,7 @@ contract OraclesMainnet is TestAccounts { uint256 expectedMarketPrice = latestAnswerREthEth * latestAnswerEthUsd / 1e18; - uint256 rate = rETHToken.getExchangeRate(); + uint256 rate = rethToken.getExchangeRate(); assertGt(rate, 1e18); uint256 expectedCanonicalPrice = rate * latestAnswerEthUsd / 1e18; @@ -243,45 +251,6 @@ contract OraclesMainnet is TestAccounts { assertEq(fetchedRethUsdPrice, expectedPrice); } - function testFetchPriceReturnsCorrectPriceETHX() public { - (uint256 fetchedEthXUsdPrice,) = ethXPriceFeed.fetchPrice(); - assertGt(fetchedEthXUsdPrice, 0); - - uint256 latestAnswerEthXEth = _getLatestAnswerFromOracle(ethXOracle); - uint256 latestAnswerEthUsd = _getLatestAnswerFromOracle(ethOracle); - - uint256 expectedMarketPrice = latestAnswerEthXEth * latestAnswerEthUsd / 1e18; - - (, uint256 ethBalance, uint256 ethXSupply) = staderOracle.exchangeRate(); - uint256 rate = ethBalance * 1e18 / ethXSupply; - assertGt(rate, 1e18); - - uint256 expectedCanonicalPrice = rate * latestAnswerEthUsd / 1e18; - - uint256 expectedPrice = LiquityMath._min(expectedMarketPrice, expectedCanonicalPrice); - - assertEq(fetchedEthXUsdPrice, expectedPrice); - } - - function testFetchPriceReturnsCorrectPriceOSETH() public { - (uint256 fetchedOsEthUsdPrice,) = osEthPriceFeed.fetchPrice(); - assertGt(fetchedOsEthUsdPrice, 0); - - uint256 latestAnswerOsEthEth = _getLatestAnswerFromOracle(osEthOracle); - uint256 latestAnswerEthUsd = _getLatestAnswerFromOracle(ethOracle); - - uint256 expectedMarketPrice = latestAnswerOsEthEth * latestAnswerEthUsd / 1e18; - - uint256 rate = osTokenVaultController.convertToAssets(1e18); - assertGt(rate, 1e18); - - uint256 expectedCanonicalPrice = rate * latestAnswerEthUsd / 1e18; - - uint256 expectedPrice = LiquityMath._min(expectedMarketPrice, expectedCanonicalPrice); - - assertEq(fetchedOsEthUsdPrice, expectedPrice); - } - function testFetchPriceReturnsCorrectPriceWSTETH() public { (uint256 fetchedStethUsdPrice,) = wstethPriceFeed.fetchPrice(); assertGt(fetchedStethUsdPrice, 0); @@ -307,30 +276,10 @@ contract OraclesMainnet is TestAccounts { } function testRethEthStalenessThresholdSetRETH() public view { - (, uint256 storedRethEthStaleness,) = rethPriceFeed.lstEthOracle(); + (, uint256 storedRethEthStaleness,) = rethPriceFeed.rEthEthOracle(); assertEq(storedRethEthStaleness, _48_HOURS); } - function testEthUsdStalenessThresholdSetETHX() public view { - (, uint256 storedEthUsdStaleness,) = ethXPriceFeed.ethUsdOracle(); - assertEq(storedEthUsdStaleness, _24_HOURS); - } - - function testEthXEthStalenessThresholdSetETHX() public view { - (, uint256 storedEthXEthStaleness,) = ethXPriceFeed.lstEthOracle(); - assertEq(storedEthXEthStaleness, _48_HOURS); - } - - function testEthUsdStalenessThresholdSetOSETH() public view { - (, uint256 storedEthUsdStaleness,) = osEthPriceFeed.ethUsdOracle(); - assertEq(storedEthUsdStaleness, _24_HOURS); - } - - function testOsEthEthStalenessThresholdSetOSETH() public view { - (, uint256 storedOsEthStaleness,) = osEthPriceFeed.lstEthOracle(); - assertEq(storedOsEthStaleness, _48_HOURS); - } - function testStethUsdStalenessThresholdSetWSTETH() public view { (, uint256 storedStEthUsdStaleness,) = wstethPriceFeed.stEthUsdOracle(); assertEq(storedStEthUsdStaleness, _24_HOURS); @@ -377,51 +326,9 @@ contract OraclesMainnet is TestAccounts { assertEq(trovesCount, 1); } - function testOpenTroveETHX() public { - uint256 latestAnswerEthXEth = _getLatestAnswerFromOracle(ethXOracle); - uint256 latestAnswerEthUsd = _getLatestAnswerFromOracle(ethOracle); - - uint256 calcdEthXUsdPrice = latestAnswerEthXEth * latestAnswerEthUsd / 1e18; - - uint256 coll = 5 ether; - uint256 debtRequest = coll * calcdEthXUsdPrice / 2 / 1e18; - - uint256 trovesCount = contractsArray[3].troveManager.getTroveIdsCount(); - assertEq(trovesCount, 0); - - vm.startPrank(A); - contractsArray[3].borrowerOperations.openTrove( - A, 0, coll, debtRequest, 0, 0, 5e16, debtRequest, address(0), address(0), address(0) - ); - - trovesCount = contractsArray[3].troveManager.getTroveIdsCount(); - assertEq(trovesCount, 1); - } - - function testOpenTroveOSETH() public { - uint256 latestAnswerOsEthEth = _getLatestAnswerFromOracle(osEthOracle); - uint256 latestAnswerEthUsd = _getLatestAnswerFromOracle(ethOracle); - - uint256 calcdOsEthUsdPrice = latestAnswerOsEthEth * latestAnswerEthUsd / 1e18; - - uint256 coll = 5 ether; - uint256 debtRequest = coll * calcdOsEthUsdPrice / 2 / 1e18; - - uint256 trovesCount = contractsArray[4].troveManager.getTroveIdsCount(); - assertEq(trovesCount, 0); - - vm.startPrank(A); - contractsArray[4].borrowerOperations.openTrove( - A, 0, coll, debtRequest, 0, 0, 5e16, debtRequest, address(0), address(0), address(0) - ); - - trovesCount = contractsArray[4].troveManager.getTroveIdsCount(); - assertEq(trovesCount, 1); - } - function testOpenTroveWSTETH() public { uint256 latestAnswerStethUsd = _getLatestAnswerFromOracle(stethOracle); - uint256 wstethStethExchangeRate = wstETH.tokensPerStEth(); + uint256 wstethStethExchangeRate = wstETH.stEthPerToken(); uint256 calcdWstethUsdPrice = latestAnswerStethUsd * wstethStethExchangeRate / 1e18; @@ -444,41 +351,38 @@ contract OraclesMainnet is TestAccounts { function testManipulatedChainlinkReturnsStalePrice() public { // Replace the ETH Oracle's code with the mock oracle's code that returns a stale price - vm.etch(address(ethOracle), address(mockOracle).code); + etchStaleMockToEthOracle(address(mockOracle).code); (,,, uint256 updatedAt,) = ethOracle.latestRoundData(); - console2.log(updatedAt); - console2.log(block.timestamp); - // Confirm it's stale assertEq(updatedAt, block.timestamp - 7 days); } function testManipulatedChainlinkReturns2kUsdPrice() public { // Replace the ETH Oracle's code with the mock oracle's code that returns a stale price - vm.etch(address(ethOracle), address(mockOracle).code); + etchStaleMockToEthOracle(address(mockOracle).code); uint256 price = _getLatestAnswerFromOracle(ethOracle); assertEq(price, 2000e18); } function testOpenTroveWETHWithStalePriceReverts() public { - vm.etch(address(ethOracle), address(mockOracle).code); + Vars memory vars; + etchStaleMockToEthOracle(address(mockOracle).code); (,,, uint256 updatedAt,) = ethOracle.latestRoundData(); assertEq(updatedAt, block.timestamp - 7 days); assertFalse(contractsArray[0].borrowerOperations.hasBeenShutDown()); - uint256 price = _getLatestAnswerFromOracle(ethOracle); - - uint256 coll = 5 ether; - uint256 debtRequest = coll * price / 2 / 1e18; + vars.price = _getLatestAnswerFromOracle(ethOracle); + vars.coll = 5 ether; + vars.debtRequest = vars.coll * vars.price / 2 / 1e18; vm.startPrank(A); vm.expectRevert(BorrowerOperations.NewOracleFailureDetected.selector); contractsArray[0].borrowerOperations.openTrove( - A, 0, coll, debtRequest, 0, 0, 5e16, debtRequest, address(0), address(0), address(0) + A, 0, vars.coll, vars.debtRequest, 0, 0, 5e16, vars.debtRequest, address(0), address(0), address(0) ); } @@ -498,7 +402,7 @@ contract OraclesMainnet is TestAccounts { assertEq(trovesCount, 1); // Replace oracle with a stale oracle - vm.etch(address(ethOracle), address(mockOracle).code); + etchStaleMockToEthOracle(address(mockOracle).code); (,,, uint256 updatedAt,) = ethOracle.latestRoundData(); assertEq(updatedAt, block.timestamp - 7 days); @@ -508,7 +412,7 @@ contract OraclesMainnet is TestAccounts { } function testOpenTroveWSTETHWithStalePriceReverts() public { - vm.etch(address(stethOracle), address(mockOracle).code); + etchStaleMockToStethOracle(address(mockOracle).code); (,,, uint256 updatedAt,) = stethOracle.latestRoundData(); assertEq(updatedAt, block.timestamp - 7 days); @@ -542,7 +446,7 @@ contract OraclesMainnet is TestAccounts { assertEq(trovesCount, 1); // Replace oracle with a stale oracle - vm.etch(address(stethOracle), address(mockOracle).code); + etchStaleMockToStethOracle(address(mockOracle).code); (,,, uint256 updatedAt,) = stethOracle.latestRoundData(); assertEq(updatedAt, block.timestamp - 7 days); @@ -553,7 +457,7 @@ contract OraclesMainnet is TestAccounts { function testOpenTroveRETHWithStaleRETHPriceReverts() public { // Make only RETH oracle stale - vm.etch(address(rethOracle), address(mockOracle).code); + etchStaleMockToRethOracle(address(mockOracle).code); (,,, uint256 updatedAt,) = rethOracle.latestRoundData(); assertEq(updatedAt, block.timestamp - 7 days); @@ -591,7 +495,7 @@ contract OraclesMainnet is TestAccounts { assertEq(trovesCount, 1); // Make only RETH oracle stale - vm.etch(address(rethOracle), address(mockOracle).code); + etchStaleMockToRethOracle(address(mockOracle).code); (,,, uint256 updatedAt,) = rethOracle.latestRoundData(); assertEq(updatedAt, block.timestamp - 7 days); @@ -602,7 +506,7 @@ contract OraclesMainnet is TestAccounts { function testOpenTroveRETHWithStaleETHPriceReverts() public { // Make only ETH oracle stale - vm.etch(address(ethOracle), address(mockOracle).code); + etchStaleMockToEthOracle(address(mockOracle).code); (,,, uint256 updatedAt,) = ethOracle.latestRoundData(); assertEq(updatedAt, block.timestamp - 7 days); @@ -631,8 +535,7 @@ contract OraclesMainnet is TestAccounts { uint256 debtRequest = coll * calcdRethUsdPrice / 2 / 1e18; vm.startPrank(A); - /* uint256 troveId = */ - contractsArray[1].borrowerOperations.openTrove( + uint256 troveId = contractsArray[1].borrowerOperations.openTrove( A, 0, coll, debtRequest, 0, 0, 5e16, debtRequest, address(0), address(0), address(0) ); @@ -641,17 +544,1455 @@ contract OraclesMainnet is TestAccounts { assertEq(trovesCount, 1); // Make only ETH oracle stale - vm.etch(address(ethOracle), address(mockOracle).code); + etchStaleMockToEthOracle(address(mockOracle).code); (,,, uint256 updatedAt,) = ethOracle.latestRoundData(); assertEq(updatedAt, block.timestamp - 7 days); // // Try to adjust Trove - // vm.expectRevert(BorrowerOperations.NewOracleFailureDetected.selector); - // contractsArray[1].borrowerOperations.adjustTrove(troveId, 0, false, 1 wei, true, 1e18); + vm.expectRevert(BorrowerOperations.NewOracleFailureDetected.selector); + contractsArray[1].borrowerOperations.adjustTrove(troveId, 0, false, 1 wei, true, 1e18); + } + + // --- WETH shutdown --- + + function testWETHPriceFeedShutsDownWhenETHUSDOracleFails() public { + // Fetch price + (uint256 price, bool ethUsdFailed) = wethPriceFeed.fetchPrice(); + assertGt(price, 0); + + // Check oracle call didn't fail + assertFalse(ethUsdFailed); + + // Check branch is live, not shut down + assertEq(contractsArray[0].troveManager.shutdownTime(), 0); + + // Make the ETH-USD oracle stale + etchStaleMockToEthOracle(address(mockOracle).code); + (,,, uint256 updatedAt,) = ethOracle.latestRoundData(); + assertEq(updatedAt, block.timestamp - 7 days); + + // Fetch price again + (, ethUsdFailed) = wethPriceFeed.fetchPrice(); + + // Check oracle call failed this time + assertTrue(ethUsdFailed); + + // Confirm the branch is now shutdown + assertEq(contractsArray[0].troveManager.shutdownTime(), block.timestamp); + } + + function testWETHPriceFeedReturnsLastGoodPriceWhenETHUSDOracleFails() public { + // Fetch price + wethPriceFeed.fetchPrice(); + uint256 lastGoodPrice1 = wethPriceFeed.lastGoodPrice(); + assertGt(lastGoodPrice1, 0, "lastGoodPrice 0"); + + // Make the ETH-USD oracle stale + etchStaleMockToEthOracle(address(mockOracle).code); + (, int256 mockPrice,, uint256 updatedAt,) = ethOracle.latestRoundData(); + assertEq(updatedAt, block.timestamp - 7 days); + assertGt(mockPrice, 0, "mockPrice 0"); + // Confirm the lastGoodPrice is not coincidentally equal to the mock oracle's price + assertNotEq(lastGoodPrice1, uint256(mockPrice)); + + // Fetch price again + (uint256 price, bool ethUsdFailed) = wethPriceFeed.fetchPrice(); + + // Check oracle call failed this time + assertTrue(ethUsdFailed); + + // Confirm the PriceFeed's returned price equals the lastGoodPrice + assertEq(price, lastGoodPrice1, "current price != lastGoodPrice"); + + // Confirm the stored lastGoodPrice has not changed + assertEq(wethPriceFeed.lastGoodPrice(), lastGoodPrice1, "lastGoodPrice not same"); + } + + // --- RETH shutdown --- + + function testRETHPriceFeedShutsDownWhenETHUSDOracleFails() public { + // Fetch price + (uint256 price, bool oracleFailedWhileBranchLive) = rethPriceFeed.fetchPrice(); + assertGt(price, 0); + + // Check oracle call didn't fail + assertFalse(oracleFailedWhileBranchLive); + + // Check branch is live, not shut down + assertEq(contractsArray[1].troveManager.shutdownTime(), 0); + + // Make the ETH-USD oracle stale + etchStaleMockToEthOracle(address(mockOracle).code); + (,,, uint256 updatedAt,) = ethOracle.latestRoundData(); + assertEq(updatedAt, block.timestamp - 7 days); + + // Fetch price again + (, oracleFailedWhileBranchLive) = rethPriceFeed.fetchPrice(); + + // Check an oracle call failed this time + assertTrue(oracleFailedWhileBranchLive); + + // Confirm the branch is now shutdown + assertEq(contractsArray[1].troveManager.shutdownTime(), block.timestamp); + } + + function testRETHPriceFeedShutsDownWhenExchangeRateFails() public { + // Fetch price + (uint256 price, bool oracleFailedWhileBranchLive) = rethPriceFeed.fetchPrice(); + assertGt(price, 0); + + // Check oracle call didn't fail + assertFalse(oracleFailedWhileBranchLive); + + // Check branch is live, not shut down + assertEq(contractsArray[1].troveManager.shutdownTime(), 0); + + // Make the exchange rate 0 + vm.etch(address(rethToken), address(mockRethToken).code); + uint256 rate = rethToken.getExchangeRate(); + assertEq(rate, 0); + + // Fetch price again + (, oracleFailedWhileBranchLive) = rethPriceFeed.fetchPrice(); + + // Check a call failed this time + assertTrue(oracleFailedWhileBranchLive); + + // Confirm the branch is now shutdown + assertEq(contractsArray[1].troveManager.shutdownTime(), block.timestamp, "timestamps not equal"); + } + + function testRETHPriceFeedReturnsLastGoodPriceWhenETHUSDOracleFails() public { + // Fetch price + rethPriceFeed.fetchPrice(); + uint256 lastGoodPrice1 = rethPriceFeed.lastGoodPrice(); + assertGt(lastGoodPrice1, 0, "lastGoodPrice 0"); + + // Make the ETH-USD oracle stale + etchStaleMockToEthOracle(address(mockOracle).code); + (, int256 mockPrice,, uint256 updatedAt,) = ethOracle.latestRoundData(); + assertEq(updatedAt, block.timestamp - 7 days); + assertGt(mockPrice, 0, "mockPrice 0"); + + // Fetch price again + (uint256 price, bool oracleFailedWhileBranchLive) = rethPriceFeed.fetchPrice(); + + // Check an oracle call failed this time + assertTrue(oracleFailedWhileBranchLive); + + // Confirm the PriceFeed's returned price equals the lastGoodPrice + assertEq(price, lastGoodPrice1); + + // Confirm the stored lastGoodPrice has not changed + assertEq(rethPriceFeed.lastGoodPrice(), lastGoodPrice1); + } + + function testRETHPriceFeedReturnsLastGoodPriceWhenExchangeRateFails() public { + // Fetch price + rethPriceFeed.fetchPrice(); + uint256 lastGoodPrice1 = rethPriceFeed.lastGoodPrice(); + assertGt(lastGoodPrice1, 0, "lastGoodPrice 0"); + + // Make the exchange rate 0 + vm.etch(address(rethToken), address(mockRethToken).code); + uint256 rate = rethToken.getExchangeRate(); + assertEq(rate, 0); + + // Fetch price again + (uint256 price, bool oracleFailedWhileBranchLive) = rethPriceFeed.fetchPrice(); + + // Check an oracle call failed this time + assertTrue(oracleFailedWhileBranchLive); + + // Confirm the PriceFeed's returned price equals the lastGoodPrice + assertEq(price, lastGoodPrice1); + + // Confirm the stored lastGoodPrice has not changed + assertEq(rethPriceFeed.lastGoodPrice(), lastGoodPrice1); + } + + function testRETHPriceSourceIsLastGoodPriceWhenETHUSDFails() public { + // Fetch price + rethPriceFeed.fetchPrice(); + + // Check using primary + assertEq(uint8(rethPriceFeed.priceSource()), uint8(IMainnetPriceFeed.PriceSource.primary)); + + // Make the ETH-USD oracle stale + etchStaleMockToEthOracle(address(mockOracle).code); + (,,, uint256 updatedAt,) = ethOracle.latestRoundData(); + assertEq(updatedAt, block.timestamp - 7 days); + + // Fetch price again + (, bool oracleFailedWhileBranchLive) = rethPriceFeed.fetchPrice(); + + assertTrue(oracleFailedWhileBranchLive); + + // Check using lastGoodPrice + assertEq(uint8(rethPriceFeed.priceSource()), uint8(IMainnetPriceFeed.PriceSource.lastGoodPrice)); + } + + function testRETHPriceFeedShutsDownWhenRETHETHOracleFails() public { + // Fetch price + (uint256 price, bool oracleFailedWhileBranchLive) = rethPriceFeed.fetchPrice(); + assertGt(price, 0); + + // Check oracle call didn't fail + assertFalse(oracleFailedWhileBranchLive); + + // Check branch is live, not shut down + assertEq(contractsArray[1].troveManager.shutdownTime(), 0); + + // Make the RETH-ETH oracle stale + etchStaleMockToRethOracle(address(mockOracle).code); + (,,, uint256 updatedAt,) = rethOracle.latestRoundData(); + assertEq(updatedAt, block.timestamp - 7 days); + + // Fetch price again + (, oracleFailedWhileBranchLive) = rethPriceFeed.fetchPrice(); + + // Check an oracle call failed this time + assertTrue(oracleFailedWhileBranchLive); + + // Confirm the branch is now shutdown + assertEq(contractsArray[1].troveManager.shutdownTime(), block.timestamp); + } + + function testFetchPriceReturnsMinETHUSDxCanonicalAndLastGoodPriceWhenRETHETHOracleFails() public { + // Make the RETH-ETH oracle stale + etchStaleMockToRethOracle(address(mockOracle).code); + (,,, uint256 updatedAt,) = rethOracle.latestRoundData(); + assertEq(updatedAt, block.timestamp - 7 days); + + // Fetch price + (uint256 price, bool oracleFailedWhileBranchLive) = rethPriceFeed.fetchPrice(); + assertGt(price, 0); + + // Check that the primary calc oracle did fail + assertTrue(oracleFailedWhileBranchLive); + + // Calc expected price i.e. ETH-USD x canonical + uint256 ethUsdPrice = _getLatestAnswerFromOracle(ethOracle); + uint256 exchangeRate = rethToken.getExchangeRate(); + assertGt(ethUsdPrice, 0); + assertGt(exchangeRate, 0); + + uint256 expectedPrice = LiquityMath._min(rethPriceFeed.lastGoodPrice(), ethUsdPrice * exchangeRate / 1e18); + + assertEq(price, expectedPrice, "price not expected price"); + } + + function testRETHPriceSourceIsETHUSDxCanonicalWhenRETHETHFails() public { + // Fetch price + rethPriceFeed.fetchPrice(); + + // Check using primary + assertEq(uint8(rethPriceFeed.priceSource()), uint8(IMainnetPriceFeed.PriceSource.primary)); + + // Make the RETH-ETH oracle stale + etchStaleMockToRethOracle(address(mockOracle).code); + (,,, uint256 updatedAt,) = rethOracle.latestRoundData(); + assertEq(updatedAt, block.timestamp - 7 days); + + // Fetch price again + (, bool oracleFailedWhileBranchLive) = rethPriceFeed.fetchPrice(); + + assertTrue(oracleFailedWhileBranchLive); + + // Check using canonical + assertEq(uint8(rethPriceFeed.priceSource()), uint8(IMainnetPriceFeed.PriceSource.ETHUSDxCanonical)); + } + + function testRETHWhenUsingETHUSDxCanonicalSwitchesToLastGoodPriceWhenETHUSDOracleFails() public { + // Make the RETH-USD oracle stale + etchStaleMockToRethOracle(address(mockOracle).code); + (,,, uint256 updatedAt,) = rethOracle.latestRoundData(); + assertEq(updatedAt, block.timestamp - 7 days); + + // Check using primary + assertEq(uint8(rethPriceFeed.priceSource()), uint8(IMainnetPriceFeed.PriceSource.primary), "not using primary"); + + // Fetch price + (uint256 price, bool oracleFailedWhileBranchLive) = rethPriceFeed.fetchPrice(); + + // Check that the primary calc oracle did fail + assertTrue(oracleFailedWhileBranchLive, "primary oracle calc didnt fail"); + + // Check using ETHUSDxCanonical + assertEq( + uint8(rethPriceFeed.priceSource()), + uint8(IMainnetPriceFeed.PriceSource.ETHUSDxCanonical), + "not using ethusdxcanonical" + ); + + uint256 lastGoodPrice = rethPriceFeed.lastGoodPrice(); + + // Make the ETH-USD oracle stale too + etchStaleMockToEthOracle(address(mockOracle).code); + (,,, updatedAt,) = ethOracle.latestRoundData(); + assertEq(updatedAt, block.timestamp - 7 days); + + // Calc expected price if didnt fail, i.e. ETH-USD x canonical + uint256 ethUsdPrice = _getLatestAnswerFromOracle(ethOracle); + uint256 exchangeRate = rethToken.getExchangeRate(); + assertGt(ethUsdPrice, 0); + assertGt(exchangeRate, 0); + uint256 priceIfDidntFail = ethUsdPrice * exchangeRate / 1e18; + + // These should differ since the mock oracle's price should not equal the previous real price + assertNotEq(priceIfDidntFail, lastGoodPrice, "price if didnt fail == lastGoodPrice"); + + // Now fetch the price + (price, oracleFailedWhileBranchLive) = rethPriceFeed.fetchPrice(); + + // This should be false, since the branch is already shutdown and not live + assertFalse(oracleFailedWhileBranchLive); + + // Confirm the returned price is the last good price + assertEq(price, lastGoodPrice, "fetched price != lastGoodPrice"); + } + + function testRETHWhenUsingETHUSDxCanonicalSwitchesToLastGoodPriceWhenExchangeRateFails() public { + // Make the RETH-USD oracle stale + etchStaleMockToRethOracle(address(mockOracle).code); + (,,, uint256 updatedAt,) = rethOracle.latestRoundData(); + assertEq(updatedAt, block.timestamp - 7 days); + + // Check using primary + assertEq(uint8(rethPriceFeed.priceSource()), uint8(IMainnetPriceFeed.PriceSource.primary), "not using primary"); + + // Fetch price + (uint256 price, bool oracleFailedWhileBranchLive) = rethPriceFeed.fetchPrice(); + + // Check that the primary calc oracle did fail + assertTrue(oracleFailedWhileBranchLive, "primary oracle calc didnt fail"); + + // Check using ETHUSDxCanonical + assertEq( + uint8(rethPriceFeed.priceSource()), + uint8(IMainnetPriceFeed.PriceSource.ETHUSDxCanonical), + "not using ethusdxcanonical" + ); + + uint256 lastGoodPrice = rethPriceFeed.lastGoodPrice(); + + // Calc expected price if didnt fail, i.e. ETH-USD x canonical + uint256 ethUsdPrice = _getLatestAnswerFromOracle(ethOracle); + uint256 exchangeRate = rethToken.getExchangeRate(); + assertGt(ethUsdPrice, 0); + assertGt(exchangeRate, 0); + + // Make the exchange rate return 0 + vm.etch(address(rethToken), address(mockRethToken).code); + uint256 rate = rethToken.getExchangeRate(); + assertEq(rate, 0, "mock rate non-zero"); + + // Now fetch the price + (price, oracleFailedWhileBranchLive) = rethPriceFeed.fetchPrice(); + + // This should be false, since the branch is already shutdown and not live + assertFalse(oracleFailedWhileBranchLive); + + // Confirm the returned price is the last good price + assertEq(price, lastGoodPrice, "fetched price != lastGoodPrice"); + // Check we've switched to lastGoodPrice source + assertEq( + uint8(rethPriceFeed.priceSource()), + uint8(IMainnetPriceFeed.PriceSource.lastGoodPrice), + "not using lastGoodPrice" + ); + } + + function testRETHWhenUsingETHUSDxCanonicalReturnsMinOfLastGoodPriceAndETHUSDxCanonical() public { + // Make the RETH-ETH oracle stale + etchStaleMockToRethOracle(address(mockOracle).code); + (,,, uint256 updatedAt,) = rethOracle.latestRoundData(); + assertEq(updatedAt, block.timestamp - 7 days); + + // Check using primary + assertEq(uint8(rethPriceFeed.priceSource()), uint8(IMainnetPriceFeed.PriceSource.primary)); + + // Fetch price + (uint256 price, bool oracleFailedWhileBranchLive) = rethPriceFeed.fetchPrice(); + + // Check that the primary calc oracle did fail + assertTrue(oracleFailedWhileBranchLive); + + // Check using ETHUSDxCanonical + assertEq(uint8(rethPriceFeed.priceSource()), uint8(IMainnetPriceFeed.PriceSource.ETHUSDxCanonical)); + + // Make lastGoodPrice tiny, and below ETHUSDxCanonical + vm.store( + address(rethPriceFeed), + bytes32(uint256(1)), // 1st storage slot where lastGoodPrice is stored + bytes32(uint256(1)) // make lastGoodPrice equal to 1 wei + ); + assertEq(rethPriceFeed.lastGoodPrice(), 1); + + + // Fetch the price again + (price, ) = rethPriceFeed.fetchPrice(); + + // Check price was lastGoodPrice + assertEq(price, rethPriceFeed.lastGoodPrice()); + + // Now make lastGoodPrice massive, and greater than ETHUSDxCanonical + vm.store( + address(rethPriceFeed), + bytes32(uint256(1)), // 1st storage slot where lastGoodPrice is stored + bytes32(uint256(1e27)) // make lastGoodPrice equal to 1e27 i.e. 1 billion (with 18 decimal digits) + ); + assertEq(rethPriceFeed.lastGoodPrice(), 1e27); + + // Fetch the price again + (price, ) = rethPriceFeed.fetchPrice(); + + // Check price is expected ETH-USDxCanonical + // Calc expected price if didnt fail, i.e. + uint256 ethUsdPrice = _getLatestAnswerFromOracle(ethOracle); + uint256 exchangeRate = rethToken.getExchangeRate(); + assertGt(ethUsdPrice, 0); + assertGt(exchangeRate, 0); + uint256 priceIfDidntFail = ethUsdPrice * exchangeRate / 1e18; + + assertEq(price, priceIfDidntFail, "price not equal expected"); } - // TODO: + function testRETHPriceFeedShutsDownWhenBothOraclesFail() public { + // Fetch price + (uint256 price, bool oracleFailedWhileBranchLive) = rethPriceFeed.fetchPrice(); + assertGt(price, 0); + + // Check oracle call didn't fail + assertFalse(oracleFailedWhileBranchLive); + + // Check branch is live, not shut down + assertEq(contractsArray[1].troveManager.shutdownTime(), 0); + + // Make the RETH-ETH oracle stale + etchStaleMockToRethOracle(address(mockOracle).code); + (,,, uint256 updatedAt,) = rethOracle.latestRoundData(); + assertEq(updatedAt, block.timestamp - 7 days); + + // Make the ETH-USD oracle stale too + etchStaleMockToEthOracle(address(mockOracle).code); + (,,, updatedAt,) = ethOracle.latestRoundData(); + assertEq(updatedAt, block.timestamp - 7 days); + + // Fetch price again + (, oracleFailedWhileBranchLive) = rethPriceFeed.fetchPrice(); + + // Check an oracle call failed this time + assertTrue(oracleFailedWhileBranchLive); + + // Confirm the branch is now shutdown + assertEq(contractsArray[1].troveManager.shutdownTime(), block.timestamp); + } + + function testRETHPriceFeedReturnsLastGoodPriceWhenBothOraclesFail() public { + // Fetch price + rethPriceFeed.fetchPrice(); + uint256 lastGoodPrice1 = rethPriceFeed.lastGoodPrice(); + assertGt(lastGoodPrice1, 0, "lastGoodPrice 0"); + + // Make the ETH-USD oracle stale + etchStaleMockToEthOracle(address(mockOracle).code); + (, int256 mockPrice,, uint256 updatedAt,) = ethOracle.latestRoundData(); + assertEq(updatedAt, block.timestamp - 7 days); + + // Make the RETH-ETH oracle stale too + etchStaleMockToRethOracle(address(mockOracle).code); + (, mockPrice,, updatedAt,) = rethOracle.latestRoundData(); + assertEq(updatedAt, block.timestamp - 7 days); + + // Fetch price again + (uint256 price, bool oracleFailedWhileBranchLive) = rethPriceFeed.fetchPrice(); + + // Check an oracle call failed this time + assertTrue(oracleFailedWhileBranchLive); + + // Confirm the PriceFeed's returned price equals the lastGoodPrice + assertEq(price, lastGoodPrice1); + + // Confirm the stored lastGoodPrice has not changed + assertEq(rethPriceFeed.lastGoodPrice(), lastGoodPrice1); + } + + function testRETHPriceSourceIsLastGoodPriceWhenBothOraclesFail() public { + // Fetch price + rethPriceFeed.fetchPrice(); + uint256 lastGoodPrice1 = rethPriceFeed.lastGoodPrice(); + assertGt(lastGoodPrice1, 0, "lastGoodPrice 0"); + + // Check using primary + assertEq(uint8(rethPriceFeed.priceSource()), uint8(IMainnetPriceFeed.PriceSource.primary)); + + // Make the ETH-USD oracle stale + etchStaleMockToEthOracle(address(mockOracle).code); + (, int256 mockPrice,, uint256 updatedAt,) = ethOracle.latestRoundData(); + assertEq(updatedAt, block.timestamp - 7 days); + + // Make the RETH-ETH oracle stale too + etchStaleMockToRethOracle(address(mockOracle).code); + (, mockPrice,, updatedAt,) = rethOracle.latestRoundData(); + assertEq(updatedAt, block.timestamp - 7 days); + + // Fetch price again + rethPriceFeed.fetchPrice(); + + // Check using lastGoodPrice + assertEq(uint8(rethPriceFeed.priceSource()), uint8(IMainnetPriceFeed.PriceSource.lastGoodPrice)); + } + + // --- WSTETH shutdown --- + + function testWSTETHPriceFeedShutsDownWhenExchangeRateFails() public { + // Fetch price + (uint256 price, bool oracleFailedWhileBranchLive) = wstethPriceFeed.fetchPrice(); + assertGt(price, 0); + + // Check oracle call didn't fail + assertFalse(oracleFailedWhileBranchLive); + + // Check branch is live, not shut down + assertEq(contractsArray[1].troveManager.shutdownTime(), 0); + + // Make the exchange rate 0 + vm.etch(address(wstETH), address(mockWstethToken).code); + uint256 rate = wstETH.stEthPerToken(); + assertEq(rate, 0); + + // Fetch price again + (, oracleFailedWhileBranchLive) = wstethPriceFeed.fetchPrice(); + + // Check a call failed this time + assertTrue(oracleFailedWhileBranchLive); + + // Confirm the branch is now shutdown + assertEq(contractsArray[2].troveManager.shutdownTime(), block.timestamp, "timestamps not equal"); + } + + function testWSTETHPriceFeedReturnsLastGoodPriceWhenExchangeRateFails() public { + // Fetch price + wstethPriceFeed.fetchPrice(); + uint256 lastGoodPrice1 = wstethPriceFeed.lastGoodPrice(); + assertGt(lastGoodPrice1, 0, "lastGoodPrice 0"); + + // Make the exchange rate 0 + vm.etch(address(wstETH), address(mockWstethToken).code); + uint256 rate = wstETH.stEthPerToken(); + assertEq(rate, 0); + + // Fetch price + (uint256 price, bool oracleFailedWhileBranchLive) = wstethPriceFeed.fetchPrice(); + + // Check a call failed this time + assertTrue(oracleFailedWhileBranchLive); + + // Confirm the PriceFeed's returned price equals the lastGoodPrice + assertEq(price, lastGoodPrice1); + + // Confirm the stored lastGoodPrice has not changed + assertEq(wstethPriceFeed.lastGoodPrice(), lastGoodPrice1); + } + + function testWSTETHPriceSourceIsLastGoodPricePriceWhenETHUSDOracleFails() public { + // Fetch price + (uint256 price1,) = wstethPriceFeed.fetchPrice(); + assertGt(price1, 0, "price is 0"); + + // Check using primary + assertEq(uint8(wstethPriceFeed.priceSource()), uint8(IMainnetPriceFeed.PriceSource.primary)); + + // Make the ETH-USD oracle stale + etchStaleMockToEthOracle(address(mockOracle).code); + (, int256 mockPrice,, uint256 updatedAt,) = ethOracle.latestRoundData(); + assertEq(updatedAt, block.timestamp - 7 days); + assertGt(mockPrice, 0, "mockPrice 0"); + + // Fetch price again + (, bool oracleFailedWhileBranchLive) = wstethPriceFeed.fetchPrice(); + + // Check ncall failed + assertTrue(oracleFailedWhileBranchLive); + + // Check using lastGoodPrice + assertEq(uint8(wstethPriceFeed.priceSource()), uint8(IMainnetPriceFeed.PriceSource.lastGoodPrice)); + } + + function testWSTETHPriceFeedReturnsLastGoodPriceWhenETHUSDOracleFails() public { + // Fetch price + (uint256 price1,) = wstethPriceFeed.fetchPrice(); + assertGt(price1, 0, "price is 0"); + + uint256 lastGoodPriceBeforeFail = wstethPriceFeed.lastGoodPrice(); + + // Make the ETH-USD oracle stale + etchStaleMockToEthOracle(address(mockOracle).code); + (, int256 mockPrice,, uint256 updatedAt,) = ethOracle.latestRoundData(); + assertEq(updatedAt, block.timestamp - 7 days); + assertGt(mockPrice, 0, "mockPrice 0"); + + // Fetch price again + (uint256 price2, bool oracleFailedWhileBranchLive) = wstethPriceFeed.fetchPrice(); + + // Check oracle failed in this call + assertTrue(oracleFailedWhileBranchLive); + + // Confirm the PriceFeed's returned price equals the stored lastGoodPrice + assertEq(price2, lastGoodPriceBeforeFail); + // Confirm the stored last good price didn't change + assertEq(lastGoodPriceBeforeFail, wstethPriceFeed.lastGoodPrice()); + } + + function testWSTETHPriceDoesShutsDownWhenETHUSDOracleFails() public { + // Fetch price + (, bool oracleFailedWhileBranchLive) = wstethPriceFeed.fetchPrice(); + + // Check no oracle failed in this call, since it uses only STETH-USD oracle in the primary calc + assertFalse(oracleFailedWhileBranchLive); + + // Check branch is live, not shut down + assertEq(contractsArray[2].troveManager.shutdownTime(), 0); + + // Make the ETH-USD oracle stale + etchStaleMockToEthOracle(address(mockOracle).code); + (,,, uint256 updatedAt,) = ethOracle.latestRoundData(); + assertEq(updatedAt, block.timestamp - 7 days); + + // Fetch price again + (, oracleFailedWhileBranchLive) = wstethPriceFeed.fetchPrice(); + + // Check that the primary calc did fail + assertTrue(oracleFailedWhileBranchLive); + + // Confirm branch is shut down + assertEq(contractsArray[2].troveManager.shutdownTime(), block.timestamp); + } + + function testWSTETHPriceShutdownWhenSTETHUSDOracleFails() public { + // Fetch price + (, bool oracleFailedWhileBranchLive) = wstethPriceFeed.fetchPrice(); + + // Check no oracle failed in this call, since it uses only STETH-USD oracle in the primary calc + assertFalse(oracleFailedWhileBranchLive); + + // Check branch is live, not shut down + assertEq(contractsArray[2].troveManager.shutdownTime(), 0); + + // Make the STETH-USD oracle stale + etchStaleMockToStethOracle(address(mockOracle).code); + (,,, uint256 updatedAt,) = stethOracle.latestRoundData(); + assertEq(updatedAt, block.timestamp - 7 days); + + // Fetch price again + (, oracleFailedWhileBranchLive) = wstethPriceFeed.fetchPrice(); + + // Check that this time the primary calc oracle did fail + assertTrue(oracleFailedWhileBranchLive); + + // Confirm branch is now shut down + assertEq(contractsArray[2].troveManager.shutdownTime(), block.timestamp); + } + + function testFetchPriceReturnsMinETHUSDxCanonicalAndLastGoodPriceWhenSTETHUSDOracleFails() public { + // Make the STETH-USD oracle stale + etchStaleMockToStethOracle(address(mockOracle).code); + (,,, uint256 updatedAt,) = stethOracle.latestRoundData(); + assertEq(updatedAt, block.timestamp - 7 days); + + // Fetch price + (uint256 price, bool oracleFailedWhileBranchLive) = wstethPriceFeed.fetchPrice(); + + // Check that the primary calc oracle did fail + assertTrue(oracleFailedWhileBranchLive); + + + // Calc expected price i.e. ETH-USD x canonical + uint256 ethUsdPrice = _getLatestAnswerFromOracle(ethOracle); + uint256 exchangeRate = wstETH.stEthPerToken(); + assertGt(ethUsdPrice, 0); + assertGt(exchangeRate, 0); + + uint256 expectedPrice = LiquityMath._min(wstethPriceFeed.lastGoodPrice(), ethUsdPrice * exchangeRate / 1e18); + + assertEq(price, expectedPrice, "price not expected price"); + } + + function testSTETHPriceSourceIsETHUSDxCanonicalWhenSTETHUSDOracleFails() public { + // Check using primary + assertEq(uint8(wstethPriceFeed.priceSource()), uint8(IMainnetPriceFeed.PriceSource.primary)); + + // Make the STETH-USD oracle stale + etchStaleMockToStethOracle(address(mockOracle).code); + (,,, uint256 updatedAt,) = stethOracle.latestRoundData(); + assertEq(updatedAt, block.timestamp - 7 days); + + // Fetch price + (, bool oracleFailedWhileBranchLive) = wstethPriceFeed.fetchPrice(); + + // Check that the primary calc oracle did fail + assertTrue(oracleFailedWhileBranchLive); + + // Check using ETHUSDxCanonical + assertEq(uint8(wstethPriceFeed.priceSource()), uint8(IMainnetPriceFeed.PriceSource.ETHUSDxCanonical)); + } + + function testSTETHWhenUsingETHUSDxCanonicalSwitchesToLastGoodPriceWhenETHUSDOracleFails() public { + // Make the STETH-USD oracle stale + etchStaleMockToStethOracle(address(mockOracle).code); + (,,, uint256 updatedAt,) = stethOracle.latestRoundData(); + assertEq(updatedAt, block.timestamp - 7 days); + + // Check using primary + assertEq(uint8(wstethPriceFeed.priceSource()), uint8(IMainnetPriceFeed.PriceSource.primary)); + + // Fetch price + (uint256 price, bool oracleFailedWhileBranchLive) = wstethPriceFeed.fetchPrice(); + + // Check that the primary calc oracle did fail + assertTrue(oracleFailedWhileBranchLive); + + // Check using ETHUSDxCanonical + assertEq(uint8(wstethPriceFeed.priceSource()), uint8(IMainnetPriceFeed.PriceSource.ETHUSDxCanonical)); + + uint256 lastGoodPrice = wstethPriceFeed.lastGoodPrice(); + + // Make the ETH-USD oracle stale too + etchStaleMockToEthOracle(address(mockOracle).code); + (,,, updatedAt,) = ethOracle.latestRoundData(); + assertEq(updatedAt, block.timestamp - 7 days); + + // Calc expected price if didnt fail, i.e. ETH-USD x canonical + uint256 ethUsdPrice = _getLatestAnswerFromOracle(ethOracle); + uint256 exchangeRate = wstETH.stEthPerToken(); + assertGt(ethUsdPrice, 0); + assertGt(exchangeRate, 0); + uint256 priceIfDidntFail = ethUsdPrice * exchangeRate / 1e18; + + // These should differ since the mock oracle's price should not equal the previous real price + assertNotEq(priceIfDidntFail, lastGoodPrice, "price if didnt fail == lastGoodPrice"); + + // Now fetch the price + (price, oracleFailedWhileBranchLive) = wstethPriceFeed.fetchPrice(); + + // Check using lastGoodPrice + assertEq(uint8(wstethPriceFeed.priceSource()), uint8(IMainnetPriceFeed.PriceSource.lastGoodPrice)); + + // This should be false, since the branch is already shutdown and not live + assertFalse(oracleFailedWhileBranchLive); + + // Confirm the returned price is the last good price + assertEq(price, lastGoodPrice, "fetched price != lastGoodPrice"); + } + + function testSTETHWhenUsingETHUSDxCanonicalSwitchesToLastGoodPriceWhenExchangeRateFails() public { + // Make the STETH-USD oracle stale + etchStaleMockToStethOracle(address(mockOracle).code); + (,,, uint256 updatedAt,) = stethOracle.latestRoundData(); + assertEq(updatedAt, block.timestamp - 7 days); + + // Check using primary + assertEq(uint8(wstethPriceFeed.priceSource()), uint8(IMainnetPriceFeed.PriceSource.primary), "not using primary"); + + // Fetch price + (uint256 price, bool oracleFailedWhileBranchLive) = wstethPriceFeed.fetchPrice(); + + // Check that the primary calc oracle did fail + assertTrue(oracleFailedWhileBranchLive, "primary oracle calc didnt fail"); + + // Check using ETHUSDxCanonical + assertEq( + uint8(wstethPriceFeed.priceSource()), + uint8(IMainnetPriceFeed.PriceSource.ETHUSDxCanonical), + "not using ethusdxcanonical" + ); + + uint256 lastGoodPrice = wstethPriceFeed.lastGoodPrice(); + + // Make the exchange rate return 0 + vm.etch(address(wstETH), address(mockWstethToken).code); + uint256 rate = wstETH.stEthPerToken(); + assertEq(rate, 0, "mock rate non-zero"); + + // Now fetch the price + (price, oracleFailedWhileBranchLive) = wstethPriceFeed.fetchPrice(); + + // This should be false, since the branch is already shutdown and not live + assertFalse(oracleFailedWhileBranchLive); + + // Confirm the returned price is the last good price + assertEq(price, lastGoodPrice, "fetched price != lastGoodPrice"); + // Check we've switched to lastGoodPrice source + assertEq( + uint8(wstethPriceFeed.priceSource()), + uint8(IMainnetPriceFeed.PriceSource.lastGoodPrice), + "not using lastGoodPrice" + ); + } + + function testSTETHWhenUsingETHUSDxCanonicalRemainsShutDownWhenETHUSDOracleFails() public { + // Make the STETH-USD oracle stale + etchStaleMockToStethOracle(address(mockOracle).code); + (,,, uint256 updatedAt,) = stethOracle.latestRoundData(); + assertEq(updatedAt, block.timestamp - 7 days); + + // Check using primary + assertEq(uint8(wstethPriceFeed.priceSource()), uint8(IMainnetPriceFeed.PriceSource.primary)); + + // Check branch is live, not shut down + assertEq(contractsArray[2].troveManager.shutdownTime(), 0); + + // Fetch price + (uint256 price, bool oracleFailedWhileBranchLive) = wstethPriceFeed.fetchPrice(); + + // Check that the primary calc oracle did fail + assertTrue(oracleFailedWhileBranchLive); + + // Check using ETHUSDxCanonical + assertEq(uint8(wstethPriceFeed.priceSource()), uint8(IMainnetPriceFeed.PriceSource.ETHUSDxCanonical)); + + // Check branch is now shut down + assertEq(contractsArray[2].troveManager.shutdownTime(), block.timestamp); + + // Make the ETH-USD oracle stale too + etchStaleMockToEthOracle(address(mockOracle).code); + (,,, updatedAt,) = ethOracle.latestRoundData(); + assertEq(updatedAt, block.timestamp - 7 days); + + // Now fetch the price again + (price, oracleFailedWhileBranchLive) = wstethPriceFeed.fetchPrice(); + + // Check using lastGoodPrice + assertEq(uint8(wstethPriceFeed.priceSource()), uint8(IMainnetPriceFeed.PriceSource.lastGoodPrice)); + + // Check branch is still down + assertEq(contractsArray[2].troveManager.shutdownTime(), block.timestamp); + } + + function testSTETHWhenUsingETHUSDxCanonicalReturnsMinOfLastGoodPriceAndETHUSDxCanonical() public { + // Make the STETH-USD oracle stale + etchStaleMockToStethOracle(address(mockOracle).code); + (,,, uint256 updatedAt,) = stethOracle.latestRoundData(); + assertEq(updatedAt, block.timestamp - 7 days); + + // Check using primary + assertEq(uint8(wstethPriceFeed.priceSource()), uint8(IMainnetPriceFeed.PriceSource.primary)); + + // Fetch price + (uint256 price, bool oracleFailedWhileBranchLive) = wstethPriceFeed.fetchPrice(); + + // Check that the primary calc oracle did fail + assertTrue(oracleFailedWhileBranchLive); + + // Check using ETHUSDxCanonical + assertEq(uint8(wstethPriceFeed.priceSource()), uint8(IMainnetPriceFeed.PriceSource.ETHUSDxCanonical)); + + // Make lastGoodPrice tiny, and below ETHUSDxCanonical + vm.store( + address(wstethPriceFeed), + bytes32(uint256(1)), // 1st storage slot where lastGoodPrice is stored + bytes32(uint256(1)) // make lastGoodPrice equal to 1 wei + ); + assertEq(wstethPriceFeed.lastGoodPrice(), 1); + + + // Fetch the price again + (price, ) = wstethPriceFeed.fetchPrice(); + + // Check price was lastGoodPrice + assertEq(price, wstethPriceFeed.lastGoodPrice()); + + // Now make lastGoodPrice massive, and greater than ETHUSDxCanonical + vm.store( + address(wstethPriceFeed), + bytes32(uint256(1)), // 1st storage slot where lastGoodPrice is stored + bytes32(uint256(1e27)) // make lastGoodPrice equal to 1e27 i.e. 1 billion (with 18 decimal digits) + ); + assertEq(wstethPriceFeed.lastGoodPrice(), 1e27); + + // Fetch the price again + (price, ) = wstethPriceFeed.fetchPrice(); + + // Check price is expected ETH-USDxCanonical + // Calc expected price if didnt fail, i.e. + uint256 ethUsdPrice = _getLatestAnswerFromOracle(ethOracle); + uint256 exchangeRate = wstETH.stEthPerToken(); + assertGt(ethUsdPrice, 0); + assertGt(exchangeRate, 0); + uint256 priceIfDidntFail = ethUsdPrice * exchangeRate / 1e18; + + assertEq(price, priceIfDidntFail); + } + + function testWSTETHPriceShutdownWhenBothOraclesFail() public { + // Fetch price + (, bool oracleFailedWhileBranchLive) = wstethPriceFeed.fetchPrice(); + + // Check no oracle failed in this call, since it uses only STETH-USD oracle in the primary calc + assertFalse(oracleFailedWhileBranchLive); + + // Check branch is live, not shut down + assertEq(contractsArray[2].troveManager.shutdownTime(), 0); + + // Make the STETH-USD oracle stale + etchStaleMockToStethOracle(address(mockOracle).code); + (, int256 mockPrice,, uint256 updatedAt,) = stethOracle.latestRoundData(); + assertEq(updatedAt, block.timestamp - 7 days); + + // Make the ETH-USD oracle stale too + etchStaleMockToEthOracle(address(mockOracle).code); + (, mockPrice,, updatedAt,) = ethOracle.latestRoundData(); + assertEq(updatedAt, block.timestamp - 7 days); + + // Fetch price again + (, oracleFailedWhileBranchLive) = wstethPriceFeed.fetchPrice(); + + // Check that this time the primary calc oracle did fail + assertTrue(oracleFailedWhileBranchLive); + + // Confirm branch is now shut down + assertEq(contractsArray[2].troveManager.shutdownTime(), block.timestamp); + } + + function testWSTETHPriceFeedReturnsLastGoodPriceWhenBothOraclesFail() public { + // Fetch price + wstethPriceFeed.fetchPrice(); + uint256 lastGoodPrice1 = wstethPriceFeed.lastGoodPrice(); + assertGt(lastGoodPrice1, 0, "lastGoodPrice 0"); + + // Make the STETH-USD oracle stale + etchStaleMockToStethOracle(address(mockOracle).code); + (, int256 mockPrice,, uint256 updatedAt,) = stethOracle.latestRoundData(); + assertEq(updatedAt, block.timestamp - 7 days); + + // Make the ETH-USD oracle stale too + etchStaleMockToEthOracle(address(mockOracle).code); + (, mockPrice,, updatedAt,) = ethOracle.latestRoundData(); + assertEq(updatedAt, block.timestamp - 7 days); + + // Fetch price again + (uint256 price, bool oracleFailedWhileBranchLive) = wstethPriceFeed.fetchPrice(); + + // Check an oracle call failed this time + assertTrue(oracleFailedWhileBranchLive); + + // Confirm the PriceFeed's returned price equals the lastGoodPrice + assertEq(price, lastGoodPrice1); + + // Confirm the stored lastGoodPrice has not changed + assertEq(wstethPriceFeed.lastGoodPrice(), lastGoodPrice1); + } + + function testWSTETHPriceSourceIsLastGoodPriceWhenBothOraclesFail() public { + // Fetch price + wstethPriceFeed.fetchPrice(); + uint256 lastGoodPrice1 = wstethPriceFeed.lastGoodPrice(); + assertGt(lastGoodPrice1, 0, "lastGoodPrice 0"); + + // Check using primary + assertEq(uint8(wstethPriceFeed.priceSource()), uint8(IMainnetPriceFeed.PriceSource.primary)); + + // Make the STETH-USD oracle stale + etchStaleMockToStethOracle(address(mockOracle).code); + (, int256 mockPrice,, uint256 updatedAt,) = stethOracle.latestRoundData(); + assertEq(updatedAt, block.timestamp - 7 days); + + // Make the ETH-USD oracle stale too + etchStaleMockToEthOracle(address(mockOracle).code); + (, mockPrice,, updatedAt,) = ethOracle.latestRoundData(); + assertEq(updatedAt, block.timestamp - 7 days); + + // Fetch price again + wstethPriceFeed.fetchPrice(); + + // Check using lastGoodPrice + assertEq(uint8(wstethPriceFeed.priceSource()), uint8(IMainnetPriceFeed.PriceSource.lastGoodPrice)); + } + + // --- redemptions --- + + function testNormalWETHRedemptionDoesNotHitShutdownBranch() public { + // Fetch price + wethPriceFeed.fetchPrice(); + uint256 lastGoodPrice1 = wethPriceFeed.lastGoodPrice(); + assertGt(lastGoodPrice1, 0, "lastGoodPrice 0"); + + // Check using primary + assertEq(uint8(wethPriceFeed.priceSource()), uint8(IMainnetPriceFeed.PriceSource.primary)); + + uint256 coll = 100 ether; + uint256 debtRequest = 3000e18; + + vm.startPrank(A); + contractsArray[0].borrowerOperations.openTrove( + A, 0, coll, debtRequest, 0, 0, 5e16, debtRequest, address(0), address(0), address(0) + ); + + // Make the ETH-USD oracle stale + etchStaleMockToEthOracle(address(mockOracle).code); + (,,,uint256 updatedAt,) = ethOracle.latestRoundData(); + assertEq(updatedAt, block.timestamp - 7 days); + + // Fetch price again + (, bool oracleFailedWhileBranchLive) = wethPriceFeed.fetchPrice(); + assertTrue(oracleFailedWhileBranchLive); + // Confirm branch shutdown + assertEq(contractsArray[0].troveManager.shutdownTime(), block.timestamp); + + uint256 totalBoldRedeemAmount = 100e18; + uint256 branch0DebtBefore = contractsArray[0].activePool.getBoldDebt(); + assertGt(branch0DebtBefore, 0); + + uint256 boldBalBefore_A = boldToken.balanceOf(A); + + // Redeem + redeem(A, totalBoldRedeemAmount); + + // Confirm A lost no BOLD + assertEq( boldToken.balanceOf(A), boldBalBefore_A); + + // Confirm WETH branch did not get redeemed from + assertEq(contractsArray[0].activePool.getBoldDebt(), branch0DebtBefore); + } + + function testNormalRETHRedemptionDoesNotHitShutdownBranch() public { + // Fetch price + rethPriceFeed.fetchPrice(); + uint256 lastGoodPrice1 = rethPriceFeed.lastGoodPrice(); + assertGt(lastGoodPrice1, 0, "lastGoodPrice 0"); + + // Check using primary + assertEq(uint8(rethPriceFeed.priceSource()), uint8(IMainnetPriceFeed.PriceSource.primary)); + + uint256 coll = 100 ether; + uint256 debtRequest = 3000e18; + + vm.startPrank(A); + contractsArray[1].borrowerOperations.openTrove( + A, 0, coll, debtRequest, 0, 0, 5e16, debtRequest, address(0), address(0), address(0) + ); + + // Make the RETH-ETH oracle stale + etchStaleMockToRethOracle(address(mockOracle).code); + (,,,uint256 updatedAt,) = rethOracle.latestRoundData(); + assertEq(updatedAt, block.timestamp - 7 days); + + // Fetch price again + (, bool oracleFailedWhileBranchLive) = rethPriceFeed.fetchPrice(); + assertTrue(oracleFailedWhileBranchLive); + // Confirm RETH branch shutdown + assertEq(contractsArray[1].troveManager.shutdownTime(), block.timestamp); + + uint256 totalBoldRedeemAmount = 100e18; + uint256 branch1DebtBefore = contractsArray[1].activePool.getBoldDebt(); + assertGt(branch1DebtBefore, 0); + + uint256 boldBalBefore_A = boldToken.balanceOf(A); + + // Redeem + redeem(A, totalBoldRedeemAmount); + + // Confirm A lost no BOLD + assertEq( boldToken.balanceOf(A), boldBalBefore_A); + + // Confirm RETH branch did not get redeemed from + assertEq(contractsArray[1].activePool.getBoldDebt(), branch1DebtBefore); + } + + function testNormalWSTETHRedemptionDoesNotHitShutdownBranch() public { + // Fetch price + wstethPriceFeed.fetchPrice(); + uint256 lastGoodPrice1 = wstethPriceFeed.lastGoodPrice(); + assertGt(lastGoodPrice1, 0, "lastGoodPrice 0"); + + // Check using primary + assertEq(uint8(wstethPriceFeed.priceSource()), uint8(IMainnetPriceFeed.PriceSource.primary)); + + uint256 coll = 100 ether; + uint256 debtRequest = 3000e18; + + vm.startPrank(A); + contractsArray[2].borrowerOperations.openTrove( + A, 0, coll, debtRequest, 0, 0, 5e16, debtRequest, address(0), address(0), address(0) + ); + + // Make the STETH-USD oracle stale + etchStaleMockToStethOracle(address(mockOracle).code); + (,,,uint256 updatedAt,) = stethOracle.latestRoundData(); + assertEq(updatedAt, block.timestamp - 7 days); + + // Fetch price again + (, bool oracleFailedWhileBranchLive) = wstethPriceFeed.fetchPrice(); + assertTrue(oracleFailedWhileBranchLive); + // Confirm RETH branch shutdown + assertEq(contractsArray[2].troveManager.shutdownTime(), block.timestamp); + + uint256 totalBoldRedeemAmount = 100e18; + uint256 branch2DebtBefore = contractsArray[2].activePool.getBoldDebt(); + assertGt(branch2DebtBefore, 0); + + uint256 boldBalBefore_A = boldToken.balanceOf(A); + + // Redeem + redeem(A, totalBoldRedeemAmount); + + // Confirm A lost no BOLD + assertEq( boldToken.balanceOf(A), boldBalBefore_A); + + // Confirm RETH branch did not get redeemed from + assertEq(contractsArray[2].activePool.getBoldDebt(), branch2DebtBefore); + } + + function testRedemptionOfWETHUsesETHUSDMarketforPrimaryPrice() public { + // Fetch price + wethPriceFeed.fetchPrice(); + uint256 lastGoodPrice1 = wethPriceFeed.lastGoodPrice(); + assertGt(lastGoodPrice1, 0, "lastGoodPrice 0"); + + // Check using primary + assertEq(uint8(wethPriceFeed.priceSource()), uint8(IMainnetPriceFeed.PriceSource.primary)); + + uint256 coll = 100 ether; + uint256 debtRequest = 3000e18; + + vm.startPrank(A); + contractsArray[0].borrowerOperations.openTrove( + A, 0, coll, debtRequest, 0, 0, 5e16, debtRequest, address(0), address(0), address(0) + ); + + // Expected price used for primary calc: ETH-USD market price + uint256 expectedPrice = _getLatestAnswerFromOracle(ethOracle); + assertGt(expectedPrice, 0); + + // Calc expected fee based on price + uint256 totalBoldRedeemAmount = 100e18; + uint256 totalCorrespondingColl = totalBoldRedeemAmount * DECIMAL_PRECISION / expectedPrice; + assertGt(totalCorrespondingColl, 0); + + uint256 redemptionFeePct = collateralRegistry.getEffectiveRedemptionFeeInBold(totalBoldRedeemAmount) + * DECIMAL_PRECISION / totalBoldRedeemAmount; + assertGt(redemptionFeePct, 0); + + uint256 totalCollFee = totalCorrespondingColl * redemptionFeePct / DECIMAL_PRECISION; + + uint256 expectedCollDelta = totalCorrespondingColl - totalCollFee; + assertGt(expectedCollDelta, 0); + + uint256 branch0DebtBefore = contractsArray[0].activePool.getBoldDebt(); + assertGt(branch0DebtBefore, 0); + uint256 A_collBefore = contractsArray[0].collToken.balanceOf(A); + assertGt(A_collBefore, 0); + // Redeem + redeem(A, totalBoldRedeemAmount); + + // Confirm WETH branch got redeemed from + assertEq(contractsArray[0].activePool.getBoldDebt(), branch0DebtBefore - totalBoldRedeemAmount); + + // Confirm the received amount coll is the expected amount (i.e. used the expected price) + assertEq(contractsArray[0].collToken.balanceOf(A), A_collBefore + expectedCollDelta); + } + + function testRedemptionOfWSTETHUsesMaxETHUSDMarketandWSTETHUSDMarketForPrimaryPriceWhenWithin1pct() public { + // Fetch price + wstethPriceFeed.fetchPrice(); + uint256 lastGoodPrice1 = wstethPriceFeed.lastGoodPrice(); + assertGt(lastGoodPrice1, 0, "lastGoodPrice 0"); + + // Check using primary + assertEq(uint8(wstethPriceFeed.priceSource()), uint8(IMainnetPriceFeed.PriceSource.primary)); + + uint256 coll = 100 ether; + uint256 debtRequest = 3000e18; + + vm.startPrank(A); + contractsArray[2].borrowerOperations.openTrove( + A, 0, coll, debtRequest, 0, 0, 5e16, debtRequest, address(0), address(0), address(0) + ); + + // Expected price used for primary calc: ETH-USD market price + uint256 ethUsdPrice = _getLatestAnswerFromOracle(ethOracle); + uint256 stethUsdPrice = _getLatestAnswerFromOracle(stethOracle); + assertNotEq(ethUsdPrice, stethUsdPrice, "raw prices equal"); + // Check STETH-USD is within 1ct of ETH-USD + uint256 max = (1e18 + 1e16) * ethUsdPrice / 1e18; + uint256 min = (1e18 - 1e16) * ethUsdPrice / 1e18; + assertGe(stethUsdPrice, min); + assertLe(stethUsdPrice, max); + + // USD_per_WSTETH = USD_per_STETH(or_per_ETH) * STETH_per_WSTETH + uint256 expectedPrice = LiquityMath._max(ethUsdPrice, stethUsdPrice) * wstETH.stEthPerToken() / 1e18; + assertGt(expectedPrice, 0, "expected price not 0"); + + // Calc expected fee based on price + uint256 totalBoldRedeemAmount = 100e18; + uint256 totalCorrespondingColl = totalBoldRedeemAmount * DECIMAL_PRECISION / expectedPrice; + assertGt(totalCorrespondingColl, 0, "coll not 0"); + + uint256 redemptionFeePct = collateralRegistry.getEffectiveRedemptionFeeInBold(totalBoldRedeemAmount) + * DECIMAL_PRECISION / totalBoldRedeemAmount; + assertGt(redemptionFeePct, 0, "fee not 0"); + + uint256 totalCollFee = totalCorrespondingColl * redemptionFeePct / DECIMAL_PRECISION; + + uint256 expectedCollDelta = totalCorrespondingColl - totalCollFee; + assertGt(expectedCollDelta, 0, "delta not 0"); + + uint256 branch2DebtBefore = contractsArray[2].activePool.getBoldDebt(); + assertGt(branch2DebtBefore, 0); + uint256 A_collBefore = contractsArray[2].collToken.balanceOf(A); + assertGt(A_collBefore, 0); + + // Redeem + redeem(A, totalBoldRedeemAmount); + + // Confirm WSTETH branch got redeemed from + assertEq(contractsArray[2].activePool.getBoldDebt(), branch2DebtBefore - totalBoldRedeemAmount); + + // Confirm the received amount coll is the expected amount (i.e. used the expected price) + assertEq(contractsArray[2].collToken.balanceOf(A), A_collBefore + expectedCollDelta); + } + + function testRedemptionOfWSTETHUsesMinETHUSDMarketandWSTETHUSDMarketForPrimaryPriceWhenNotWithin1pct() public { + // Fetch price + console.log("test::first wsteth pricefeed call"); + wstethPriceFeed.fetchPrice(); + uint256 lastGoodPrice1 = wstethPriceFeed.lastGoodPrice(); + assertGt(lastGoodPrice1, 0, "lastGoodPrice 0"); + + // Check using primary + assertEq(uint8(wstethPriceFeed.priceSource()), uint8(IMainnetPriceFeed.PriceSource.primary)); + + uint256 coll = 100 ether; + uint256 debtRequest = 3000e18; + + vm.startPrank(A); + contractsArray[2].borrowerOperations.openTrove( + A, 0, coll, debtRequest, 0, 0, 5e16, debtRequest, address(0), address(0), address(0) + ); + + // Get the raw ETH-USD price (at 8 decimals) for comparison + (,int256 rawEthUsdPrice,,,) = ethOracle.latestRoundData(); + assertGt(rawEthUsdPrice, 0, "eth-usd price not 0"); + + // Replace the STETH-USD Oracle's code with the mock oracle's code + etchStaleMockToStethOracle(address(mockOracle).code); + ChainlinkOracleMock mock = ChainlinkOracleMock(address(stethOracle)); + // Reduce STETH-USD price to 90% of ETH-USD price. Use 8 decimal precision on the oracle. + mock.setPrice(int256(rawEthUsdPrice * 90e6 / 1e8)); + // Make it fresh + mock.setUpdatedAt(block.timestamp); + // STETH-USD price has 8 decimals + mock.setDecimals(8); + + assertEq(contractsArray[2].troveManager.shutdownTime(), 0, "is shutdown"); + + uint256 ethUsdPrice = _getLatestAnswerFromOracle(ethOracle); + uint256 stethUsdPrice = _getLatestAnswerFromOracle(stethOracle); + console.log(stethUsdPrice, "test stehUsdPrice after replacement"); + console.log(ethUsdPrice, "test ethUsdPrice after replacement"); + console.log(ethUsdPrice * 90e16 / 1e18, "test ethUsdPrice * 90e16 / 1e18"); + + // Confirm that STETH-USD is lower than ETH-USD + assertLt(stethUsdPrice, ethUsdPrice, "steth-usd not < eth-usd"); + + // USD_per_STETH = USD_per_STETH * STETH_per_WSTETH + // Use STETH-USD as expected price since it is out of range of ETH-USD + uint256 expectedPrice = stethUsdPrice * wstETH.stEthPerToken() / 1e18; + assertGt(expectedPrice, 0, "expected price not 0"); + + // Calc expected fee based on price + uint256 totalBoldRedeemAmount = 100e18; + uint256 totalCorrespondingColl = totalBoldRedeemAmount * DECIMAL_PRECISION / expectedPrice; + assertGt(totalCorrespondingColl, 0, "coll not 0"); + + uint256 redemptionFeePct = collateralRegistry.getEffectiveRedemptionFeeInBold(totalBoldRedeemAmount) + * DECIMAL_PRECISION / totalBoldRedeemAmount; + assertGt(redemptionFeePct, 0, "fee not 0"); + + uint256 totalCollFee = totalCorrespondingColl * redemptionFeePct / DECIMAL_PRECISION; + + uint256 expectedCollDelta = totalCorrespondingColl - totalCollFee; + assertGt(expectedCollDelta, 0, "delta not 0"); + + uint256 branch2DebtBefore = contractsArray[2].activePool.getBoldDebt(); + assertGt(branch2DebtBefore, 0); + uint256 A_collBefore = contractsArray[2].collToken.balanceOf(A); + assertGt(A_collBefore, 0); + + // Redeem + redeem(A, totalBoldRedeemAmount); + + assertEq(contractsArray[2].troveManager.shutdownTime(), 0, "is shutdown"); + + // Confirm WSTETH branch got redeemed from + assertEq(contractsArray[2].activePool.getBoldDebt(), branch2DebtBefore - totalBoldRedeemAmount, "remaining branch debt wrong"); + + // Confirm the received amount coll is the expected amount (i.e. used the expected price) + assertEq(contractsArray[2].collToken.balanceOf(A), A_collBefore + expectedCollDelta, "remaining branch coll wrong"); + } + + function testRedemptionOfRETHUsesMaxCanonicalAndMarketforPrimaryPriceWhenWithin2pct() public { + // Fetch price + rethPriceFeed.fetchPrice(); + uint256 lastGoodPrice1 = rethPriceFeed.lastGoodPrice(); + assertGt(lastGoodPrice1, 0, "lastGoodPrice 0"); + + // Check using primary + assertEq(uint8(rethPriceFeed.priceSource()), uint8(IMainnetPriceFeed.PriceSource.primary)); + + uint256 coll = 100 ether; + uint256 debtRequest = 3000e18; + + vm.startPrank(A); + contractsArray[1].borrowerOperations.openTrove( + A, 0, coll, debtRequest, 0, 0, 5e16, debtRequest, address(0), address(0), address(0) + ); + + // Expected price used for primary calc: ETH-USD market price + uint256 canonicalRethRate = rethToken.getExchangeRate(); + uint256 marketRethPrice = _getLatestAnswerFromOracle(rethOracle); + uint256 ethUsdPrice = _getLatestAnswerFromOracle(ethOracle); + assertNotEq(canonicalRethRate, marketRethPrice, "raw price and rate equal"); + + // Check market is within 2pct of max; + uint256 max = (1e18 + 2e16) * canonicalRethRate / 1e18; + uint256 min = (1e18 - 2e16) * canonicalRethRate / 1e18; + assertGe(marketRethPrice, min); + assertLe(marketRethPrice, max); + + // USD_per_WSTETH = USD_per_STETH(or_per_ETH) * STETH_per_WSTETH + uint256 expectedPrice = LiquityMath._max(canonicalRethRate, marketRethPrice) * ethUsdPrice / 1e18; + assertGt(expectedPrice, 0, "expected price not 0"); + + // Calc expected fee based on price + uint256 totalBoldRedeemAmount = 100e18; + uint256 totalCorrespondingColl = totalBoldRedeemAmount * DECIMAL_PRECISION / expectedPrice; + assertGt(totalCorrespondingColl, 0, "coll not 0"); + + uint256 redemptionFeePct = collateralRegistry.getEffectiveRedemptionFeeInBold(totalBoldRedeemAmount) + * DECIMAL_PRECISION / totalBoldRedeemAmount; + assertGt(redemptionFeePct, 0, "fee not 0"); + + uint256 totalCollFee = totalCorrespondingColl * redemptionFeePct / DECIMAL_PRECISION; + + uint256 expectedCollDelta = totalCorrespondingColl - totalCollFee; + assertGt(expectedCollDelta, 0, "delta not 0"); + + uint256 branch1DebtBefore = contractsArray[1].activePool.getBoldDebt(); + assertGt(branch1DebtBefore, 0); + uint256 A_collBefore = contractsArray[1].collToken.balanceOf(A); + assertGt(A_collBefore, 0); + + // Redeem + redeem(A, totalBoldRedeemAmount); + + // Confirm RETH branch got redeemed from + assertEq(contractsArray[1].activePool.getBoldDebt(), branch1DebtBefore - totalBoldRedeemAmount); + + // Confirm the received amount coll is the expected amount (i.e. used the expected price) + assertEq(contractsArray[1].collToken.balanceOf(A), A_collBefore + expectedCollDelta); + } + + function testRedemptionOfRETHUsesMinCanonicalAndMarketforPrimaryPriceWhenDeviationGreaterThan2pct() public { + // Fetch price + rethPriceFeed.fetchPrice(); + uint256 lastGoodPrice1 = rethPriceFeed.lastGoodPrice(); + assertGt(lastGoodPrice1, 0, "lastGoodPrice 0"); + + // Check using primary + assertEq(uint8(rethPriceFeed.priceSource()), uint8(IMainnetPriceFeed.PriceSource.primary)); + + uint256 coll = 100 ether; + uint256 debtRequest = 3000e18; + + vm.startPrank(A); + contractsArray[1].borrowerOperations.openTrove( + A, 0, coll, debtRequest, 0, 0, 5e16, debtRequest, address(0), address(0), address(0) + ); + vm.stopPrank(); + + // Replace the RETH Oracle's code with the mock oracle's code + etchStaleMockToRethOracle(address(mockOracle).code); + ChainlinkOracleMock mock = ChainlinkOracleMock(address(rethOracle)); + // Set ETH_per_RETH market price to 0.95 + mock.setPrice(95e16); + // Make it fresh + mock.setUpdatedAt(block.timestamp); + // RETH-ETH price has 18 decimals + mock.setDecimals(18); + + (,int256 price,,,) = rethOracle.latestRoundData(); + // Confirm that RETH oracle now returns the artificial low price + assertEq(price, 95e16, "reth-eth price not 0.95"); + + // // Expected price used for primary calc: ETH-USD market price + uint256 canonicalRethRate = rethToken.getExchangeRate(); + uint256 marketRethPrice = _getLatestAnswerFromOracle(rethOracle); + uint256 ethUsdPrice = _getLatestAnswerFromOracle(ethOracle); + assertNotEq(canonicalRethRate, marketRethPrice, "raw price and rate equal"); + + // Check market is not within 2pct of canonical + uint256 min = (1e18 - 2e16) * canonicalRethRate / 1e18; + assertLe(marketRethPrice, min, "market reth-eth price not < min"); + + // USD_per_WSTETH = USD_per_STETH(or_per_ETH) * STETH_per_WSTETH + uint256 expectedPrice = LiquityMath._min(canonicalRethRate, marketRethPrice) * ethUsdPrice / 1e18; + assertGt(expectedPrice, 0, "expected price not 0"); + + // Calc expected fee based on price, i.e. the minimum + uint256 totalBoldRedeemAmount = 100e18; + uint256 totalCorrespondingColl = totalBoldRedeemAmount * DECIMAL_PRECISION / expectedPrice; + assertGt(totalCorrespondingColl, 0, "coll not 0"); + + uint256 redemptionFeePct = collateralRegistry.getEffectiveRedemptionFeeInBold(totalBoldRedeemAmount) + * DECIMAL_PRECISION / totalBoldRedeemAmount; + assertGt(redemptionFeePct, 0, "fee not 0"); + + uint256 totalCollFee = totalCorrespondingColl * redemptionFeePct / DECIMAL_PRECISION; + + uint256 expectedCollDelta = totalCorrespondingColl - totalCollFee; + assertGt(expectedCollDelta, 0, "delta not 0"); + + uint256 branch1DebtBefore = contractsArray[1].activePool.getBoldDebt(); + assertGt(branch1DebtBefore, 0); + uint256 A_collBefore = contractsArray[1].collToken.balanceOf(A); + assertGt(A_collBefore, 0); + + // Redeem + redeem(A, totalBoldRedeemAmount); + + // Confirm RETH branch got redeemed from + assertEq(contractsArray[1].activePool.getBoldDebt(), branch1DebtBefore - totalBoldRedeemAmount, "active debt != branch - redeemed"); + + // Confirm the received amount coll is the expected amount (i.e. used the expected price) + assertEq(contractsArray[1].collToken.balanceOf(A), A_collBefore + expectedCollDelta, "A's coll didn't change" ); + } + + // --- Low gas reverts --- + +// --- Call these functions with 10k gas - i.e. enough to run out of gas in the Chainlink calls --- +function testRevertLowGasWSTETH() public { + vm.expectRevert(MainnetPriceFeedBase.InsufficientGasForExternalCall.selector); + // just catch return val to suppress warning + (bool success, ) = address(wstethPriceFeed).call{gas: 10000}(abi.encodeWithSignature("fetchPrice()")); + assertFalse(success); +} + +function testRevertLowGasRETH() public { + vm.expectRevert(MainnetPriceFeedBase.InsufficientGasForExternalCall.selector); + // just catch return val to suppress warning + (bool success, ) = address(rethPriceFeed).call{gas: 10000}(abi.encodeWithSignature("fetchPrice()")); + assertFalse(success); +} + +function testRevertLowGasWETH() public { + vm.expectRevert(MainnetPriceFeedBase.InsufficientGasForExternalCall.selector); + // just catch return val to suppress warning + (bool success, ) = address(wethPriceFeed).call{gas: 10000}(abi.encodeWithSignature("fetchPrice()")); + assertFalse(success); +} + // - More basic actions tests (adjust, close, etc) // - liq tests (manipulate aggregator stored price) - // - conditional shutdown logic tests (manipulate aggregator stored price) } diff --git a/contracts/src/test/TestContracts/ChainlinkOracleMock.sol b/contracts/src/test/TestContracts/ChainlinkOracleMock.sol index 8242abca2..b859318e5 100644 --- a/contracts/src/test/TestContracts/ChainlinkOracleMock.sol +++ b/contracts/src/test/TestContracts/ChainlinkOracleMock.sol @@ -4,10 +4,19 @@ pragma solidity 0.8.24; import "../../Dependencies/AggregatorV3Interface.sol"; -// Mock Chainlink oracle that returns a stale price answer +// Mock Chainlink oracle that returns a stale price answer. +// this contract code is etched over mainnet oracle addresses in mainnet fork tests. +// As such, we use bools for staleness and decimals to save us having to set some contract state each time after etching. contract ChainlinkOracleMock is AggregatorV3Interface { - function decimals() external pure returns (uint8) { - return 8; + uint8 decimal; + + int256 price; + + uint256 lastUpdateTime; + + // We use 8 decimals unless set to 18 + function decimals() external view returns (uint8) { + return decimal; } function latestRoundData() @@ -15,10 +24,21 @@ contract ChainlinkOracleMock is AggregatorV3Interface { view returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) { - // returns a 2000 USD price, but it's stale - int256 price = 2000e8; - uint256 lastUpdateTime = block.timestamp - 7 days; - + // console2.log(lastUpdateTime, "lastUpdateTime"); + // console2.log(block.timestamp, "block.timestamp"); + // console2.log(price, "price returned by oracle"); return (0, price, 0, lastUpdateTime, 0); } + + function setDecimals(uint8 _decimals) external { + decimal = _decimals; + } + + function setPrice(int256 _price) external { + price = _price; + } + + function setUpdatedAt(uint256 _updatedAt) external { + lastUpdateTime = _updatedAt; + } } diff --git a/contracts/src/test/TestContracts/Deployment.t.sol b/contracts/src/test/TestContracts/Deployment.t.sol index 60a172517..8d28e05d3 100644 --- a/contracts/src/test/TestContracts/Deployment.t.sol +++ b/contracts/src/test/TestContracts/Deployment.t.sol @@ -43,8 +43,6 @@ import {ERC20Faucet} from "./ERC20Faucet.sol"; import "../../PriceFeeds/WETHPriceFeed.sol"; import "../../PriceFeeds/WSTETHPriceFeed.sol"; import "../../PriceFeeds/RETHPriceFeed.sol"; -import "../../PriceFeeds/OSETHPriceFeed.sol"; -import "../../PriceFeeds/ETHXPriceFeed.sol"; import "forge-std/console2.sol"; @@ -186,20 +184,14 @@ contract TestDeployer is MetadataDeployment { address ETHOracle; address STETHOracle; address RETHOracle; - address ETHXOracle; - address OSETHOracle; address WSTETHToken; address RETHToken; - address StaderOracle; // "StaderOracle" is the ETHX contract that manages the canonical exchange rate. Not a market pricacle. - address OsTokenVaultController; } struct OracleParams { uint256 ethUsdStalenessThreshold; uint256 stEthUsdStalenessThreshold; uint256 rEthEthStalenessThreshold; - uint256 ethXEthStalenessThreshold; - uint256 osEthEthStalenessThreshold; } // See: https://solidity-by-example.org/app/create2/ @@ -512,23 +504,16 @@ contract TestDeployer is MetadataDeployment { result.externalAddresses.ETHOracle = 0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419; result.externalAddresses.RETHOracle = 0x536218f9E9Eb48863970252233c8F271f554C2d0; result.externalAddresses.STETHOracle = 0xCfE54B5cD566aB89272946F602D76Ea879CAb4a8; - result.externalAddresses.ETHXOracle = 0xC5f8c4aB091Be1A899214c0C3636ca33DcA0C547; result.externalAddresses.WSTETHToken = 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0; - // Redstone Oracle with CL interface - // TODO: obtain the Chainlink market price feed and use that, when it's ready - result.externalAddresses.OSETHOracle = 0x66ac817f997Efd114EDFcccdce99F3268557B32C; result.externalAddresses.RETHToken = 0xae78736Cd615f374D3085123A210448E74Fc6393; - result.externalAddresses.StaderOracle = 0xF64bAe65f6f2a5277571143A24FaaFDFC0C2a737; - result.externalAddresses.OsTokenVaultController = 0x2A261e60FB14586B474C208b1B7AC6D0f5000306; vars.oracleParams.ethUsdStalenessThreshold = _24_HOURS; vars.oracleParams.stEthUsdStalenessThreshold = _24_HOURS; vars.oracleParams.rEthEthStalenessThreshold = _48_HOURS; - vars.oracleParams.ethXEthStalenessThreshold = _48_HOURS; - vars.oracleParams.osEthEthStalenessThreshold = _48_HOURS; - vars.numCollaterals = 5; + // Colls: WETH, WSTETH, RETH + vars.numCollaterals = 3; result.contractsArray = new LiquityContracts[](vars.numCollaterals); result.zappersArray = new Zappers[](vars.numCollaterals); vars.priceFeeds = new IPriceFeed[](vars.numCollaterals); @@ -555,30 +540,12 @@ contract TestDeployer is MetadataDeployment { // wstETH vars.priceFeeds[2] = new WSTETHPriceFeed( - address(this), - result.externalAddresses.STETHOracle, - vars.oracleParams.stEthUsdStalenessThreshold, - result.externalAddresses.WSTETHToken - ); - - // ETHx - vars.priceFeeds[3] = new ETHXPriceFeed( address(this), result.externalAddresses.ETHOracle, - result.externalAddresses.ETHXOracle, - result.externalAddresses.StaderOracle, - vars.oracleParams.ethUsdStalenessThreshold, - vars.oracleParams.ethXEthStalenessThreshold - ); - - // osETH - vars.priceFeeds[4] = new OSETHPriceFeed( - address(this), - result.externalAddresses.ETHOracle, - result.externalAddresses.OSETHOracle, - result.externalAddresses.OsTokenVaultController, + result.externalAddresses.STETHOracle, + result.externalAddresses.WSTETHToken, vars.oracleParams.ethUsdStalenessThreshold, - vars.oracleParams.osEthEthStalenessThreshold + vars.oracleParams.stEthUsdStalenessThreshold ); // Deploy Bold @@ -606,18 +573,6 @@ contract TestDeployer is MetadataDeployment { _deployAddressesRegistryMainnet(_troveManagerParamsArray[2]); vars.troveManagers[2] = ITroveManager(troveManagerAddress); - // ETHX - vars.collaterals[3] = IERC20Metadata(0xA35b1B31Ce002FBF2058D22F30f95D405200A15b); - (vars.addressesRegistries[3], troveManagerAddress) = - _deployAddressesRegistryMainnet(_troveManagerParamsArray[3]); - vars.troveManagers[3] = ITroveManager(troveManagerAddress); - - // OSETH - vars.collaterals[4] = IERC20Metadata(0xf1C9acDc66974dFB6dEcB12aA385b9cD01190E38); - (vars.addressesRegistries[4], troveManagerAddress) = - _deployAddressesRegistryMainnet(_troveManagerParamsArray[4]); - vars.troveManagers[4] = ITroveManager(troveManagerAddress); - // Deploy registry and register the TMs result.collateralRegistry = new CollateralRegistry(result.boldToken, vars.collaterals, vars.troveManagers); diff --git a/contracts/src/test/TestContracts/PriceFeedMock.sol b/contracts/src/test/TestContracts/PriceFeedMock.sol index 96a9371b8..b1b6f6de3 100644 --- a/contracts/src/test/TestContracts/PriceFeedMock.sol +++ b/contracts/src/test/TestContracts/PriceFeedMock.sol @@ -19,6 +19,10 @@ contract PriceFeedMock is IPriceFeedMock { return (PRICE, false); } + function fetchRedemptionPrice() external view returns (uint256, bool) { + return (PRICE, false); + } + function lastGoodPrice() external view returns (uint256) { return PRICE; } diff --git a/contracts/src/test/TestContracts/PriceFeedTestnet.sol b/contracts/src/test/TestContracts/PriceFeedTestnet.sol index 4e72a4968..cb61b3ddc 100644 --- a/contracts/src/test/TestContracts/PriceFeedTestnet.sol +++ b/contracts/src/test/TestContracts/PriceFeedTestnet.sol @@ -31,6 +31,13 @@ contract PriceFeedTestnet is IPriceFeedTestnet { return (_price, false); } + function fetchRedemptionPrice() external override returns (uint256, bool) { + // Fire an event just like the mainnet version would. + // This lets the subgraph rely on events to get the latest price even when developing locally. + emit LastGoodPriceUpdated(_price); + return (_price, false); + } + // Manual external price setter. function setPrice(uint256 price) external returns (bool) { _price = price; diff --git a/contracts/src/test/TestContracts/RETHTokenMock.sol b/contracts/src/test/TestContracts/RETHTokenMock.sol new file mode 100644 index 000000000..436841ede --- /dev/null +++ b/contracts/src/test/TestContracts/RETHTokenMock.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.24; + +import "../../Interfaces/IRETHToken.sol"; + +contract RETHTokenMock is IRETHToken { + + function getExchangeRate() external pure returns (uint256) { + return 0; + } +} \ No newline at end of file diff --git a/contracts/src/test/TestContracts/WSTETHTokenMock.sol b/contracts/src/test/TestContracts/WSTETHTokenMock.sol new file mode 100644 index 000000000..1c4be0e57 --- /dev/null +++ b/contracts/src/test/TestContracts/WSTETHTokenMock.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.24; + +import "../../Interfaces/IWSTETH.sol"; + +contract WSTETHTokenMock is IWSTETH{ + + function stEthPerToken() external pure returns (uint256) {return 0;} + function wrap(uint256 _stETHAmount) external pure returns (uint256) {return _stETHAmount;} + function unwrap(uint256 _wstETHAmount) external pure returns (uint256) {return _wstETHAmount;} + function getWstETHByStETH(uint256 _stETHAmount) external pure returns (uint256) {return _stETHAmount;} + function getStETHByWstETH(uint256 _wstETHAmount) external pure returns (uint256) {return _wstETHAmount;} + function tokensPerStEth() external pure returns (uint256) {return 0;} +} \ No newline at end of file diff --git a/contracts/src/test/zapperLeverage.t.sol b/contracts/src/test/zapperLeverage.t.sol index 863bbe397..3358b5d83 100644 --- a/contracts/src/test/zapperLeverage.t.sol +++ b/contracts/src/test/zapperLeverage.t.sol @@ -37,7 +37,7 @@ contract ZapperLeverageMainnet is DevTestSetup { uint24 constant UNIV3_FEE_USDC_WETH = 500; // 0.05% uint24 constant UNIV3_FEE_WETH_COLL = 100; // 0.01% - uint256 constant NUM_COLLATERALS = 5; + uint256 constant NUM_COLLATERALS = 3; IZapper[] baseZapperArray; ILeverageZapper[] leverageZapperCurveArray; diff --git a/contracts/utils/assets/test_output/uris.html b/contracts/utils/assets/test_output/uris.html new file mode 100644 index 000000000..14d106a1e --- /dev/null +++ b/contracts/utils/assets/test_output/uris.html @@ -0,0 +1,3 @@ +Test Uri