diff --git a/contracts/contracts/interfaces/IVault.sol b/contracts/contracts/interfaces/IVault.sol index e6b1668ca9..7e4d1ce83e 100644 --- a/contracts/contracts/interfaces/IVault.sol +++ b/contracts/contracts/interfaces/IVault.sol @@ -120,6 +120,8 @@ interface IVault { function priceUnitRedeem(address asset) external view returns (uint256); + function floorPrice() external view returns (uint256); + function withdrawAllFromStrategy(address _strategyAddr) external; function withdrawAllFromStrategies() external; diff --git a/contracts/contracts/interfaces/chainlink/README.md b/contracts/contracts/interfaces/chainlink/README.md new file mode 100644 index 0000000000..456fa4c904 --- /dev/null +++ b/contracts/contracts/interfaces/chainlink/README.md @@ -0,0 +1,3 @@ +# Diagrams + +![AggregatorV3Interface](../../../docs/AggregatorV3Interface.svg) diff --git a/contracts/contracts/mocks/MockVault.sol b/contracts/contracts/mocks/MockVault.sol index b563f9a725..9e45945331 100644 --- a/contracts/contracts/mocks/MockVault.sol +++ b/contracts/contracts/mocks/MockVault.sol @@ -10,6 +10,7 @@ contract MockVault is VaultCore { using StableMath for uint256; uint256 storedTotalValue; + uint256 public override floorPrice; function setTotalValue(uint256 _value) public { storedTotalValue = _value; @@ -42,4 +43,8 @@ contract MockVault is VaultCore { function setMaxSupplyDiff(uint256 _maxSupplyDiff) external onlyGovernor { maxSupplyDiff = _maxSupplyDiff; } + + function setFloorPrice(uint256 _floorPrice) external { + floorPrice = _floorPrice; + } } diff --git a/contracts/contracts/mocks/curve/MockCurveOethEthPool.sol b/contracts/contracts/mocks/curve/MockCurveOethEthPool.sol new file mode 100644 index 0000000000..40ae612150 --- /dev/null +++ b/contracts/contracts/mocks/curve/MockCurveOethEthPool.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import { MockCurveAbstractMetapool } from "./MockCurveAbstractMetapool.sol"; +import "../MintableERC20.sol"; + +contract MockCurveOethEthPool is MockCurveAbstractMetapool { + constructor(address[2] memory _coins) + ERC20("Curve.fi Factory Pool: OETH", "OETHCRV-f") + { + coins = _coins; + } + + // Simulate pool's EMA Oracle price + uint256 public price_oracle = 9995e14; // 0.9995 + + function setOraclePrice(uint256 _price) public { + price_oracle = _price; + } +} diff --git a/contracts/contracts/oracle/BaseOracle.sol b/contracts/contracts/oracle/BaseOracle.sol new file mode 100644 index 0000000000..849535c73b --- /dev/null +++ b/contracts/contracts/oracle/BaseOracle.sol @@ -0,0 +1,202 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import { IOracleReceiver } from "./IOracleReceiver.sol"; +import { AggregatorV3Interface } from "../interfaces/chainlink/AggregatorV3Interface.sol"; +import { Governable } from "../governance/Governable.sol"; + +/** + * @title BaseOracle + * @notice Generic Chainlink style oracle + * @author Origin Protocol Inc + */ +abstract contract BaseOracle is + AggregatorV3Interface, + IOracleReceiver, + Governable +{ + /// @notice Contract or account that can call addRoundData() to update the Oracle prices + address public oracleUpdater; + + /// @notice Last round ID where isBadData is false and price is within maximum deviation + uint80 public lastCorrectRoundId; + + /// @notice Historical Oracle prices + Round[] public rounds; + + /// @notice Packed Round data struct + /// @notice price Oracle price to 18 decimals + /// @notice timestamp timestamp in seconds of price + struct Round { + uint128 price; + uint40 timestamp; + } + + event SetOracleUpdater(address oracleUpdater); + + constructor(address _oracleUpdater) Governable() { + _setOracleUpdater(_oracleUpdater); + } + + /*************************************** + Internal Setters + ****************************************/ + + /// @notice Sets the contract or account that can add Oracle prices + /// @param _oracleUpdater Address of the contract or account that can update the Oracle prices + function _setOracleUpdater(address _oracleUpdater) internal { + emit SetOracleUpdater(_oracleUpdater); + oracleUpdater = _oracleUpdater; + } + + /*************************************** + External Setters + ****************************************/ + + /// @notice Sets the contract or account that can update the Oracle prices + /// @param _oracleUpdater Address of the contract or account that can update the Oracle prices + function setOracleUpdater(address _oracleUpdater) external onlyGovernor { + _setOracleUpdater(_oracleUpdater); + } + + /*************************************** + Metadata + ****************************************/ + + /// @notice The number of decimals in the Oracle price. + function decimals() + external + pure + virtual + override + returns (uint8 _decimals) + { + _decimals = 18; + } + + /// @notice The version number for the AggregatorV3Interface. + /// @dev Adheres to AggregatorV3Interface, which is different than typical semver + function version() + external + view + virtual + override + returns (uint256 _version) + { + _version = 1; + } + + /*************************************** + Oracle Receiver + ****************************************/ + + /// @notice Adds a new Oracle price by the Oracle updater. + /// Can not be run twice in the same block. + /// @param _price is the Oracle price with 18 decimals + function addPrice(uint128 _price) external override { + if (msg.sender != oracleUpdater) revert OnlyOracleUpdater(); + if (_price == 0) revert NoPriceData(); + + // Can not add price in the same or previous blocks + uint256 _roundsLength = rounds.length; + if ( + _roundsLength > 0 && + block.timestamp <= rounds[_roundsLength - 1].timestamp + ) { + revert AddPriceSameBlock(); + } + + lastCorrectRoundId = uint80(_roundsLength); + + rounds.push( + Round({ price: _price, timestamp: uint40(block.timestamp) }) + ); + } + + /*************************************** + Prices + ****************************************/ + + function _getRoundData(uint80 _roundId) + internal + view + returns ( + uint80 roundId, + int256 answer, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ) + { + if (rounds.length <= _roundId) revert NoPriceData(); + + Round memory _round = rounds[_roundId]; + answer = int256(uint256(_round.price)); + + roundId = answeredInRound = _roundId; + startedAt = updatedAt = _round.timestamp; + } + + /// @notice Returns the Oracle price data for a specific round. + /// @param _roundId The round ID + /// @return roundId The round ID + /// @return answer The Oracle price + /// @return startedAt Timestamp of when the round started + /// @return updatedAt Timestamp of when the round was updated + /// @return answeredInRound The round ID in which the answer was computed + function getRoundData(uint80 _roundId) + external + view + override + returns ( + uint80 roundId, + int256 answer, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ) + { + ( + roundId, + answer, + startedAt, + updatedAt, + answeredInRound + ) = _getRoundData(_roundId); + } + + /// @notice Returns the latest Oracle price data. + /// @return roundId The round ID + /// @return answer The Oracle price + /// @return startedAt Timestamp of when the round started + /// @return updatedAt Timestamp of when the round was updated + /// @return answeredInRound The round ID in which the answer was computed + function latestRoundData() + external + view + override + returns ( + uint80 roundId, + int256 answer, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ) + { + ( + roundId, + answer, + startedAt, + updatedAt, + answeredInRound + ) = _getRoundData(lastCorrectRoundId); + } + + /*************************************** + Errors + ****************************************/ + + error AddPriceSameBlock(); + error NoPriceData(); + error OnlyOracleUpdater(); +} diff --git a/contracts/contracts/oracle/IOracleReceiver.sol b/contracts/contracts/oracle/IOracleReceiver.sol new file mode 100644 index 0000000000..b943e9b987 --- /dev/null +++ b/contracts/contracts/oracle/IOracleReceiver.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +interface IOracleReceiver { + function addPrice(uint128 price) external; +} diff --git a/contracts/contracts/oracle/OETHOracle.sol b/contracts/contracts/oracle/OETHOracle.sol new file mode 100644 index 0000000000..6092ce5244 --- /dev/null +++ b/contracts/contracts/oracle/OETHOracle.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import { BaseOracle } from "./BaseOracle.sol"; + +/** + * @title OETH Oracle + * @notice Chainlink style oracle for OETH/ETH + * @author Origin Protocol Inc + */ +contract OETHOracle is BaseOracle { + string public constant override description = "OETH / ETH"; + + /** + * @param _oracleUpdater Address of the contract that is authorized to add prices + */ + constructor(address _oracleUpdater) BaseOracle(_oracleUpdater) {} +} diff --git a/contracts/contracts/oracle/OETHOracleUpdater.sol b/contracts/contracts/oracle/OETHOracleUpdater.sol new file mode 100644 index 0000000000..ecafe0484c --- /dev/null +++ b/contracts/contracts/oracle/OETHOracleUpdater.sol @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import { IOracleReceiver } from "./IOracleReceiver.sol"; +import { IVault } from "../interfaces/IVault.sol"; +import { Governable } from "../governance/Governable.sol"; +import { ICurvePool } from "../strategies/ICurvePool.sol"; + +/** + * @title OETH Oracle Updater + * @notice Gathers on-chain OETH pricing data and updates the OETHOracle contract. + * @author Origin Protocol Inc + */ +contract OETHOracleUpdater is Governable { + /// @notice Max OETH price when redeeming via the vault to 18 decimals. + /// The vault charges a 0.5% withdraw fee and the oracle prices of + /// the vault collateral assets are capped at 1 so the max price is 0.995. + /// @dev A new OETHOracleUpdater needs to be deployed if the vault withdraw fee changes. + uint256 public constant MAX_VAULT_PRICE = 995e15; + + /// @notice The OETH/ETH Curve pool + ICurvePool public immutable curvePool; + /// @notice The OETH Vault + IVault public immutable vault; + + struct OracleUpdaterConfig { + address vault; + address curvePool; + } + + event AddPrice( + address indexed oracle, + uint256 answer, + uint256 vaultPrice, + uint256 marketPrice + ); + + /** + * @param _vault Address of the OETH Vault + * @param _curvePool Address of the OETH/ETH Curve pool + */ + constructor(address _vault, address _curvePool) Governable() { + curvePool = ICurvePool(_curvePool); + vault = IVault(_vault); + } + + /// @notice Adds a new on-chain, aggregated OETH/ETH price to 18 decimals to the specified Oracle. + /// @dev Callable by anyone as the prices are sourced and aggregated on-chain. + /// @param oracle Address of the Oracle that has authorized this contract to add prices. + function addPrice(IOracleReceiver oracle) external { + ( + uint256 answer, + uint256 vaultPrice, + uint256 marketPrice + ) = _getPrices(); + + emit AddPrice(address(oracle), answer, vaultPrice, marketPrice); + + // Add the new aggregated price to the oracle. + // Authorization is handled on Oracle side + oracle.addPrice(uint128(answer)); + } + + /** + * @dev Gets the OETH/ETH market price, vault floor price and aggregates to a OETH/ETH price to 18 decimals. + */ + function _getPrices() + internal + view + returns ( + uint256 answer, + uint256 vaultPrice, + uint256 marketPrice + ) + { + // Get the aggregated market price from on-chain DEXs + marketPrice = _getMarketPrice(); + + // If market price is equal or above the vault price with the withdraw fee + if (marketPrice >= MAX_VAULT_PRICE) { + answer = marketPrice; + // Avoid getting the vault price as this is gas intensive. + // its not going to be higher than 0.995 with a 0.5% withdraw fee + vaultPrice = MAX_VAULT_PRICE; + } else { + // Get the price from the Vault. This includes the withdraw fee + // and the vault collateral assets priced using oracles + vaultPrice = vault.floorPrice(); + + if (marketPrice > vaultPrice) { + // Return the market price with the Vault price as the floor price + answer = marketPrice; + } else { + // Return the vault price + answer = vaultPrice; + } + } + + // Cap the OETH/ETH price at 1 + if (answer > 1e18) { + answer = 1e18; + } + } + + /** + * @dev Gets the market prices from on-chain DEXs. + * Currently, this is Curve's OETH/ETH Exponential Moving Average (EMA) oracle. + * This can be expended later to support aggregation across multiple on-chain DEXs. + * For example, other OETH Curve, Balancer or Uniswap pools. + */ + function _getMarketPrice() internal view returns (uint256 marketPrice) { + // Get the EMA oracle price from the Curve pool + marketPrice = curvePool.price_oracle(); + } + + /// @notice Get the latest prices from the OETH Vault and OETH/ETH Curve pool to 18 decimals. + /// @return answer the aggregated OETH/ETH price + /// @return vaultPrice the vault floor price if the market price is below the max vault floor price + /// @return marketPrice the latest market price + function getPrices() + external + view + returns ( + uint256 answer, + uint256 vaultPrice, + uint256 marketPrice + ) + { + (answer, vaultPrice, marketPrice) = _getPrices(); + } +} diff --git a/contracts/contracts/oracle/README.md b/contracts/contracts/oracle/README.md index b309bbbbf5..5c4feffdf3 100644 --- a/contracts/contracts/oracle/README.md +++ b/contracts/contracts/oracle/README.md @@ -14,16 +14,24 @@ ![OETH Oracle Router Storage](../../docs/OETHOracleRouterStorage.svg) -## Mix Oracle +## OETH Oracle ### Hierarchy -![Mix Oracle Hierarchy](../../docs/MixOracleHierarchy.svg) +![OETH Oracle Hierarchy](../../docs/OETHOracleHierarchy.svg) -### Squashed +### OETHOracle Squashed -![Mix Oracle Squashed](../../docs/MixOracleSquashed.svg) +![OETH Oracle Squashed](../../docs/OETHOracleSquashed.svg) -### Storage +### OETHOracle Storage + +![OETH Oracle Storage](../../docs/OETHOracleStorage.svg) + +### OETHOracleUpdater Squashed + +![OETH Oracle Updater Squashed](../../docs/OETHOracleUpdaterSquashed.svg) + +### OETHOracleUpdater Storage -![Mix Oracle Storage](../../docs/MixOracleStorage.svg) +![OETH Oracle Updater Storage](../../docs/OETHOracleUpdaterStorage.svg) diff --git a/contracts/contracts/strategies/ICurvePool.sol b/contracts/contracts/strategies/ICurvePool.sol index 9f2b5c9a28..ed5ac33f7e 100644 --- a/contracts/contracts/strategies/ICurvePool.sol +++ b/contracts/contracts/strategies/ICurvePool.sol @@ -36,4 +36,6 @@ interface ICurvePool { uint256[3] calldata _amounts, uint256 maxBurnAmount ) external; + + function price_oracle() external view returns (uint256); } diff --git a/contracts/contracts/vault/VaultCore.sol b/contracts/contracts/vault/VaultCore.sol index efa8f2ca41..cfa4afc241 100644 --- a/contracts/contracts/vault/VaultCore.sol +++ b/contracts/contracts/vault/VaultCore.sol @@ -11,13 +11,12 @@ pragma solidity ^0.8.0; * @author Origin Protocol Inc */ -import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { IERC20, SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import { StableMath } from "../utils/StableMath.sol"; import { IOracle } from "../interfaces/IOracle.sol"; import { IGetExchangeRateToken } from "../interfaces/IGetExchangeRateToken.sol"; - -import "./VaultInitializer.sol"; +import { IStrategy, VaultInitializer } from "./VaultInitializer.sol"; contract VaultCore is VaultInitializer { using SafeERC20 for IERC20; @@ -565,6 +564,34 @@ contract VaultCore is VaultInitializer { Pricing ****************************************/ + /** + * @notice The value (USD or ETH) of the collateral assets received from + * redeeming 1 Origin Token (OUSD or OETH) from the Vault. + * This is the minimum price for the OToken. A better price is usually achieved by + * swapping the OToken on the Curve pool used for Automated Market Operations (AMO). + * For OETH, that's the Curve OETH/ETH pool. + * For OUSD, that's the Curve OUSD/3Crv pool. + * @param price the price to 18 decimals. + */ + function floorPrice() external view virtual returns (uint256 price) { + // Get the assets for redeeming 1 OETH + // This has already had the redeem fee applied + uint256[] memory redeemAssets = _calculateRedeemOutputs(1e18); + + // For each of the redeemed assets + for (uint256 i = 0; i < redeemAssets.length; ++i) { + // Sum the value of the vault asset = asset amount * oracle price + // For OUSD's USDC and USDT assets that are to 6 decimals, the oracle + // price is to 18 decimals, so we do not need to scale them up to 18 decimals + price += + redeemAssets[i] * + IOracle(priceProvider).price(allAssets[i]); + } + + // scale back down to 18 decimals as we multiplied two 18 decimals numbers to get the value. + price = price / 1e18; + } + /** * @notice Returns the total price in 18 digit units for a given asset. * Never goes above 1, since that is how we price mints. @@ -662,15 +689,15 @@ contract VaultCore is VaultInitializer { function _toUnitPrice(address _asset, bool isMint) internal view - returns (uint256 price) + returns (uint256 price_) { UnitConversion conversion = assets[_asset].unitConversion; - price = IOracle(priceProvider).price(_asset); + price_ = IOracle(priceProvider).price(_asset); if (conversion == UnitConversion.GETEXCHANGERATE) { uint256 exchangeRate = IGetExchangeRateToken(_asset) .getExchangeRate(); - price = (price * 1e18) / exchangeRate; + price_ = (price_ * 1e18) / exchangeRate; } else if (conversion != UnitConversion.DECIMALS) { revert("Unsupported conversion type"); } @@ -679,23 +706,23 @@ contract VaultCore is VaultInitializer { * so the price checks are agnostic to underlying asset being * pegged to a USD or to an ETH or having a custom exchange rate. */ - require(price <= MAX_UNIT_PRICE_DRIFT, "Vault: Price exceeds max"); - require(price >= MIN_UNIT_PRICE_DRIFT, "Vault: Price under min"); + require(price_ <= MAX_UNIT_PRICE_DRIFT, "Vault: Price exceeds max"); + require(price_ >= MIN_UNIT_PRICE_DRIFT, "Vault: Price under min"); if (isMint) { /* Never price a normalized unit price for more than one * unit of OETH/OUSD when minting. */ - if (price > 1e18) { - price = 1e18; + if (price_ > 1e18) { + price_ = 1e18; } - require(price >= MINT_MINIMUM_UNIT_PRICE, "Asset price below peg"); + require(price_ >= MINT_MINIMUM_UNIT_PRICE, "Asset price below peg"); } else { /* Never give out more than 1 normalized unit amount of assets * for one unit of OETH/OUSD when redeeming. */ - if (price < 1e18) { - price = 1e18; + if (price_ < 1e18) { + price_ = 1e18; } } } diff --git a/contracts/contracts/vault/VaultStorage.sol b/contracts/contracts/vault/VaultStorage.sol index 066754e38f..b71179b69c 100644 --- a/contracts/contracts/vault/VaultStorage.sol +++ b/contracts/contracts/vault/VaultStorage.sol @@ -72,6 +72,7 @@ contract VaultStorage is Initializable, Governable { // slither-disable-next-line uninitialized-state mapping(address => Asset) internal assets; /// @dev list of all assets supported by the vault. + // slither-disable-next-line uninitialized-state address[] internal allAssets; // Strategies approved for use by the Vault diff --git a/contracts/deploy/001_core.js b/contracts/deploy/001_core.js index 49a38b9a5a..4082032235 100644 --- a/contracts/deploy/001_core.js +++ b/contracts/deploy/001_core.js @@ -1191,6 +1191,50 @@ const deployOUSDSwapper = async () => { await vault.connect(sGovernor).setOracleSlippage(assetAddresses.USDT, 50); }; +/** + * Deploy the OETHOracle contracts. + */ +const deployOETHOracle = async () => { + const { deployerAddr, governorAddr } = await getNamedAccounts(); + const sDeployer = await ethers.provider.getSigner(deployerAddr); + const sGovernor = await ethers.provider.getSigner(governorAddr); + + const oeth = await ethers.getContract("OETHProxy"); + await deployWithConfirmation("MockCurveOethEthPool", [ + [oeth.address, addresses.ETH], + ]); + const curveOethEthPool = await ethers.getContract("MockCurveOethEthPool"); + + const vault = await ethers.getContract("MockVault"); + + await deployWithConfirmation("OETHOracleUpdater", [ + vault.address, + curveOethEthPool.address, + ]); + const cOETHOracleUpdater = await ethers.getContract("OETHOracleUpdater"); + + await withConfirmation( + cOETHOracleUpdater.connect(sDeployer).transferGovernance(governorAddr) + ); + await withConfirmation( + cOETHOracleUpdater + .connect(sGovernor) // Claim governance with governor + .claimGovernance() + ); + + await deployWithConfirmation("OETHOracle", [cOETHOracleUpdater.address]); + const cOETHOracle = await ethers.getContract("OETHOracle"); + + await withConfirmation( + cOETHOracle.connect(sDeployer).transferGovernance(governorAddr) + ); + await withConfirmation( + cOETHOracle + .connect(sGovernor) // Claim governance with governor + .claimGovernance() + ); +}; + const main = async () => { console.log("Running 001_core deployment..."); await deployOracles(); @@ -1216,6 +1260,7 @@ const main = async () => { await deployWOusd(); await deployOETHSwapper(); await deployOUSDSwapper(); + await deployOETHOracle(); console.log("001_core deploy done."); return true; }; diff --git a/contracts/deploy/081_oeth_oracle.js b/contracts/deploy/081_oeth_oracle.js new file mode 100644 index 0000000000..e2eedf3d4a --- /dev/null +++ b/contracts/deploy/081_oeth_oracle.js @@ -0,0 +1,80 @@ +const addresses = require("../utils/addresses"); +const { + deploymentWithGovernanceProposal, + withConfirmation, +} = require("../utils/deploy"); + +module.exports = deploymentWithGovernanceProposal( + { + deployName: "081_oeth_oracle", + forceDeploy: false, + // forceSkip: true, + reduceQueueTime: false, + deployerIsProposer: true, + // proposalId: "", + }, + async ({ ethers, deployWithConfirmation }) => { + const { deployerAddr } = await getNamedAccounts(); + const sDeployer = await ethers.provider.getSigner(deployerAddr); + + // 1. Connect to the OETH Vault as its governor via the proxy + const cVaultProxy = await ethers.getContract("OETHVaultProxy"); + + // 2. Deploy the new Vault implementation + const dVaultCore = await deployWithConfirmation("OETHVaultCore"); + + // 3. Deploy the new Oracle contracts + const dOETHOracleUpdater = await deployWithConfirmation( + "OETHOracleUpdater", + [cVaultProxy.address, addresses.mainnet.CurveOETHMetaPool] + ); + const cOETHOracleUpdater = await ethers.getContractAt( + "OETHOracleUpdater", + dOETHOracleUpdater.address + ); + const dOETHOracle = await deployWithConfirmation("OETHOracle", [ + dOETHOracleUpdater.address, + ]); + const cOETHOracle = await ethers.getContractAt( + "OETHOracle", + dOETHOracle.address + ); + + // 4. Transfer governance + await withConfirmation( + cOETHOracleUpdater + .connect(sDeployer) + .transferGovernance(addresses.mainnet.Timelock) + ); + await withConfirmation( + cOETHOracle + .connect(sDeployer) + .transferGovernance(addresses.mainnet.Timelock) + ); + + // 4. Governance Actions + return { + name: "Upgrade the OETH Vault and deploy the OETH Oracle and Oracle Updater.", + actions: [ + // 1. Upgrade the OETH Vault proxy to the new core vault implementation + { + contract: cVaultProxy, + signature: "upgradeTo(address)", + args: [dVaultCore.address], + }, + // 2. Accept governance for OETHOracleUpdater + { + contract: cOETHOracleUpdater, + signature: "claimGovernance()", + args: [], + }, + // 3. Accept governance for OETHOracle + { + contract: cOETHOracle, + signature: "claimGovernance()", + args: [], + }, + ], + }; + } +); diff --git a/contracts/docs/AggregatorV3Interface.svg b/contracts/docs/AggregatorV3Interface.svg new file mode 100644 index 0000000000..061050d054 --- /dev/null +++ b/contracts/docs/AggregatorV3Interface.svg @@ -0,0 +1,28 @@ + + + + + + +UmlClassDiagram + + + +187 + +<<Interface>> +AggregatorV3Interface +../contracts/interfaces/chainlink/AggregatorV3Interface.sol + +External: +     decimals(): uint8 +     description(): string +     version(): uint256 +     getRoundData(_roundId: uint80): (roundId: uint80, answer: int256, startedAt: uint256, updatedAt: uint256, answeredInRound: uint80) +     latestRoundData(): (roundId: uint80, answer: int256, startedAt: uint256, updatedAt: uint256, answeredInRound: uint80) + + + diff --git a/contracts/docs/OETHOracleHierarchy.svg b/contracts/docs/OETHOracleHierarchy.svg new file mode 100644 index 0000000000..bb1363581e --- /dev/null +++ b/contracts/docs/OETHOracleHierarchy.svg @@ -0,0 +1,60 @@ + + + + + + +UmlClassDiagram + + + +20 + +Governable +../contracts/governance/Governable.sol + + + +115 + +<<Abstract>> +BaseOracle +../contracts/oracle/BaseOracle.sol + + + +115->20 + + + + + +118 + +OETHOracle +../contracts/oracle/OETHOracle.sol + + + +118->115 + + + + + +119 + +OETHOracleUpdater +../contracts/oracle/OETHOracleUpdater.sol + + + +119->20 + + + + + diff --git a/contracts/docs/OETHOracleRouterHierarchy.svg b/contracts/docs/OETHOracleRouterHierarchy.svg index 1eefc3d304..d0122deb8e 100644 --- a/contracts/docs/OETHOracleRouterHierarchy.svg +++ b/contracts/docs/OETHOracleRouterHierarchy.svg @@ -4,78 +4,44 @@ - - + + UmlClassDiagram - - + + -30 - -<<Interface>> -IOracle -../contracts/interfaces/IOracle.sol +121 + +<<Abstract>> +OracleRouterBase +../contracts/oracle/OracleRouter.sol - + -167 - -<<Interface>> -AggregatorV3Interface -../contracts/interfaces/chainlink/AggregatorV3Interface.sol +122 + +OracleRouter +../contracts/oracle/OracleRouter.sol - - -80 - -<<Abstract>> -OracleRouterBase -../contracts/oracle/OracleRouter.sol - - + -80->30 - - - - - -80->167 - - - - - -81 - -OracleRouter -../contracts/oracle/OracleRouter.sol +122->121 + + - - -81->80 - - - - - -82 - -OETHOracleRouter -../contracts/oracle/OracleRouter.sol - - - -82->167 - - + + +123 + +OETHOracleRouter +../contracts/oracle/OracleRouter.sol - - -82->81 - - + + +123->122 + + diff --git a/contracts/docs/OETHOracleSquashed.svg b/contracts/docs/OETHOracleSquashed.svg new file mode 100644 index 0000000000..bc79f51a56 --- /dev/null +++ b/contracts/docs/OETHOracleSquashed.svg @@ -0,0 +1,61 @@ + + + + + + +UmlClassDiagram + + + +118 + +OETHOracle +../contracts/oracle/OETHOracle.sol + +Private: +   governorPosition: bytes32 <<Governable>> +   pendingGovernorPosition: bytes32 <<Governable>> +   reentryStatusPosition: bytes32 <<Governable>> +Public: +   _NOT_ENTERED: uint256 <<Governable>> +   _ENTERED: uint256 <<Governable>> +   oracleUpdater: address <<BaseOracle>> +   lastCorrectRoundId: uint80 <<BaseOracle>> +   rounds: Round[] <<BaseOracle>> +   description: string <<OETHOracle>> + +Internal: +    _governor(): (governorOut: address) <<Governable>> +    _pendingGovernor(): (pendingGovernor: address) <<Governable>> +    _setGovernor(newGovernor: address) <<Governable>> +    _setPendingGovernor(newGovernor: address) <<Governable>> +    _changeGovernor(_newGovernor: address) <<Governable>> +    _setOracleUpdater(_oracleUpdater: address) <<BaseOracle>> +    _getRoundData(_roundId: uint80): (roundId: uint80, answer: int256, startedAt: uint256, updatedAt: uint256, answeredInRound: uint80) <<BaseOracle>> +External: +     description(): string <<AggregatorV3Interface>> +    decimals(): (_decimals: uint8) <<BaseOracle>> +    version(): (_version: uint256) <<BaseOracle>> +    getRoundData(_roundId: uint80): (roundId: uint80, answer: int256, startedAt: uint256, updatedAt: uint256, answeredInRound: uint80) <<BaseOracle>> +    latestRoundData(): (roundId: uint80, answer: int256, startedAt: uint256, updatedAt: uint256, answeredInRound: uint80) <<BaseOracle>> +    addPrice(_price: uint128) <<BaseOracle>> +    transferGovernance(_newGovernor: address) <<onlyGovernor>> <<Governable>> +    claimGovernance() <<Governable>> +    setOracleUpdater(_oracleUpdater: address) <<onlyGovernor>> <<BaseOracle>> +Public: +    <<event>> PendingGovernorshipTransfer(previousGovernor: address, newGovernor: address) <<Governable>> +    <<event>> GovernorshipTransferred(previousGovernor: address, newGovernor: address) <<Governable>> +    <<event>> SetOracleUpdater(oracleUpdater: address) <<BaseOracle>> +    <<modifier>> onlyGovernor() <<Governable>> +    <<modifier>> nonReentrant() <<Governable>> +    constructor() <<Governable>> +    governor(): address <<Governable>> +    isGovernor(): bool <<Governable>> +    constructor(_oracleUpdater: address) <<OETHOracle>> + + + diff --git a/contracts/docs/OETHOracleStorage.svg b/contracts/docs/OETHOracleStorage.svg new file mode 100644 index 0000000000..8ff620e07d --- /dev/null +++ b/contracts/docs/OETHOracleStorage.svg @@ -0,0 +1,81 @@ + + + + + + +StorageDiagram + + + +3 + +OETHOracle <<Contract>> + +slot + +0 + +1 + +type: <inherited contract>.variable (bytes) + +unallocated (2) + +uint80: BaseOracle.lastCorrectRoundId (10) + +address: BaseOracle.oracleUpdater (20) + +Round[]: BaseOracle.rounds (32) + + + +2 + +Round[]: rounds <<Array>> +0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6 + +offset + +0 + +type: variable (bytes) + +Round (32) + + + +3:6->2 + + + + + +1 + +Round <<Struct>> +0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6 + +offset + +0 + +type: variable (bytes) + +unallocated (11) + +uint40: timestamp (5) + +uint128: price (16) + + + +2:5->1 + + + + + diff --git a/contracts/docs/OETHOracleUpdaterSquashed.svg b/contracts/docs/OETHOracleUpdaterSquashed.svg new file mode 100644 index 0000000000..ad2f350a3f --- /dev/null +++ b/contracts/docs/OETHOracleUpdaterSquashed.svg @@ -0,0 +1,55 @@ + + + + + + +UmlClassDiagram + + + +119 + +OETHOracleUpdater +../contracts/oracle/OETHOracleUpdater.sol + +Private: +   governorPosition: bytes32 <<Governable>> +   pendingGovernorPosition: bytes32 <<Governable>> +   reentryStatusPosition: bytes32 <<Governable>> +Public: +   _NOT_ENTERED: uint256 <<Governable>> +   _ENTERED: uint256 <<Governable>> +   MAX_VAULT_PRICE: uint256 <<OETHOracleUpdater>> +   curvePool: ICurvePool <<OETHOracleUpdater>> +   vault: IVault <<OETHOracleUpdater>> + +Internal: +    _governor(): (governorOut: address) <<Governable>> +    _pendingGovernor(): (pendingGovernor: address) <<Governable>> +    _setGovernor(newGovernor: address) <<Governable>> +    _setPendingGovernor(newGovernor: address) <<Governable>> +    _changeGovernor(_newGovernor: address) <<Governable>> +    _getPrices(): (answer: uint256, vaultPrice: uint256, marketPrice: uint256) <<OETHOracleUpdater>> +    _getMarketPrice(): (marketPrice: uint256) <<OETHOracleUpdater>> +External: +    transferGovernance(_newGovernor: address) <<onlyGovernor>> <<Governable>> +    claimGovernance() <<Governable>> +    addPrice(oracle: IOracleReceiver) <<OETHOracleUpdater>> +    getPrices(): (answer: uint256, vaultPrice: uint256, marketPrice: uint256) <<OETHOracleUpdater>> +Public: +    <<event>> PendingGovernorshipTransfer(previousGovernor: address, newGovernor: address) <<Governable>> +    <<event>> GovernorshipTransferred(previousGovernor: address, newGovernor: address) <<Governable>> +    <<event>> AddPrice(answer: uint256, vaultPrice: uint256, marketPrice: uint256) <<OETHOracleUpdater>> +    <<modifier>> onlyGovernor() <<Governable>> +    <<modifier>> nonReentrant() <<Governable>> +    constructor() <<Governable>> +    governor(): address <<Governable>> +    isGovernor(): bool <<Governable>> +    constructor(_vault: address, _curvePool: address) <<OETHOracleUpdater>> + + + diff --git a/contracts/docs/OETHOracleUpdaterStorage.svg b/contracts/docs/OETHOracleUpdaterStorage.svg new file mode 100644 index 0000000000..54b8454473 --- /dev/null +++ b/contracts/docs/OETHOracleUpdaterStorage.svg @@ -0,0 +1,23 @@ + + + + + + +StorageDiagram + + + +1 + +OETHOracleUpdater <<Contract>> + +slot + +type: <inherited contract>.variable (bytes) + + + diff --git a/contracts/docs/generate.sh b/contracts/docs/generate.sh index 88a9a8db53..321bc38c67 100644 --- a/contracts/docs/generate.sh +++ b/contracts/docs/generate.sh @@ -22,19 +22,26 @@ sol2uml .. -v -hv -hf -he -hs -hl -b OETHHarvester -o OETHHarvesterHierarchy.svg sol2uml .. -s -d 0 -b OETHHarvester -o OETHHarvesterSquashed.svg sol2uml storage .. -c OETHHarvester -o OETHHarvesterStorage.svg + +# contracts/interfaces/chainlink + +sol2uml .. -b AggregatorV3Interface -o AggregatorV3Interface.svg + # contracts/governance sol2uml .. -v -hv -hf -he -hs -hl -b Governor -o GovernorHierarchy.svg sol2uml .. -s -d 0 -b Governor -o GovernorSquashed.svg sol2uml storage .. -c Governor -o GovernorStorage.svg # contracts/oracles -sol2uml .. -v -hv -hf -he -hs -hl -b OETHOracleRouter -o OETHOracleRouterHierarchy.svg +sol2uml .. -v -hv -hf -he -hs -hl -hi -b OETHOracleRouter -o OETHOracleRouterHierarchy.svg sol2uml .. -s -d 0 -b OETHOracleRouter -o OETHOracleRouterSquashed.svg sol2uml storage .. -c OETHOracleRouter -o OETHOracleRouterStorage.svg -sol2uml .. -v -hv -hf -he -hs -hl -b MixOracle -o MixOracleHierarchy.svg -sol2uml .. -s -d 0 -b MixOracle -o MixOracleSquashed.svg -sol2uml storage .. -c MixOracle -o MixOracleStorage.svg +sol2uml .. -v -hv -hf -he -hs -hl -hi -b OETHOracle,OETHOracleUpdater -o OETHOracleHierarchy.svg +sol2uml .. -s -d 0 -b OETHOracle -o OETHOracleSquashed.svg +sol2uml storage .. -c OETHOracle -o OETHOracleStorage.svg +sol2uml .. -s -d 0 -b OETHOracleUpdater -o OETHOracleUpdaterSquashed.svg +sol2uml storage .. -c OETHOracleUpdater -o OETHOracleUpdaterStorage.svg # contracts/proxies sol2uml .. -v -hv -hf -he -hs -hl -b OUSDProxy -o OUSDProxyHierarchy.svg diff --git a/contracts/docs/plantuml/oethOracles.png b/contracts/docs/plantuml/oethOracles.png index 05161a2e38..da78b2fc88 100644 Binary files a/contracts/docs/plantuml/oethOracles.png and b/contracts/docs/plantuml/oethOracles.png differ diff --git a/contracts/docs/plantuml/oethOracles.puml b/contracts/docs/plantuml/oethOracles.puml index 4747fab1fe..c76fa659d5 100644 --- a/contracts/docs/plantuml/oethOracles.puml +++ b/contracts/docs/plantuml/oethOracles.puml @@ -20,14 +20,22 @@ pairs: \trETH/ETH } -object "FrxEthFraxOracle" as fo <> { -pair: frxETH/ETH +object "OETHOracleUpdater" as oou <> #DeepSkyBlue { +pair: OETH/ETH } -object "FrxEthEthDualOracle" as fdo <> { -pair: frxETH/ETH +object "OETHOracle" as oetho <> #DeepSkyBlue { +pair: OETH/ETH } +' object "frxETHOracleUpdater" as fou <> #DeepSkyBlue { +' pair: frxETH/ETH +' } + +' object "frxETHOracle" as frxo <> #DeepSkyBlue { +' pair: frxETH/ETH +' } + object "External\nAccess\nControlled\nAggregator" as clrETH <> { pair: rETH/ETH } @@ -36,35 +44,78 @@ object "External\nAccess\nControlled\nAggregator" as clstETH <> { pair: stETH/ETH } -object "External\nAccess\nControlled\nAggregator" as cleth <> { -pair: ETH/USD +object "FrxEthFraxOracle" as fefo <> { +pair: frxETH/ETH } -object "External\nAccess\nControlled\nAggregator" as clfrax <> { -pair: FRAX/USD +object "OETH/ETH Pool" as coep <> { +assets: OETH, ETH } -object "frxETH/ETH Pool" as cp <> { -assets: frxETH, ETH -} +' object "frxETH/OETH Pool" as cfop <> { +' assets: frxETH, OETH +' } -object "StaticOracle" as uso <> { -} +' object "frxETH/ETH Pool" as cfep <> { +' assets: frxETH, ETH +' } -object "frxETH/FRAX Pool" as up <> { - assets: frxETH, FRAX -} +' object "frxETH/WETH Pool" as cfwp <> { +' assets: frxETH, WETH +' } + +' object "OETH/ETH Pool" as boep <> { +' assets: OETH, ETH +' } vault ..> router : price(asset) -router ...> clrETH : latestRoundData() -router ...> clstETH : latestRoundData() -router ..> fo : latestRoundData() -fdo .> fo : addRoundData() -fdo ....> cp : price_oracle() -fdo ....> uso : quoteSpecificPoolsWithTimePeriod() -uso .> up : observe() -fdo ..> cleth : latestRoundData() -fdo ..> clfrax : latestRoundData() +router ..> clrETH : latestRoundData() +router ..> clstETH : latestRoundData() +router ..> fefo : latestRoundData() + +vault <.. oou : price() +oetho <.. oou : addPrice() +oou ..> coep : price_oracle() +' oou ...> cfop : price_oracle() +' oou ...> boep : price_oracle() + +' router ..> frxo : latestRoundData() +' fou ..> frxo : addPrice() +' fou ...> cfep : price_oracle() +' fou ...> cfwp : price_oracle() + + +' object "FrxEthFraxOracle" as fo <> { +' pair: frxETH/ETH +' } + +' object "FrxEthEthDualOracle" as fdo <> { +' pair: frxETH/ETH +' } + + +' object "External\nAccess\nControlled\nAggregator" as cleth <> { +' pair: ETH/USD +' } + +' object "External\nAccess\nControlled\nAggregator" as clfrax <> { +' pair: FRAX/USD +' } + +' object "StaticOracle" as uso <> { +' } + +' object "frxETH/FRAX Pool" as up <> { +' assets: frxETH, FRAX +' } + +' router ..> fo : latestRoundData() +' fdo .> fo : addRoundData() +' fdo ....> cfep : price_oracle() +' fdo ....> uso : quoteSpecificPoolsWithTimePeriod() +' uso .> up : observe() +' fdo ..> cleth : latestRoundData() +' fdo ..> clfrax : latestRoundData() @enduml \ No newline at end of file diff --git a/contracts/test/_fixture.js b/contracts/test/_fixture.js index 0fee88ac82..da421fd83a 100644 --- a/contracts/test/_fixture.js +++ b/contracts/test/_fixture.js @@ -148,6 +148,9 @@ const defaultFixture = deployments.createFixture(async () => { isFork ? "OETHOracleRouter" : "OracleRouter" ); + const oethOracleUpdater = await ethers.getContract("OETHOracleUpdater"); + const oethOracle = await ethers.getContract("OETHOracle"); + let usdt, dai, tusd, @@ -517,6 +520,8 @@ const defaultFixture = deployments.createFixture(async () => { compoundStrategy, oracleRouter, oethOracleRouter, + oethOracle, + oethOracleUpdater, // Assets usdt, dai, diff --git a/contracts/test/behaviour/governable.js b/contracts/test/behaviour/governable.js index d6eef036b9..bb36d5009b 100644 --- a/contracts/test/behaviour/governable.js +++ b/contracts/test/behaviour/governable.js @@ -1,5 +1,4 @@ const { expect } = require("chai"); -const { daiUnits } = require("../helpers"); /** * @@ -25,19 +24,6 @@ const shouldBehaveLikeGovernable = (context) => { expect(await strategy.connect(signer).isGovernor()).to.be.false; } }); - - it("Should not allow transfer of arbitrary token by non-Governor", async () => { - const { strategist, anna, dai, strategy } = context(); - - const recoveryAmount = daiUnits("800"); - for (const signer of [strategist, anna]) { - // Naughty signer - await expect( - strategy.connect(signer).transferToken(dai.address, recoveryAmount) - ).to.be.revertedWith("Caller is not the Governor"); - } - }); - it("Should allow governor to transfer governance", async () => { const { governor, anna, strategy } = context(); diff --git a/contracts/test/oracle/oracle-oeth.js b/contracts/test/oracle/oracle-oeth.js new file mode 100644 index 0000000000..5ea9311620 --- /dev/null +++ b/contracts/test/oracle/oracle-oeth.js @@ -0,0 +1,182 @@ +const { expect } = require("chai"); + +const { loadDefaultFixture } = require("../_fixture"); +const { parseUnits } = require("ethers/lib/utils"); +const { impersonateAndFund } = require("../../utils/signers"); +const { shouldBehaveLikeGovernable } = require("../behaviour/governable"); + +/* + * Because the oracle code is so tightly integrated into the vault, + * the actual tests for the core oracle features are just a part of the vault tests. + */ + +const maxVaultPrice = parseUnits("0.995"); + +describe("OETH Oracle", async () => { + let fixture; + beforeEach(async () => { + fixture = await loadDefaultFixture(); + }); + + shouldBehaveLikeGovernable(() => ({ + ...fixture, + strategy: fixture.oethOracle, + })); + + shouldBehaveLikeGovernable(() => ({ + ...fixture, + strategy: fixture.oethOracleUpdater, + })); + + const assetAddPrice = async (prices) => { + const { anna, oethOracle, oethOracleUpdater } = fixture; + const { vaultPrice, marketPrice, expectPrice, expectedVaultPrice } = prices; + + const vault = await ethers.getContract("MockVault"); + await vault.setFloorPrice(vaultPrice); + + const curvePool = await ethers.getContract("MockCurveOethEthPool"); + await curvePool.setOraclePrice(marketPrice); + + const tx = await oethOracleUpdater + .connect(anna) + .addPrice(oethOracle.address); + + await expect(tx) + .to.emit(oethOracleUpdater, "AddPrice") + .withArgs( + oethOracle.address, + expectPrice, + expectedVaultPrice, + marketPrice + ); + + const roundData = await oethOracle.latestRoundData(); + expect(roundData.answer).to.eq(expectPrice); + }; + + describe("Should add price when", () => { + describe("vault > 0.995", () => { + const vaultPrice = parseUnits("0.9956"); + it("curve > 1", async () => { + const marketPrice = parseUnits("1.001"); + + await assetAddPrice({ + marketPrice, + vaultPrice, + expectPrice: parseUnits("1"), + expectedVaultPrice: maxVaultPrice, + }); + }); + it("curve < 1", async () => { + const marketPrice = parseUnits("0.998"); + + await assetAddPrice({ + marketPrice, + vaultPrice, + expectPrice: marketPrice, + expectedVaultPrice: maxVaultPrice, + }); + }); + it("curve < 0.995", async () => { + const marketPrice = parseUnits("0.992"); + + await assetAddPrice({ + marketPrice, + vaultPrice, + expectPrice: vaultPrice, + expectedVaultPrice: vaultPrice, + }); + }); + }); + describe("vault < 0.995", () => { + const vaultPrice = parseUnits("0.9937"); + it("curve > 1", async () => { + const marketPrice = parseUnits("1.001"); + + await assetAddPrice({ + marketPrice, + vaultPrice, + expectPrice: parseUnits("1"), + expectedVaultPrice: maxVaultPrice, + }); + }); + it("curve > 0.995", async () => { + const marketPrice = parseUnits("0.9998"); + + await assetAddPrice({ + marketPrice, + vaultPrice, + expectPrice: marketPrice, + expectedVaultPrice: maxVaultPrice, + }); + }); + it("curve < 0.995 and curve > vault", async () => { + const marketPrice = parseUnits("0.9949"); + + await assetAddPrice({ + marketPrice, + vaultPrice, + expectPrice: marketPrice, + expectedVaultPrice: vaultPrice, + }); + }); + it("curve < 0.995 and curve < vault", async () => { + const marketPrice = parseUnits("0.983"); + + await assetAddPrice({ + marketPrice, + vaultPrice, + expectPrice: vaultPrice, + expectedVaultPrice: vaultPrice, + }); + }); + }); + }); + it("Should not add zero price", async () => { + const { oethOracle, oethOracleUpdater } = fixture; + + const updaterSigner = await impersonateAndFund(oethOracleUpdater.address); + await expect(oethOracle.connect(updaterSigner).addPrice(0)).to.revertedWith( + "NoPriceData" + ); + }); + it("Should not add price by non-updater", async () => { + const { anna, oethOracle } = fixture; + + await expect(oethOracle.connect(anna).addPrice(0)).to.revertedWith( + "OnlyOracleUpdater" + ); + }); + describe("when multiple txs in a block", () => { + beforeEach(async () => { + await ethers.provider.send("evm_setAutomine", [false]); + }); + afterEach(async () => { + await ethers.provider.send("evm_setAutomine", [true]); + }); + it("Should not add a second price in the same block", async () => { + const { oethOracle, oethOracleUpdater } = fixture; + + const updaterSigner = await impersonateAndFund(oethOracleUpdater.address); + // Add a price so there is at leave 1 round before two are added + await oethOracle.connect(updaterSigner).addPrice(parseUnits("0.999")); + + // Mine a block + await ethers.provider.send("evm_mine", []); + + // First add price in block + await oethOracle.connect(updaterSigner).addPrice(parseUnits("0.998")); + // Second add price in block + const tx2 = await oethOracle + .connect(updaterSigner) + .addPrice(parseUnits("0.997")); + + // Mine the two transactions in a new block + await ethers.provider.send("evm_mine", []); + + // ideally this would be revertedWith "AddPriceSameBlock" but it's catching it + await expect(tx2).to.reverted; + }); + }); +}); diff --git a/contracts/test/oracle/oracle.fork-test.js b/contracts/test/oracle/oracle.fork-test.js index 52b75fe1ba..c9e1603067 100644 --- a/contracts/test/oracle/oracle.fork-test.js +++ b/contracts/test/oracle/oracle.fork-test.js @@ -1,21 +1,27 @@ const { expect } = require("chai"); -const { parseUnits } = require("ethers/lib/utils"); +const { parseUnits, formatUnits } = require("ethers/lib/utils"); const { loadDefaultFixture } = require("../_fixture"); const { isCI } = require("../helpers"); +const { impersonateAndFund } = require("../../utils/signers.js"); +const addresses = require("../../utils/addresses"); -describe("ForkTest: Oracle Routers", function () { +const log = require("../../utils/logger")("test:fork:oracles"); + +describe("ForkTest: Oracles", function () { this.timeout(0); // Retry up to 3 times on CI this.retries(isCI ? 3 : 0); let fixture; + beforeEach(async () => { + fixture = await loadDefaultFixture(); + }); describe("OETH Oracle Router", () => { let oethOracleRouter; beforeEach(async () => { - fixture = await loadDefaultFixture(); oethOracleRouter = await ethers.getContract("OETHOracleRouter"); }); it("should get rETH price", async () => { @@ -54,4 +60,126 @@ describe("ForkTest: Oracle Routers", function () { } }); }); + describe("OETH Oracle", () => { + it("Should be initialized", async () => { + const { oethOracle, oethOracleUpdater, oethVault } = fixture; + + expect(await oethOracleUpdater.governor()).to.eq( + addresses.mainnet.Timelock + ); + expect(await oethOracleUpdater.MAX_VAULT_PRICE()).to.eq( + parseUnits("0.995") + ); + expect(await oethOracleUpdater.curvePool()).to.eq( + addresses.mainnet.CurveOETHMetaPool + ); + expect(await oethOracleUpdater.vault()).to.eq(oethVault.address); + + expect(await oethOracle.decimals()).to.eq(18); + expect(await oethOracle.version()).to.eq(1); + expect(await oethOracle.oracleUpdater()).to.eq(oethOracleUpdater.address); + expect(await oethOracle.governor()).to.eq(addresses.mainnet.Timelock); + }); + it("Should get price from OETH Oracle Updater", async () => { + const { oethOracleUpdater } = fixture; + + const prices = await oethOracleUpdater.getPrices(); + + expect(prices.answer).to.be.gte(parseUnits("0.99")); + expect(prices.answer).to.be.lt(parseUnits("1")); + expect(prices.vaultPrice).to.be.lte(parseUnits("0.995")); + expect(prices.marketPrice).to.be.gte(parseUnits("0.99")); + expect(prices.marketPrice).to.be.lt(parseUnits("1.01")); + }); + it("Should add new OETH Oracle price", async () => { + const { oethOracle, oethOracleUpdater } = fixture; + + const tx = await oethOracleUpdater.addPrice(oethOracle.address); + + await expect(tx) + .to.emit(oethOracleUpdater, "AddPrice") + .withNamedArgs({ oracle: oethOracle.address }); + + const data = await oethOracle.latestRoundData(); + log(`OETH price: ${formatUnits(data.answer, 18)}`); + + expect(data.answer).to.be.gte(parseUnits("0.99")); + expect(data.answer).to.be.lte(parseUnits("1.0001")); + expect(data.roundId).to.be.eq(0); + }); + it("Should get gas usage of latestRoundData", async () => { + const { josh, oethOracle, oethOracleUpdater } = fixture; + + await oethOracleUpdater.addPrice(oethOracle.address); + + // This uses a transaction to call a view function so the gas usage can be reported. + const tx2 = await oethOracle + .connect(josh) + .populateTransaction.latestRoundData(); + await josh.sendTransaction(tx2); + }); + it("Should add OETH Oracle price twice", async () => { + const { oethOracle, oethOracleUpdater } = fixture; + + await oethOracleUpdater.addPrice(oethOracle.address); + await oethOracleUpdater.addPrice(oethOracle.address); + + const data = await oethOracle.latestRoundData(); + log(`Oracle price: ${formatUnits(data.answer, 18)}`); + log(`Oracle round: ${data.roundId}`); + log(`Oracle answeredInRound: ${data.answeredInRound}`); + expect(data.roundId).to.be.eq(1); + }); + it("Should not add OETH Oracle price by anyone", async () => { + const { oethOracle, anna, strategist, governor, harvester, oethVault } = + fixture; + + const harvesterSigner = await impersonateAndFund(harvester.address); + const oethVaultSigner = await impersonateAndFund(oethVault.address); + + for (const signer of [ + anna, + strategist, + governor, + harvesterSigner, + oethVaultSigner, + ]) { + await expect( + oethOracle.connect(signer).addPrice(parseUnits("999", 15)) + ).to.revertedWith("OnlyOracleUpdater"); + } + }); + it("Should update the oracle updater by the governance timelock", async () => { + const { oethOracle, anna, timelock } = fixture; + + const tx = await oethOracle + .connect(timelock) + .setOracleUpdater(anna.address); + + await expect(tx) + .to.emit(oethOracle, "SetOracleUpdater") + .withArgs(anna.address); + + expect(await oethOracle.oracleUpdater()).to.eq(anna.address); + }); + it("Should not update the oracle updater by the governance timelock", async () => { + const { oethOracle, anna, strategist, governor, harvester, oethVault } = + fixture; + + const harvesterSigner = await impersonateAndFund(harvester.address); + const oethVaultSigner = await impersonateAndFund(oethVault.address); + + for (const signer of [ + anna, + strategist, + governor, + harvesterSigner, + oethVaultSigner, + ]) { + await expect( + oethOracle.connect(signer).setOracleUpdater(anna.address) + ).to.revertedWith("Caller is not the Governor"); + } + }); + }); }); diff --git a/contracts/test/oracle/oracle.js b/contracts/test/oracle/oracle.js index 943f5b3723..cc0969aa4a 100644 --- a/contracts/test/oracle/oracle.js +++ b/contracts/test/oracle/oracle.js @@ -4,11 +4,11 @@ const { loadDefaultFixture } = require("../_fixture"); const { ousdUnits, setOracleTokenPriceUsd } = require("../helpers"); /* - * Because the oracle code is so tightly intergrated into the vault, + * Because the oracle code is so tightly integrated into the vault, * the actual tests for the core oracle features are just a part of the vault tests. */ -describe("Oracle", async () => { +describe("Vault Oracle", async () => { let fixture; beforeEach(async () => { fixture = await loadDefaultFixture(); diff --git a/contracts/test/vault/oeth-vault.fork-test.js b/contracts/test/vault/oeth-vault.fork-test.js index 7ea1b103d6..97c3e6d523 100644 --- a/contracts/test/vault/oeth-vault.fork-test.js +++ b/contracts/test/vault/oeth-vault.fork-test.js @@ -2,6 +2,7 @@ const { expect } = require("chai"); const { formatUnits, parseUnits } = require("ethers/lib/utils"); const addresses = require("../../utils/addresses"); +const { resolveAsset } = require("../../utils/assets"); const { createFixtureLoader, oethDefaultFixture } = require("../_fixture"); const { isCI } = require("../helpers"); const { impersonateAndFund } = require("../../utils/signers"); @@ -17,42 +18,112 @@ describe("ForkTest: OETH Vault", function () { this.retries(isCI ? 3 : 0); let fixture; + const loadFixture = createFixtureLoader(oethDefaultFixture); + beforeEach(async () => { + fixture = await loadFixture(); + }); - describe("post deployment", () => { - const loadFixture = createFixtureLoader(oethDefaultFixture); - beforeEach(async () => { - fixture = await loadFixture(); + describe("OETH Vault", () => { + describe("post deployment", () => { + const loadFixture = createFixtureLoader(oethDefaultFixture); + beforeEach(async () => { + fixture = await loadFixture(); + }); + it("Should have the correct governor address set", async () => { + const { + oethVault, + oethDripper, + convexEthMetaStrategy, + fraxEthStrategy, + oeth, + woeth, + oethHarvester, + } = fixture; + + const oethContracts = [ + oethVault, + oethDripper, + convexEthMetaStrategy, + fraxEthStrategy, + oeth, + woeth, + oethHarvester, + ]; + + for (let i = 0; i < oethContracts.length; i++) { + expect(await oethContracts[i].governor()).to.equal( + addresses.mainnet.Timelock + ); + } + }); }); + describe("Oracle prices", () => { + const assetPriceRanges = { + WETH: { + min: parseUnits("1"), + max: parseUnits("1"), + }, + stETH: { + min: parseUnits("0.99"), + max: parseUnits("1"), + }, + rETH: { + min: parseUnits("1.08"), + max: parseUnits("1.1"), + }, + frxETH: { + min: parseUnits("0.985"), + max: parseUnits("1"), + }, + }; + for (const [symbol, { min, max }] of Object.entries(assetPriceRanges)) { + it(`Should return a price for minting with ${symbol}`, async () => { + const { oethVault, oethOracleRouter } = fixture; + + const asset = await resolveAsset(symbol); + + const oraclePrice = await oethOracleRouter.price(asset.address); + if (oraclePrice.gt(parseUnits("0.998"))) { + const price = await oethVault.priceUnitMint(asset.address); + + log(`Price for minting with ${symbol}: ${formatUnits(price, 18)}`); + + expect(price).to.be.gte(min); + expect(price).to.be.lte(max); + } else { + const tx = oethVault.priceUnitMint(asset.address); + await expect(tx).to.revertedWith("Asset price below peg"); + } + }); + it(`Should return a price for redeeming with ${symbol}`, async () => { + const { oethVault } = fixture; - it("Should have the correct governor address set", async () => { - const { - oethVault, - oethDripper, - convexEthMetaStrategy, - fraxEthStrategy, - oeth, - woeth, - oethHarvester, - } = fixture; - - const oethContracts = [ - oethVault, - oethDripper, - convexEthMetaStrategy, - fraxEthStrategy, - oeth, - woeth, - oethHarvester, - ]; - - for (let i = 0; i < oethContracts.length; i++) { - expect(await oethContracts[i].governor()).to.equal( - addresses.mainnet.Timelock - ); + const asset = await resolveAsset(symbol); + const price = await oethVault.priceUnitRedeem(asset.address); + + log(`Price for redeeming with ${symbol}: ${formatUnits(price, 18)}`); + + expect(price).to.be.gte(min); + expect(price).to.be.lte(max); + }); } + it("Should return OETH floor price", async () => { + const { oethVault, josh } = fixture; + + const price = await oethVault.floorPrice(); + log(`OETH price: ${formatUnits(price, 18)}`); + + expect(price).to.be.gte(parseUnits("0.99")); + expect(price).to.be.lte(parseUnits("1")); + + // This uses a transaction to call a view function so the gas usage can be reported. + const tx = await oethVault + .connect(josh) + .populateTransaction.floorPrice(); + await josh.sendTransaction(tx); + }); }); }); - describe("user operations", () => { let oethWhaleSigner; const loadFixture = createFixtureLoader(oethDefaultFixture);