Skip to content

Commit

Permalink
Merge pull request #569 from liquity/eth_price_fallback_2_fixes
Browse files Browse the repository at this point in the history
Eth price fallback 2 fixes
  • Loading branch information
RickGriff authored Nov 5, 2024
2 parents f170dd8 + df99fec commit 6a05645
Show file tree
Hide file tree
Showing 5 changed files with 150 additions and 17 deletions.
10 changes: 9 additions & 1 deletion contracts/src/PriceFeeds/CompositePriceFeed.sol
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import "./MainnetPriceFeedBase.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).
contract CompositePriceFeed is MainnetPriceFeedBase {
abstract contract CompositePriceFeed is MainnetPriceFeedBase {
address public rateProviderAddress;

constructor(
Expand Down Expand Up @@ -96,6 +96,14 @@ contract CompositePriceFeed is MainnetPriceFeedBase {
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;
}

// 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
Expand Down
10 changes: 9 additions & 1 deletion contracts/src/PriceFeeds/MainnetPriceFeedBase.sol
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ abstract contract MainnetPriceFeedBase is IMainnetPriceFeed, Ownable {
bool success;
}

error InsufficientGasForExternalCall();
event ShutDownFromOracleFailure(address _failedOracleAddr);

Oracle public ethUsdOracle;
Expand Down Expand Up @@ -84,7 +85,9 @@ abstract contract MainnetPriceFeedBase is IMainnetPriceFeed, 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 */
) {
Expand All @@ -96,6 +99,11 @@ abstract contract MainnetPriceFeedBase is IMainnetPriceFeed, 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;
}
Expand Down
20 changes: 9 additions & 11 deletions contracts/src/PriceFeeds/RETHPriceFeed.sol
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ contract RETHPriceFeed is CompositePriceFeed, IRETHPriceFeed {

Oracle public rEthEthOracle;

uint256 constant public _2_PERCENT = 2e16;
uint256 constant public RETH_ETH_DEVIATION_THRESHOLD = 2e16; // 2%

function _fetchPricePrimary(bool _isRedemption) internal override returns (uint256, bool) {
assert(priceSource == PriceSource.primary);
Expand Down Expand Up @@ -59,7 +59,7 @@ contract RETHPriceFeed is CompositePriceFeed, IRETHPriceFeed {
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 && _within2pct(rEthUsdMarketPrice, rEthUsdCanonicalPrice)) {
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.
Expand All @@ -72,22 +72,20 @@ contract RETHPriceFeed is CompositePriceFeed, IRETHPriceFeed {
return (rEthUsdPrice, false);
}

function _within2pct(uint256 _rEthUsdMarketPrice, uint256 _rEthUsdCanonicalPrice) internal pure returns (bool) {
// Calculate the price deviation of the oracle market price relative to the canonical price
uint256 max = _rEthUsdCanonicalPrice * (DECIMAL_PRECISION + _2_PERCENT) / 1e18;
uint256 min = _rEthUsdCanonicalPrice * (DECIMAL_PRECISION - _2_PERCENT) / 1e18;

return _rEthUsdMarketPrice >= min && _rEthUsdMarketPrice <= max;
}


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);
}
Expand Down
18 changes: 15 additions & 3 deletions contracts/src/PriceFeeds/WSTETHPriceFeed.sol
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,13 @@ import "./CompositePriceFeed.sol";
import "../Interfaces/IWSTETH.sol";
import "../Interfaces/IWSTETHPriceFeed.sol";

// import "forge-std/console2.sol";

contract WSTETHPriceFeed is CompositePriceFeed, IWSTETHPriceFeed {
Oracle public stEthUsdOracle;

uint256 constant public STETH_USD_DEVIATION_THRESHOLD = 1e16; // 1%

constructor(
address _owner,
address _ethUsdOracleAddress,
Expand Down Expand Up @@ -48,8 +52,8 @@ contract WSTETHPriceFeed is CompositePriceFeed, IWSTETHPriceFeed {
// Otherwise, use the primary price calculation:
uint256 wstEthUsdPrice;

if (_isRedemption) {
// If it's a redemption, take the max of (STETH-USD, ETH-USD) and convert to WSTETH-USD
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
Expand All @@ -62,14 +66,22 @@ contract WSTETHPriceFeed is CompositePriceFeed, IWSTETHPriceFeed {
}

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 {
// If call to exchange rate reverts, return true
// 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);
}

}
}
109 changes: 108 additions & 1 deletion contracts/src/test/OracleMainnet.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

pragma solidity 0.8.18;

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";
Expand Down Expand Up @@ -1696,7 +1701,7 @@ contract OraclesMainnet is TestAccounts {
assertEq(contractsArray[0].collToken.balanceOf(A), A_collBefore + expectedCollDelta);
}

function testRedemptionOfWSTETHUsesMaxETHUSDMarketandWSTETHUSDMarketForPrimaryPrice() public {
function testRedemptionOfWSTETHUsesMaxETHUSDMarketandWSTETHUSDMarketForPrimaryPriceWhenWithin1pct() public {
// Fetch price
wstethPriceFeed.fetchPrice();
uint256 lastGoodPrice1 = wstethPriceFeed.lastGoodPrice();
Expand All @@ -1717,6 +1722,11 @@ contract OraclesMainnet is TestAccounts {
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;
Expand Down Expand Up @@ -1751,6 +1761,85 @@ contract OraclesMainnet is TestAccounts {
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);
uint256 troveId = 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();
Expand Down Expand Up @@ -1888,6 +1977,24 @@ contract OraclesMainnet is TestAccounts {
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);
address(wstethPriceFeed).call{gas: 10000}(abi.encodeWithSignature("fetchPrice()"));
}

function testRevertLowGasRETH() public {
vm.expectRevert(MainnetPriceFeedBase.InsufficientGasForExternalCall.selector);
address(rethPriceFeed).call{gas: 10000}(abi.encodeWithSignature("fetchPrice()"));
}

function testRevertLowGasWETH() public {
vm.expectRevert(MainnetPriceFeedBase.InsufficientGasForExternalCall.selector);
address(wethPriceFeed).call{gas: 10000}(abi.encodeWithSignature("fetchPrice()"));
}

// - More basic actions tests (adjust, close, etc)
// - liq tests (manipulate aggregator stored price)
}

0 comments on commit 6a05645

Please sign in to comment.