diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7a629bde1..a15a3f72f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -335,3 +335,20 @@ jobs: - run: yarn test:registries env: NODE_OPTIONS: '--max-old-space-size=32768' + + oracles-tests: + name: 'Oracle Factory Tests' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 18.x + cache: 'yarn' + - run: yarn install --immutable + - run: yarn test:oracles + env: + NODE_OPTIONS: '--max-old-space-size=32768' + TS_NODE_SKIP_IGNORE: true + MAINNET_RPC_URL: https://eth-mainnet.g.alchemy.com/v2/${{ secrets.ALCHEMY_MAINNET_KEY }} + FORK_NETWORK: mainnet diff --git a/common/configuration.ts b/common/configuration.ts index afe7ed4ca..8fff48bd4 100644 --- a/common/configuration.ts +++ b/common/configuration.ts @@ -17,7 +17,6 @@ export interface ITokens { sUSD?: string FRAX?: string MIM?: string - eUSD?: string crvUSD?: string aDAI?: string aUSDC?: string @@ -61,7 +60,6 @@ export interface ITokens { CVX?: string SDT?: string USDCPLUS?: string - ETHPLUS?: string ankrETH?: string frxETH?: string sfrxETH?: string @@ -135,6 +133,12 @@ export interface ITokens { // Sky USDS?: string sUSDS?: string + + // RTokens + eUSD?: string + ETHPLUS?: string + bsdETH?: string + KNOX?: string } export type ITokensKeys = Array @@ -560,6 +564,7 @@ export const networkConfig: { [key: string]: INetworkConfig } = { cbBTC: '0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf', WELL: '0xA88594D404727625A9437C3f886C7643872296AE', DEGEN: '0x4ed4E862860beD51a9570b96d89aF5E1B0Efefed', + bsdETH: '0xcb327b99ff831bf8223cced12b1338ff3aa322ff', }, chainlinkFeeds: { DAI: '0x591e79239a7d679378ec8c847e5038150364c78f', // 0.3%, 24hr @@ -616,6 +621,7 @@ export const networkConfig: { [key: string]: INetworkConfig } = { saArbUSDT: '', // TODO our wrapper. remove from deployment script after placing here USDM: '0x59d9356e565ab3a36dd77763fc0d87feaf85508c', wUSDM: '0x57f5e098cad7a3d1eed53991d4d66c45c9af7812', + KNOX: '0x0bbf664d46becc28593368c97236faa0fb397595', }, chainlinkFeeds: { ARB: '0xb2A824043730FE05F3DA2efaFa1CBbe83fa548D6', diff --git a/contracts/facade/factories/CurveOracleFactory.sol b/contracts/facade/factories/CurveOracleFactory.sol deleted file mode 100644 index 1fd5193c0..000000000 --- a/contracts/facade/factories/CurveOracleFactory.sol +++ /dev/null @@ -1,48 +0,0 @@ -// SPDX-License-Identifier: BlueOak-1.0.0 -pragma solidity 0.8.19; - -import { divuu } from "../../libraries/Fixed.sol"; - -// weird circular inheritance preventing us from using proper IRToken, not worth figuring out -interface IMinimalRToken { - function basketsNeeded() external view returns (uint192); - - function totalSupply() external view returns (uint256); -} - -contract CurveOracle { - address public immutable rToken; - - constructor(address _rToken) { - rToken = _rToken; - } - - function exchangeRate() external view returns (uint256) { - return - divuu( - uint256(IMinimalRToken(rToken).basketsNeeded()), - IMinimalRToken(rToken).totalSupply() - ); - } -} - -/** - * @title CurveOracleFactory - * @notice An immutable factory for Curve oracles - */ -contract CurveOracleFactory { - error CurveOracleAlreadyDeployed(); - - event CurveOracleDeployed(address indexed rToken, address indexed curveOracle); - - mapping(address => CurveOracle) public curveOracles; - - function deployCurveOracle(address rToken) external returns (address) { - if (address(curveOracles[rToken]) != address(0)) revert CurveOracleAlreadyDeployed(); - CurveOracle curveOracle = new CurveOracle(rToken); - curveOracle.exchangeRate(); // ensure it works - curveOracles[rToken] = curveOracle; - emit CurveOracleDeployed(address(rToken), address(curveOracle)); - return address(curveOracle); - } -} diff --git a/contracts/facade/oracles/ExchangeRateOracle.sol b/contracts/facade/oracles/ExchangeRateOracle.sol new file mode 100644 index 000000000..185678046 --- /dev/null +++ b/contracts/facade/oracles/ExchangeRateOracle.sol @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.19; + +import { FIX_ONE, divuu } from "../../libraries/Fixed.sol"; +import { IExchangeRateOracle } from "./IExchangeRateOracle.sol"; +import { IAsset } from "../../interfaces/IAsset.sol"; +import { IRToken } from "../../interfaces/IRToken.sol"; + +/** + * @title ExchangeRateOracle + * @notice An immutable Exchange Rate Oracle for an RToken (eg: ETH+/ETH) + * + * ::Notice:: + * The oracle does not call refresh() on the RToken or the underlying assets, so the price can be + * stale. This is generally not an issue for active RTokens as they are refreshed often by other + * protocol operations, however do keep this in mind when using this for low-activity RTokens. + * + * If you need the freshest possible price, consider using RTokenAsset.latestPrice() instead, + * however it is a mutator function instead of a view-only function hence not compatible with + * Chainlink style interfaces. + * + * ::Warning:: In the event of an RToken taking a loss in excess of the StRSR overcollateralization + * layer, the devaluation will not be reflected until the RToken is done trading. This causes + * the exchange rate to be too high during the rebalancing phase. If the exchange rate is relied + * upon naively, then it could be misleading. + * + * As a consumer of this oracle, you may want to guard against this case by monitoring: + * `basketHandler.status() == 0 && basketHandler.fullyCollateralized()` + * where `basketHandler` can be safely cached from `rToken.main().basketHandler()`. + * + * However, note that `fullyCollateralized()` is extremely gas-costly. We recommend executing + * the function off-chain. `status()` is cheap and more reasonable to be called on-chain. + */ +contract ExchangeRateOracle is IExchangeRateOracle { + error ZeroAddress(); + + IRToken public immutable rToken; + uint256 public constant override version = 1; + + constructor(address _rToken) { + if (_rToken == address(0)) { + revert ZeroAddress(); + } + + rToken = IRToken(_rToken); + } + + function decimals() external pure override returns (uint8) { + return 18; + } + + function description() external view override returns (string memory) { + return string.concat(rToken.symbol(), " Exchange Rate Oracle"); + } + + function exchangeRate() public view returns (uint256) { + uint256 supply = IRToken(rToken).totalSupply(); + if (supply == 0) { + return FIX_ONE; + } + + return divuu(uint256(IRToken(rToken).basketsNeeded()), supply); + } + + /** + * @dev Ignores roundId completely, prefer using latestRoundData() + */ + function getRoundData(uint80) + external + view + override + returns ( + uint80 roundId, + int256 answer, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ) + { + return this.latestRoundData(); + } + + function latestRoundData() + external + view + override + returns ( + uint80 roundId, + int256 answer, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ) + { + return ( + uint80(block.number), + int256(exchangeRate()), + block.timestamp - 1, + block.timestamp, + uint80(block.number) + ); + } +} diff --git a/contracts/facade/oracles/IExchangeRateOracle.sol b/contracts/facade/oracles/IExchangeRateOracle.sol new file mode 100644 index 000000000..1dbbb638b --- /dev/null +++ b/contracts/facade/oracles/IExchangeRateOracle.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.19; + +import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol"; + +interface IExchangeRateOracle is AggregatorV3Interface { + function exchangeRate() external view returns (uint256); +} diff --git a/contracts/facade/oracles/OracleFactory.sol b/contracts/facade/oracles/OracleFactory.sol new file mode 100644 index 000000000..af727e734 --- /dev/null +++ b/contracts/facade/oracles/OracleFactory.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.19; + +import { ExchangeRateOracle } from "./ExchangeRateOracle.sol"; +import { ReferenceRateOracle } from "./ReferenceRateOracle.sol"; + +/** + * @title OracleFactory + * @notice An immutable factory for RToken Exchange Rate Oracles + */ +contract OracleFactory { + struct Oracles { + ExchangeRateOracle exchangeRateOracle; + ReferenceRateOracle referenceRateOracle; + } + + error OracleAlreadyDeployed(address rToken); + + event OracleDeployed(address indexed rToken, Oracles oracles); + + // {rtoken} => {oracle} + mapping(address => Oracles) public oracleRegistry; + + /// @param rToken The RToken to deploy oracles for + function deployOracle(address rToken) external returns (Oracles memory oracles) { + if ( + rToken == address(0) || address(oracleRegistry[rToken].exchangeRateOracle) != address(0) + ) { + revert OracleAlreadyDeployed(rToken); + } + + ExchangeRateOracle eOracle = new ExchangeRateOracle(rToken); + ReferenceRateOracle rOracle = new ReferenceRateOracle(rToken); + + oracles = Oracles({ exchangeRateOracle: eOracle, referenceRateOracle: rOracle }); + + eOracle.latestRoundData(); + rOracle.latestRoundData(); + + oracleRegistry[rToken] = oracles; + emit OracleDeployed(rToken, oracles); + } +} diff --git a/contracts/facade/oracles/ReferenceRateOracle.sol b/contracts/facade/oracles/ReferenceRateOracle.sol new file mode 100644 index 000000000..a32dc47f8 --- /dev/null +++ b/contracts/facade/oracles/ReferenceRateOracle.sol @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.19; + +import { FIX_MAX } from "../../libraries/Fixed.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IExchangeRateOracle } from "./IExchangeRateOracle.sol"; +import { IRToken } from "../../interfaces/IRToken.sol"; +import { IAssetRegistry } from "../../interfaces/IAssetRegistry.sol"; +import { IAsset } from "../../interfaces/IAsset.sol"; + +/** + * @title ReferenceRateOracle + * @notice An immutable Reference Rate Oracle for an RToken (eg: ETH+/USD) + * + * Composes oracles used by the protocol internally to calculate the reference price of an RToken, + * in UoA terms, usually USD. + * + * ::Notice:: + * The oracle does not call refresh() on the RToken or the underlying assets, so the price can be + * stale. This is generally not an issue for active RTokens as they are refreshed often by other + * protocol operations, however do keep this in mind when using this for low-activity RTokens. + * + * If you need the freshest possible price, consider using RTokenAsset.latestPrice() instead, + * however it is a mutator function instead of a view-only function hence not compatible with + * Chainlink style interfaces, and additionally can revert. + * + * As a consumer of this oracle, you may want to guard against this case by monitoring: + * `basketHandler.status() == 0 && basketHandler.fullyCollateralized()` + * where `basketHandler` can be safely cached from `rToken.main().basketHandler()`. + * + * However, note that `fullyCollateralized()` is extremely gas-costly. We recommend executing + * the function off-chain. `status()` is cheap and more reasonable to be called on-chain. + */ +contract ReferenceRateOracle is IExchangeRateOracle { + error ZeroAddress(); + + uint256 public constant override version = 1; + + IRToken public immutable rToken; + IAssetRegistry public immutable assetRegistry; + + constructor(address _rToken) { + if (_rToken == address(0)) { + revert ZeroAddress(); + } + + rToken = IRToken(_rToken); + assetRegistry = IRToken(_rToken).main().assetRegistry(); + } + + function decimals() external view override returns (uint8) { + return rToken.decimals(); + } + + function description() external view override returns (string memory) { + return string.concat(rToken.symbol(), " Reference Rate Oracle"); + } + + /** + * @dev Can revert + */ + function exchangeRate() public view returns (uint256) { + // cannot cache RTokenAsset + IAsset rTokenAsset = assetRegistry.toAsset(IERC20(address(rToken))); + + (uint256 lower, uint256 upper) = rTokenAsset.price(); + require(lower != 0 && upper < FIX_MAX, "invalid price"); + + /** + * In >=4.2.0 (not yet deployed) there is a feature called the "issuance premium", + * which if enabled, will cause the high price to remain relatively static, + * even when an RToken collateral is under peg. + * + * This is because the RToken increases issuance costs to account for the de-peg, + * which increases the size of the price band the RToken can trade on in secondary markets. + * + * Using the average of the issuance redemption cost in this case can result in a quantity + * biased upwards. + * + * If you need the *lowest* possible price the RToken can have, do not use this approach. + * Instead, use the `lower` price directly. Include our check above that `low > 0`. + */ + + return (lower + upper) / 2; + } + + /** + * @dev Ignores roundId completely, prefer using latestRoundData() + * Can revert + */ + function getRoundData(uint80) + external + view + override + returns ( + uint80 roundId, + int256 answer, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ) + { + return this.latestRoundData(); + } + + /** + * @dev Can revert + */ + function latestRoundData() + external + view + override + returns ( + uint80 roundId, + int256 answer, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ) + { + return ( + uint80(block.number), + int256(exchangeRate()), + block.timestamp - 1, + block.timestamp, + uint80(block.number) + ); + } +} diff --git a/contracts/interfaces/IRToken.sol b/contracts/interfaces/IRToken.sol index 2c4c96598..7a6049e02 100644 --- a/contracts/interfaces/IRToken.sol +++ b/contracts/interfaces/IRToken.sol @@ -5,7 +5,6 @@ import "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/IERC20Metadat // solhint-disable-next-line max-line-length import "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/draft-IERC20PermitUpgradeable.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import "../libraries/Fixed.sol"; import "../libraries/Throttle.sol"; import "./IComponent.sol"; diff --git a/package.json b/package.json index 5853f2df1..dd6416af8 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "test:fast": "bash tools/fast-test.sh", "test:p0": "PROTO_IMPL=0 hardhat test test/*.test.ts", "test:p1": "PROTO_IMPL=1 hardhat test test/*.test.ts ", + "test:oracles": "FORK=1 hardhat test test/oracles/*.test.ts", "test:registries": "PROTO_IMPL=1 hardhat test test/registries/*.test.ts", "test:plugins": "hardhat test test/{libraries,plugins}/*.test.ts", "test:plugins:integration": "PROTO_IMPL=1 FORK=1 hardhat test test/plugins/individual-collateral/**/*.test.ts", diff --git a/tasks/deployment/create-curve-oracle-factory.ts b/tasks/deployment/create-curve-oracle-factory.ts deleted file mode 100644 index 04e547ce3..000000000 --- a/tasks/deployment/create-curve-oracle-factory.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { getChainId } from '../../common/blockchain-utils' -import { task, types } from 'hardhat/config' -import { CurveOracleFactory } from '../../typechain' - -task('create-curve-oracle-factory', 'Deploys a CurveOracleFactory') - .addOptionalParam('noOutput', 'Suppress output', false, types.boolean) - .setAction(async (params, hre) => { - const [wallet] = await hre.ethers.getSigners() - - const chainId = await getChainId(hre) - - if (!params.noOutput) { - console.log( - `Deploying CurveOracleFactory to ${hre.network.name} (${chainId}) with burner account ${wallet.address}` - ) - } - - const CurveOracleFactoryFactory = await hre.ethers.getContractFactory('CurveOracleFactory') - const curveOracleFactory = ( - await CurveOracleFactoryFactory.connect(wallet).deploy() - ) - await curveOracleFactory.deployed() - - if (!params.noOutput) { - console.log( - `Deployed CurveOracleFactory to ${hre.network.name} (${chainId}): ${curveOracleFactory.address}` - ) - } - - // Uncomment to verify - if (!params.noOutput) { - console.log('sleeping 30s') - } - - // Sleep to ensure API is in sync with chain - await new Promise((r) => setTimeout(r, 30000)) // 30s - - if (!params.noOutput) { - console.log('verifying') - } - - /** ******************** Verify CurveOracleFactory ****************************************/ - console.time('Verifying CurveOracleFactory') - await hre.run('verify:verify', { - address: curveOracleFactory.address, - constructorArguments: [], - contract: 'contracts/facade/factories/CurveOracleFactory.sol:CurveOracleFactory', - }) - console.timeEnd('Verifying CurveOracleFactory') - - if (!params.noOutput) { - console.log('verified') - } - - return { curveOracleFactory: curveOracleFactory.address } - }) diff --git a/tasks/deployment/create-oracle-factory.ts b/tasks/deployment/create-oracle-factory.ts new file mode 100644 index 000000000..933c640df --- /dev/null +++ b/tasks/deployment/create-oracle-factory.ts @@ -0,0 +1,88 @@ +import { getChainId } from '../../common/blockchain-utils' +import { task, types } from 'hardhat/config' +import { OracleFactory } from '../../typechain' +import { networkConfig } from '../../common/configuration' + +export const getRTokenAddr = (chainId: string): string => { + if (chainId == '1' || chainId == '31337') { + return networkConfig[chainId].tokens.ETHPLUS! + } + if (chainId == '8453') { + return networkConfig[chainId].tokens.bsdETH! + } + if (chainId == '42161') { + return networkConfig[chainId].tokens.KNOX! + } + throw new Error(`invalid chainId: ${chainId}`) +} + +task('create-oracle-factory', 'Deploys an OracleFactory') + .addOptionalParam('noOutput', 'Suppress output', false, types.boolean) + .setAction(async (params, hre) => { + const [wallet] = await hre.ethers.getSigners() + + const chainId = await getChainId(hre) + + if (!params.noOutput) { + console.log( + `Deploying OracleFactory to ${hre.network.name} (${chainId}) with burner account ${wallet.address}` + ) + } + + const ExchangeRateFactoryFactory = await hre.ethers.getContractFactory('OracleFactory') + const oracleFactory = await ExchangeRateFactoryFactory.connect(wallet).deploy() + await oracleFactory.deployed() + + if (!params.noOutput) { + console.log( + `Deployed OracleFactory to ${hre.network.name} (${chainId}): ${oracleFactory.address}` + ) + console.log( + `Deploying dummy ExchangeRateOracle to ${hre.network.name} (${chainId}): ${oracleFactory.address}` + ) + } + + const rTokenAddr = getRTokenAddr(chainId) + + const addr = await oracleFactory.callStatic.deployOracle(rTokenAddr) + await (await oracleFactory.deployOracle(rTokenAddr)).wait() + + if (!params.noOutput) { + console.log(`Deployed dummy ExchangeRateOracle to ${hre.network.name} (${chainId}): ${addr}`) + } + + // Uncomment to verify + if (!params.noOutput) { + console.log('sleeping 10s') + } + + // Sleep to ensure API is in sync with chain + await new Promise((r) => setTimeout(r, 10000)) // 10s + + if (!params.noOutput) { + console.log('verifying') + } + + /** ******************** Verify OracleFactory ****************************************/ + console.time('Verifying OracleFactory') + await hre.run('verify:verify', { + address: oracleFactory.address, + constructorArguments: [], + contract: 'contracts/facade/oracles/OracleFactory.sol:OracleFactory', + }) + console.timeEnd('Verifying OracleFactory') + + console.time('Verifying ExchangeRateOracle') + await hre.run('verify:verify', { + address: addr, + constructorArguments: [rTokenAddr], + contract: 'contracts/facade/oracles/ExchangeRateOracle.sol:ExchangeRateOracle', + }) + console.timeEnd('Verifying ExchangeRateOracle') + + if (!params.noOutput) { + console.log('verified') + } + + return { oracleFactory: oracleFactory.address } + }) diff --git a/tasks/index.ts b/tasks/index.ts index c4c0e13c4..e6ef8f700 100644 --- a/tasks/index.ts +++ b/tasks/index.ts @@ -16,7 +16,7 @@ import './deployment/mock/deploy-mock-aave' import './deployment/mock/deploy-mock-wbtc' import './deployment/deploy-easyauction' import './deployment/create-deployer-registry' -import './deployment/create-curve-oracle-factory' +import './deployment/create-oracle-factory' import './deployment/deploy-facade-monitor' import './deployment/empty-wallet' import './deployment/cancel-tx' diff --git a/test/oracles/OracleFactory.test.ts b/test/oracles/OracleFactory.test.ts new file mode 100644 index 000000000..55349bd08 --- /dev/null +++ b/test/oracles/OracleFactory.test.ts @@ -0,0 +1,65 @@ +import hre, { ethers } from 'hardhat' +import { expect } from 'chai' +import { bn } from '#/common/numbers' +import { useEnv } from '#/utils/env' +import { resetFork } from '#/utils/chain' +import { advanceTime } from '#/utils/time' +import { getChainId } from '#/common/blockchain-utils' +import { getRTokenAddr } from '../../tasks/deployment/create-oracle-factory' +import { ExchangeRateOracle, ReferenceRateOracle } from '../../typechain-types' + +const describeFork = useEnv('FORK') ? describe : describe.skip + +describeFork('OracleFactory', () => { + let exchangeRateOracle: ExchangeRateOracle + let referenceRateOracle: ReferenceRateOracle + + beforeEach(async () => { + // Mainnet Fork only + await resetFork(hre, 23485273) + + const OracleFactory = await ethers.getContractFactory('OracleFactory') + const oracleFactory = await OracleFactory.deploy() + + const chainId = await getChainId(hre) + const rTokenAddr = getRTokenAddr(chainId) + + await oracleFactory.deployOracle(rTokenAddr) + const oracles = await oracleFactory.oracleRegistry(rTokenAddr) + + exchangeRateOracle = await ethers.getContractAt( + 'ExchangeRateOracle', + oracles.exchangeRateOracle + ) + + referenceRateOracle = await ethers.getContractAt( + 'ReferenceRateOracle', + oracles.referenceRateOracle + ) + }) + + describe('ExchangeRateOracle - ETH+ - Mainnet', () => { + it('should return exchange rate', async () => { + const { answer } = await exchangeRateOracle.latestRoundData() + expect(answer).to.be.eq(bn('1055859932301199515')) + }) + + it('should continue to return exchange rate even after oracles expired', async () => { + await advanceTime(hre, 604800) + const { answer } = await exchangeRateOracle.latestRoundData() + expect(answer).to.be.eq(bn('1055859932301199515')) + }) + }) + + describe('ReferenceRateOracle - ETH+ - Mainnet', () => { + it('should return reference rate', async () => { + const { answer } = await referenceRateOracle.latestRoundData() + expect(answer).to.be.eq(bn('4561790902935136141190')) + }) + + it('should revert after oracles expired', async () => { + await advanceTime(hre, 604800) + await expect(referenceRateOracle.latestRoundData()).to.be.revertedWith('invalid price') + }) + }) +})