From d7e68b0584391b31155359c34f17464725409671 Mon Sep 17 00:00:00 2001 From: Taylor Brent Date: Tue, 24 Sep 2024 18:02:45 -0400 Subject: [PATCH 01/17] tweak to also support chainlink interface --- .../facade/factories/CurveOracleFactory.sol | 48 ----------- .../factories/ExchangeRateOracleFactory.sol | 79 +++++++++++++++++++ .../deployment/create-curve-oracle-factory.ts | 56 ------------- .../create-exchange-rate-oracle-factory.ts | 78 ++++++++++++++++++ tasks/index.ts | 2 +- 5 files changed, 158 insertions(+), 105 deletions(-) delete mode 100644 contracts/facade/factories/CurveOracleFactory.sol create mode 100644 contracts/facade/factories/ExchangeRateOracleFactory.sol delete mode 100644 tasks/deployment/create-curve-oracle-factory.ts create mode 100644 tasks/deployment/create-exchange-rate-oracle-factory.ts 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/factories/ExchangeRateOracleFactory.sol b/contracts/facade/factories/ExchangeRateOracleFactory.sol new file mode 100644 index 000000000..3a68e893e --- /dev/null +++ b/contracts/facade/factories/ExchangeRateOracleFactory.sol @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.19; + +import { FIX_ONE, 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 ExchangeRateOracle { + error MissingRToken(); + + address public immutable rToken; + + constructor(address _rToken) { + // allow address(0) + rToken = _rToken; + } + + function exchangeRate() public view returns (uint256) { + address _rToken = rToken; + if (_rToken == address(0)) revert MissingRToken(); + + uint256 supply = IMinimalRToken(_rToken).totalSupply(); + if (supply == 0) return FIX_ONE; + + return divuu(uint256(IMinimalRToken(_rToken).basketsNeeded()), supply); + } + + // basic chainlink interface sufficient for Morpho + function latestRoundData() + external + view + returns ( + uint80 roundId, + int256 answer, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ) + { + return (0, int256(exchangeRate()), 0, 0, 0); + } + + function decimals() external pure returns (uint8) { + return 18; + } +} + + +/** + * @title ExchangeRateOracleFactory + * @notice An immutable factory for Exchange Rate Oracles + */ +contract ExchangeRateOracleFactory { + error OracleAlreadyDeployed(); + + event OracleDeployed(address indexed rToken, address indexed oracle); + + mapping(address rToken => ExchangeRateOracle oracle) public oracles; + + function deployOracle(address rToken) external returns (address) { + if (address(oracles[rToken]) != address(0)) revert OracleAlreadyDeployed(); + ExchangeRateOracle oracle = new ExchangeRateOracle(rToken); + + if (rToken != address(0)){ + oracle.exchangeRate(); + oracle.latestRoundData(); + oracle.decimals(); + } + + oracles[rToken] = oracle; + emit OracleDeployed(address(rToken), address(oracle)); + return address(oracle); + } +} 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-exchange-rate-oracle-factory.ts b/tasks/deployment/create-exchange-rate-oracle-factory.ts new file mode 100644 index 000000000..bff4baef9 --- /dev/null +++ b/tasks/deployment/create-exchange-rate-oracle-factory.ts @@ -0,0 +1,78 @@ +import { getChainId } from '../../common/blockchain-utils' +import { task, types } from 'hardhat/config' +import { ExchangeRateOracleFactory } from '../../typechain' + +task('create-exchange-rate-oracle-factory', 'Deploys an ExchangeRateOracleFactory') + .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 ExchangeRateOracleFactory to ${hre.network.name} (${chainId}) with burner account ${wallet.address}` + ) + } + + const CurveOracleFactoryFactory = await hre.ethers.getContractFactory( + 'ExchangeRateOracleFactory' + ) + const oracleFactory = ( + await CurveOracleFactoryFactory.connect(wallet).deploy() + ) + await oracleFactory.deployed() + + if (!params.noOutput) { + console.log( + `Deployed ExchangeRateOracleFactory to ${hre.network.name} (${chainId}): ${oracleFactory.address}` + ) + console.log( + `Deploying dummy ExchangeRateOracle to ${hre.network.name} (${chainId}): ${oracleFactory.address}` + ) + } + + // Deploy dummy zero address oracle + const addr = await oracleFactory.callStatic.deployOracle(hre.ethers.constants.AddressZero) + await (await oracleFactory.deployOracle(hre.ethers.constants.AddressZero)).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 ExchangeRateOracleFactory ****************************************/ + console.time('Verifying ExchangeRateOracleFactory') + await hre.run('verify:verify', { + address: oracleFactory.address, + constructorArguments: [], + contract: + 'contracts/facade/factories/ExchangeRateOracleFactory.sol:ExchangeRateOracleFactory', + }) + console.timeEnd('Verifying ExchangeRateOracleFactory') + + console.time('Verifying ExchangeRateOracle') + await hre.run('verify:verify', { + address: addr, + constructorArguments: [hre.ethers.constants.AddressZero], + contract: 'contracts/facade/factories/ExchangeRateOracleFactory.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..0ab2848a9 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-exchange-rate-oracle-factory' import './deployment/deploy-facade-monitor' import './deployment/empty-wallet' import './deployment/cancel-tx' From 7713c033d705a5a3d0e5bc9b90d8beb5cf80212c Mon Sep 17 00:00:00 2001 From: Patrick McKelvy Date: Mon, 28 Oct 2024 10:07:23 -0400 Subject: [PATCH 02/17] fix linting. --- .../facade/factories/ExchangeRateOracleFactory.sol | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/contracts/facade/factories/ExchangeRateOracleFactory.sol b/contracts/facade/factories/ExchangeRateOracleFactory.sol index 3a68e893e..b5e97899b 100644 --- a/contracts/facade/factories/ExchangeRateOracleFactory.sol +++ b/contracts/facade/factories/ExchangeRateOracleFactory.sol @@ -12,7 +12,7 @@ interface IMinimalRToken { contract ExchangeRateOracle { error MissingRToken(); - + address public immutable rToken; constructor(address _rToken) { @@ -23,7 +23,7 @@ contract ExchangeRateOracle { function exchangeRate() public view returns (uint256) { address _rToken = rToken; if (_rToken == address(0)) revert MissingRToken(); - + uint256 supply = IMinimalRToken(_rToken).totalSupply(); if (supply == 0) return FIX_ONE; @@ -50,7 +50,6 @@ contract ExchangeRateOracle { } } - /** * @title ExchangeRateOracleFactory * @notice An immutable factory for Exchange Rate Oracles @@ -60,13 +59,14 @@ contract ExchangeRateOracleFactory { event OracleDeployed(address indexed rToken, address indexed oracle); - mapping(address rToken => ExchangeRateOracle oracle) public oracles; + // {rtoken} => {oracle} + mapping(address => ExchangeRateOracle) public oracles; function deployOracle(address rToken) external returns (address) { if (address(oracles[rToken]) != address(0)) revert OracleAlreadyDeployed(); ExchangeRateOracle oracle = new ExchangeRateOracle(rToken); - if (rToken != address(0)){ + if (rToken != address(0)) { oracle.exchangeRate(); oracle.latestRoundData(); oracle.decimals(); From eba65258f5cba8079e2e7a2e9d6d7860a9254a11 Mon Sep 17 00:00:00 2001 From: Taylor Brent Date: Mon, 28 Oct 2024 17:46:52 -0700 Subject: [PATCH 03/17] add natspec to exchange rate oracle --- .../factories/ExchangeRateOracleFactory.sol | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/contracts/facade/factories/ExchangeRateOracleFactory.sol b/contracts/facade/factories/ExchangeRateOracleFactory.sol index b5e97899b..48f278088 100644 --- a/contracts/facade/factories/ExchangeRateOracleFactory.sol +++ b/contracts/facade/factories/ExchangeRateOracleFactory.sol @@ -10,6 +10,21 @@ interface IMinimalRToken { function totalSupply() external view returns (uint256); } +/** + * @title ExchangeRateOracle + * @notice An immutable Exchange Rate Oracle for an RToken + * + * 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: + * `rToken.status() == 0 && rToken.fullyCollateralized()` + * + * However, note that `fullyCollateralized()` is extremely gas-costly. We recommend executing + * the function off-chain. `status()` is cheap and more reasonable to be called from on-chain. + */ contract ExchangeRateOracle { error MissingRToken(); @@ -42,6 +57,8 @@ contract ExchangeRateOracle { uint80 answeredInRound ) { + // TODO + // make better to work with more than just Morpho return (0, int256(exchangeRate()), 0, 0, 0); } From 3f26ec2dc3a55d8e4c6001704043d04d1bdfe90a Mon Sep 17 00:00:00 2001 From: Akshat Mittal Date: Tue, 29 Oct 2024 14:50:40 +0530 Subject: [PATCH 04/17] Extended interface --- .../factories/ExchangeRateOracleFactory.sol | 41 +++++++++++-------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/contracts/facade/factories/ExchangeRateOracleFactory.sol b/contracts/facade/factories/ExchangeRateOracleFactory.sol index 48f278088..51f52a152 100644 --- a/contracts/facade/factories/ExchangeRateOracleFactory.sol +++ b/contracts/facade/factories/ExchangeRateOracleFactory.sol @@ -3,7 +3,6 @@ pragma solidity 0.8.19; import { FIX_ONE, 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); @@ -14,7 +13,7 @@ interface IMinimalRToken { * @title ExchangeRateOracle * @notice An immutable Exchange Rate Oracle for an RToken * - * Warning! In the event of an RToken taking a loss in excess of the StRSR overcollateralization + * ::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. @@ -23,7 +22,7 @@ interface IMinimalRToken { * `rToken.status() == 0 && rToken.fullyCollateralized()` * * However, note that `fullyCollateralized()` is extremely gas-costly. We recommend executing - * the function off-chain. `status()` is cheap and more reasonable to be called from on-chain. + * the function off-chain. `status()` is cheap and more reasonable to be called on-chain. */ contract ExchangeRateOracle { error MissingRToken(); @@ -36,16 +35,18 @@ contract ExchangeRateOracle { } function exchangeRate() public view returns (uint256) { - address _rToken = rToken; - if (_rToken == address(0)) revert MissingRToken(); + if (rToken == address(0)) { + revert MissingRToken(); + } - uint256 supply = IMinimalRToken(_rToken).totalSupply(); - if (supply == 0) return FIX_ONE; + uint256 supply = IMinimalRToken(rToken).totalSupply(); + if (supply == 0) { + return FIX_ONE; + } - return divuu(uint256(IMinimalRToken(_rToken).basketsNeeded()), supply); + return divuu(uint256(IMinimalRToken(rToken).basketsNeeded()), supply); } - // basic chainlink interface sufficient for Morpho function latestRoundData() external view @@ -57,22 +58,26 @@ contract ExchangeRateOracle { uint80 answeredInRound ) { - // TODO - // make better to work with more than just Morpho - return (0, int256(exchangeRate()), 0, 0, 0); + return ( + uint80(block.timestamp), + int256(exchangeRate()), + block.timestamp - 1, + block.timestamp, + uint80(block.timestamp) + ); } function decimals() external pure returns (uint8) { - return 18; + return 18; // RToken is always 18 decimals } } /** * @title ExchangeRateOracleFactory - * @notice An immutable factory for Exchange Rate Oracles + * @notice An immutable factory for RToken Exchange Rate Oracles */ contract ExchangeRateOracleFactory { - error OracleAlreadyDeployed(); + error OracleAlreadyDeployed(address oracle); event OracleDeployed(address indexed rToken, address indexed oracle); @@ -80,7 +85,10 @@ contract ExchangeRateOracleFactory { mapping(address => ExchangeRateOracle) public oracles; function deployOracle(address rToken) external returns (address) { - if (address(oracles[rToken]) != address(0)) revert OracleAlreadyDeployed(); + if (address(oracles[rToken]) != address(0)) { + revert OracleAlreadyDeployed(address(oracles[rToken])); + } + ExchangeRateOracle oracle = new ExchangeRateOracle(rToken); if (rToken != address(0)) { @@ -91,6 +99,7 @@ contract ExchangeRateOracleFactory { oracles[rToken] = oracle; emit OracleDeployed(address(rToken), address(oracle)); + return address(oracle); } } From 7bd40ab97724bef10af87316b3855456a89f73f7 Mon Sep 17 00:00:00 2001 From: Akshat Mittal Date: Mon, 4 Nov 2024 23:20:04 +0530 Subject: [PATCH 05/17] very very wip --- .../oracles/curve-oracle/CurveOracle.sol | 95 +++++++++++++++++++ .../exchange-rate/ExchangeRateOracle.sol | 74 +++++++++++++++ .../ExchangeRateOracleFactory.sol | 36 +++++++ .../exchange-rate/IExchangeRateOracle.sol | 6 ++ .../yearn-curve-oracle/YearnCurveOracle.sol | 15 +++ test/oracles/CurveOracle.test.ts | 77 +++++++++++++++ 6 files changed, 303 insertions(+) create mode 100644 contracts/facade/oracles/curve-oracle/CurveOracle.sol create mode 100644 contracts/facade/oracles/exchange-rate/ExchangeRateOracle.sol create mode 100644 contracts/facade/oracles/exchange-rate/ExchangeRateOracleFactory.sol create mode 100644 contracts/facade/oracles/exchange-rate/IExchangeRateOracle.sol create mode 100644 contracts/facade/oracles/yearn-curve-oracle/YearnCurveOracle.sol create mode 100644 test/oracles/CurveOracle.test.ts diff --git a/contracts/facade/oracles/curve-oracle/CurveOracle.sol b/contracts/facade/oracles/curve-oracle/CurveOracle.sol new file mode 100644 index 000000000..708e4a7d5 --- /dev/null +++ b/contracts/facade/oracles/curve-oracle/CurveOracle.sol @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.19; + +import { FIX_ONE, divuu } from "../../../libraries/Fixed.sol"; + +import { AggregatorV3Interface } from "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol"; +import { IExchangeRateOracle } from "../exchange-rate/IExchangeRateOracle.sol"; + +import "hardhat/console.sol"; + +interface ICurveStableSwapNG { + function coins(uint256 i) external view returns (address); + + function get_virtual_price() external view returns (uint256); + + function stored_rates() external view returns (uint256[] memory); +} + +/** + * @title CurveOracle + * @notice An immutable Exchange Rate Oracle for a StableSwapNG Curve LP Token, + * with one or more appreciating assets. Only for 2-asset Curve LP Tokens. + */ +contract CurveOracle { + enum OracleType { + STORED, + RTOKEN, + CHAINLINK + } + + struct OracleConfig { + OracleType oracleType; + address rateProvider; + uint256 timeout; + } + + error BadOracleValue(); + error InvalidOracleType(); + + ICurveStableSwapNG public immutable curvePool; + OracleConfig public oracleConfig0; + OracleConfig public oracleConfig1; + + constructor( + address _curvePool, + OracleConfig memory _oracleConfig0, + OracleConfig memory _oracleConfig1 + ) { + curvePool = ICurveStableSwapNG(_curvePool); + oracleConfig0 = _oracleConfig0; + oracleConfig1 = _oracleConfig1; + } + + function _getTokenPrice(uint256 tokenId) internal view virtual returns (uint256) { + OracleConfig memory oracleConfig = tokenId == 0 ? oracleConfig0 : oracleConfig1; + OracleType oracleType = oracleConfig.oracleType; + + if (oracleType == OracleType.STORED) { + return curvePool.stored_rates()[tokenId]; + } else if (oracleType == OracleType.RTOKEN) { + return IExchangeRateOracle(oracleConfig.rateProvider).exchangeRate(); + } else if (oracleType == OracleType.CHAINLINK) { + (, int256 price, , uint256 updateTime, ) = AggregatorV3Interface( + oracleConfig.rateProvider + ).latestRoundData(); + + if (price < 0) { + revert BadOracleValue(); + } + + if (block.timestamp - updateTime > oracleConfig.timeout) { + revert BadOracleValue(); + } + + return uint256(price); + } + + revert InvalidOracleType(); + } + + function getPrice() public view virtual returns (uint256) { + uint256 token0Price = _getTokenPrice(0); + uint256 token1Price = _getTokenPrice(1); + + console.log("token0Price: %d", token0Price); + console.log("token1Price: %d", token1Price); + + uint256 minPrice = token0Price < token1Price ? token0Price : token1Price; + uint256 virtualPrice = curvePool.get_virtual_price(); + + console.log("virtualPrice: %d", virtualPrice); + + return (virtualPrice * minPrice) / 1e18; + } +} diff --git a/contracts/facade/oracles/exchange-rate/ExchangeRateOracle.sol b/contracts/facade/oracles/exchange-rate/ExchangeRateOracle.sol new file mode 100644 index 000000000..16364716d --- /dev/null +++ b/contracts/facade/oracles/exchange-rate/ExchangeRateOracle.sol @@ -0,0 +1,74 @@ +// 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"; + +interface IMinimalRToken { + function basketsNeeded() external view returns (uint192); + + function totalSupply() external view returns (uint256); +} + +/** + * @title ExchangeRateOracle + * @notice An immutable Exchange Rate Oracle for an RToken + * + * ::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: + * `rToken.status() == 0 && rToken.fullyCollateralized()` + * + * 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 MissingRToken(); + + address public immutable rToken; + + constructor(address _rToken) { + // allow address(0) + rToken = _rToken; + } + + function exchangeRate() public view returns (uint256) { + if (rToken == address(0)) { + revert MissingRToken(); + } + + uint256 supply = IMinimalRToken(rToken).totalSupply(); + if (supply == 0) { + return FIX_ONE; + } + + return divuu(uint256(IMinimalRToken(rToken).basketsNeeded()), supply); + } + + function latestRoundData() + external + view + returns ( + uint80 roundId, + int256 answer, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ) + { + return ( + uint80(block.timestamp), + int256(exchangeRate()), + block.timestamp - 1, + block.timestamp, + uint80(block.timestamp) + ); + } + + function decimals() external pure returns (uint8) { + return 18; // RToken is always 18 decimals + } +} diff --git a/contracts/facade/oracles/exchange-rate/ExchangeRateOracleFactory.sol b/contracts/facade/oracles/exchange-rate/ExchangeRateOracleFactory.sol new file mode 100644 index 000000000..b9036cc44 --- /dev/null +++ b/contracts/facade/oracles/exchange-rate/ExchangeRateOracleFactory.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.19; + +import { ExchangeRateOracle } from "./ExchangeRateOracle.sol"; + +/** + * @title ExchangeRateOracleFactory + * @notice An immutable factory for RToken Exchange Rate Oracles + */ +contract ExchangeRateOracleFactory { + error OracleAlreadyDeployed(address oracle); + + event OracleDeployed(address indexed rToken, address indexed oracle); + + // {rtoken} => {oracle} + mapping(address => ExchangeRateOracle) public oracles; + + function deployOracle(address rToken) external returns (address) { + if (address(oracles[rToken]) != address(0)) { + revert OracleAlreadyDeployed(address(oracles[rToken])); + } + + ExchangeRateOracle oracle = new ExchangeRateOracle(rToken); + + if (rToken != address(0)) { + oracle.exchangeRate(); + oracle.latestRoundData(); + oracle.decimals(); + } + + oracles[rToken] = oracle; + emit OracleDeployed(address(rToken), address(oracle)); + + return address(oracle); + } +} diff --git a/contracts/facade/oracles/exchange-rate/IExchangeRateOracle.sol b/contracts/facade/oracles/exchange-rate/IExchangeRateOracle.sol new file mode 100644 index 000000000..0f8f87fd0 --- /dev/null +++ b/contracts/facade/oracles/exchange-rate/IExchangeRateOracle.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.19; + +interface IExchangeRateOracle { + function exchangeRate() external view returns (uint256); +} diff --git a/contracts/facade/oracles/yearn-curve-oracle/YearnCurveOracle.sol b/contracts/facade/oracles/yearn-curve-oracle/YearnCurveOracle.sol new file mode 100644 index 000000000..b1684b9a2 --- /dev/null +++ b/contracts/facade/oracles/yearn-curve-oracle/YearnCurveOracle.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.19; + +import { FIX_ONE, divuu } from "../../../libraries/Fixed.sol"; + +/** + * @title YearnCurveOracle + * @notice An immutable Exchange Rate Oracle for a Yearn Vault containing a Curve LP Token, + * with one or more appreciating assets. Only for 2-asset Curve LP Tokens. + * + * ::Warning:: When pairing with an RToken, same assumptions apply as in `ExchangeRateOracle`. + */ +contract YearnCurveOracle { + +} diff --git a/test/oracles/CurveOracle.test.ts b/test/oracles/CurveOracle.test.ts new file mode 100644 index 000000000..ba28d0f3b --- /dev/null +++ b/test/oracles/CurveOracle.test.ts @@ -0,0 +1,77 @@ +import hre, { ethers } from 'hardhat' + +import { useEnv } from '#/utils/env' +import { resetFork } from '#/utils/chain' +import { CurveOracle } from '@typechain/index' + +enum OracleType { + STORED, + RTOKEN, + CHAINLINK, +} + +const describeFork = useEnv('FORK') ? describe : describe.skip + +describeFork('Curve Oracle', () => { + describe('USD3/sDAI - Mainnet', () => { + let curveOracle: CurveOracle + + beforeEach(async () => { + // Mainnet Fork only + await resetFork(hre, 21093000) + + const CurveOracleFactory = await ethers.getContractFactory('CurveOracle') + curveOracle = await CurveOracleFactory.deploy( + '0x0e84996ac18fcf6fe18c372520798ce0cdf892d4', + { + oracleType: OracleType.STORED, + rateProvider: ethers.constants.AddressZero, + timeout: 0, + }, + { + oracleType: OracleType.STORED, + rateProvider: ethers.constants.AddressZero, + timeout: 0, + } + ) + }) + + it('log', async () => { + console.log(await curveOracle.getPrice()) + // 1.0161845170 - manual calc etherscan + // 1.0134000000 - curve ui + // 1.0323744085 - the output wtf + }) + }) + + describe('dgnETH/ETH+ - Mainnet', () => { + let curveOracle: CurveOracle + + beforeEach(async () => { + // Mainnet Fork only + await resetFork(hre, 21093000) + + const CurveOracleFactory = await ethers.getContractFactory('CurveOracle') + curveOracle = await CurveOracleFactory.deploy( + '0x5ba541585d6297b756f08b7c61a7e37752123b4f', + { + oracleType: OracleType.STORED, + rateProvider: ethers.constants.AddressZero, + timeout: 0, + }, + { + oracleType: OracleType.STORED, + rateProvider: ethers.constants.AddressZero, + timeout: 0, + } + ) + }) + + it('log', async () => { + console.log(await curveOracle.getPrice()) + // 1.0034 - manual calc + // 1.0033 - curve ui + // 1.0036 - the output + }) + }) +}) From 05bf7541559045d4b1346c11c0562b199e9e2848 Mon Sep 17 00:00:00 2001 From: Akshat Mittal Date: Tue, 5 Nov 2024 22:00:40 +0530 Subject: [PATCH 06/17] Add Yearn Curve Oracle --- .../factories/ExchangeRateOracleFactory.sol | 105 ------------------ .../oracles/curve-oracle/CurveOracle.sol | 34 +++--- .../yearn-curve-oracle/YearnCurveOracle.sol | 25 ++++- test/oracles/CurveOracle.test.ts | 60 ++++++++-- 4 files changed, 90 insertions(+), 134 deletions(-) delete mode 100644 contracts/facade/factories/ExchangeRateOracleFactory.sol diff --git a/contracts/facade/factories/ExchangeRateOracleFactory.sol b/contracts/facade/factories/ExchangeRateOracleFactory.sol deleted file mode 100644 index 51f52a152..000000000 --- a/contracts/facade/factories/ExchangeRateOracleFactory.sol +++ /dev/null @@ -1,105 +0,0 @@ -// SPDX-License-Identifier: BlueOak-1.0.0 -pragma solidity 0.8.19; - -import { FIX_ONE, divuu } from "../../libraries/Fixed.sol"; - -interface IMinimalRToken { - function basketsNeeded() external view returns (uint192); - - function totalSupply() external view returns (uint256); -} - -/** - * @title ExchangeRateOracle - * @notice An immutable Exchange Rate Oracle for an RToken - * - * ::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: - * `rToken.status() == 0 && rToken.fullyCollateralized()` - * - * 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 { - error MissingRToken(); - - address public immutable rToken; - - constructor(address _rToken) { - // allow address(0) - rToken = _rToken; - } - - function exchangeRate() public view returns (uint256) { - if (rToken == address(0)) { - revert MissingRToken(); - } - - uint256 supply = IMinimalRToken(rToken).totalSupply(); - if (supply == 0) { - return FIX_ONE; - } - - return divuu(uint256(IMinimalRToken(rToken).basketsNeeded()), supply); - } - - function latestRoundData() - external - view - returns ( - uint80 roundId, - int256 answer, - uint256 startedAt, - uint256 updatedAt, - uint80 answeredInRound - ) - { - return ( - uint80(block.timestamp), - int256(exchangeRate()), - block.timestamp - 1, - block.timestamp, - uint80(block.timestamp) - ); - } - - function decimals() external pure returns (uint8) { - return 18; // RToken is always 18 decimals - } -} - -/** - * @title ExchangeRateOracleFactory - * @notice An immutable factory for RToken Exchange Rate Oracles - */ -contract ExchangeRateOracleFactory { - error OracleAlreadyDeployed(address oracle); - - event OracleDeployed(address indexed rToken, address indexed oracle); - - // {rtoken} => {oracle} - mapping(address => ExchangeRateOracle) public oracles; - - function deployOracle(address rToken) external returns (address) { - if (address(oracles[rToken]) != address(0)) { - revert OracleAlreadyDeployed(address(oracles[rToken])); - } - - ExchangeRateOracle oracle = new ExchangeRateOracle(rToken); - - if (rToken != address(0)) { - oracle.exchangeRate(); - oracle.latestRoundData(); - oracle.decimals(); - } - - oracles[rToken] = oracle; - emit OracleDeployed(address(rToken), address(oracle)); - - return address(oracle); - } -} diff --git a/contracts/facade/oracles/curve-oracle/CurveOracle.sol b/contracts/facade/oracles/curve-oracle/CurveOracle.sol index 708e4a7d5..5e8ec4e3c 100644 --- a/contracts/facade/oracles/curve-oracle/CurveOracle.sol +++ b/contracts/facade/oracles/curve-oracle/CurveOracle.sol @@ -1,13 +1,9 @@ // SPDX-License-Identifier: BlueOak-1.0.0 pragma solidity 0.8.19; -import { FIX_ONE, divuu } from "../../../libraries/Fixed.sol"; - -import { AggregatorV3Interface } from "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol"; +import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol"; import { IExchangeRateOracle } from "../exchange-rate/IExchangeRateOracle.sol"; -import "hardhat/console.sol"; - interface ICurveStableSwapNG { function coins(uint256 i) external view returns (address); @@ -20,10 +16,16 @@ interface ICurveStableSwapNG { * @title CurveOracle * @notice An immutable Exchange Rate Oracle for a StableSwapNG Curve LP Token, * with one or more appreciating assets. Only for 2-asset Curve LP Tokens. + * @dev Does not account for native asset appreciation, only accounts for the + * appreciation in the Curve LP via trading volume. + * + * The oracles specified for the pool MUST be for the base unit, for example + * if the paired token is sDAI, you'd specify the oracle for DAI/USD. */ contract CurveOracle { enum OracleType { STORED, + STATIC, RTOKEN, CHAINLINK } @@ -31,6 +33,7 @@ contract CurveOracle { struct OracleConfig { OracleType oracleType; address rateProvider; + uint256 staticValue; uint256 timeout; } @@ -57,12 +60,14 @@ contract CurveOracle { if (oracleType == OracleType.STORED) { return curvePool.stored_rates()[tokenId]; + } else if (oracleType == OracleType.STATIC) { + return oracleConfig.staticValue; } else if (oracleType == OracleType.RTOKEN) { return IExchangeRateOracle(oracleConfig.rateProvider).exchangeRate(); } else if (oracleType == OracleType.CHAINLINK) { - (, int256 price, , uint256 updateTime, ) = AggregatorV3Interface( - oracleConfig.rateProvider - ).latestRoundData(); + AggregatorV3Interface oracle = AggregatorV3Interface(oracleConfig.rateProvider); + uint8 decimals = oracle.decimals(); + (, int256 price, , uint256 updateTime, ) = oracle.latestRoundData(); if (price < 0) { revert BadOracleValue(); @@ -72,7 +77,13 @@ contract CurveOracle { revert BadOracleValue(); } - return uint256(price); + if (decimals == 18) { + return uint256(price); + } else if (decimals < 18) { + return uint256(price) * (10**(18 - decimals)); + } else { + return uint256(price) / (10**(decimals - 18)); + } } revert InvalidOracleType(); @@ -82,14 +93,9 @@ contract CurveOracle { uint256 token0Price = _getTokenPrice(0); uint256 token1Price = _getTokenPrice(1); - console.log("token0Price: %d", token0Price); - console.log("token1Price: %d", token1Price); - uint256 minPrice = token0Price < token1Price ? token0Price : token1Price; uint256 virtualPrice = curvePool.get_virtual_price(); - console.log("virtualPrice: %d", virtualPrice); - return (virtualPrice * minPrice) / 1e18; } } diff --git a/contracts/facade/oracles/yearn-curve-oracle/YearnCurveOracle.sol b/contracts/facade/oracles/yearn-curve-oracle/YearnCurveOracle.sol index b1684b9a2..dd06ff395 100644 --- a/contracts/facade/oracles/yearn-curve-oracle/YearnCurveOracle.sol +++ b/contracts/facade/oracles/yearn-curve-oracle/YearnCurveOracle.sol @@ -1,15 +1,32 @@ // SPDX-License-Identifier: BlueOak-1.0.0 pragma solidity 0.8.19; -import { FIX_ONE, divuu } from "../../../libraries/Fixed.sol"; +import { CurveOracle } from "../curve-oracle/CurveOracle.sol"; + +interface YearnVault { + function pricePerShare() external view returns (uint256); +} /** * @title YearnCurveOracle * @notice An immutable Exchange Rate Oracle for a Yearn Vault containing a Curve LP Token, * with one or more appreciating assets. Only for 2-asset Curve LP Tokens. - * - * ::Warning:: When pairing with an RToken, same assumptions apply as in `ExchangeRateOracle`. */ -contract YearnCurveOracle { +contract YearnCurveOracle is CurveOracle { + YearnVault public immutable yearnVault; + + constructor( + address _yearnVault, + address _curvePool, + OracleConfig memory _oracleConfig0, + OracleConfig memory _oracleConfig1 + ) CurveOracle(_curvePool, _oracleConfig0, _oracleConfig1) { + yearnVault = YearnVault(_yearnVault); + } + + function getPrice() public view virtual override returns (uint256) { + uint256 pricePerShare = yearnVault.pricePerShare(); + return (CurveOracle.getPrice() * pricePerShare) / 1e18; + } } diff --git a/test/oracles/CurveOracle.test.ts b/test/oracles/CurveOracle.test.ts index ba28d0f3b..a9e3948b7 100644 --- a/test/oracles/CurveOracle.test.ts +++ b/test/oracles/CurveOracle.test.ts @@ -3,17 +3,20 @@ import hre, { ethers } from 'hardhat' import { useEnv } from '#/utils/env' import { resetFork } from '#/utils/chain' import { CurveOracle } from '@typechain/index' +import { BigNumber } from 'ethers' +import { expect } from 'chai' enum OracleType { STORED, + STATIC, RTOKEN, CHAINLINK, } const describeFork = useEnv('FORK') ? describe : describe.skip -describeFork('Curve Oracle', () => { - describe('USD3/sDAI - Mainnet', () => { +describeFork('Rate Oracles', () => { + describe('USD3/sDAI - Curve LP - Mainnet', () => { let curveOracle: CurveOracle beforeEach(async () => { @@ -23,28 +26,62 @@ describeFork('Curve Oracle', () => { const CurveOracleFactory = await ethers.getContractFactory('CurveOracle') curveOracle = await CurveOracleFactory.deploy( '0x0e84996ac18fcf6fe18c372520798ce0cdf892d4', + { + oracleType: OracleType.STATIC, + rateProvider: ethers.constants.AddressZero, + staticValue: BigNumber.from(10).pow(18), + timeout: 0, + }, + { + oracleType: OracleType.CHAINLINK, + rateProvider: '0xAed0c38402a5d19df6E4c03F4E2DceD6e29c1ee9', // DAI/USD Chainlink + staticValue: 0, + timeout: 3600, + } + ) + }) + + it('log', async () => { + const price = await curveOracle.getPrice() + console.log(price) + expect(price).to.be.eq(BigNumber.from(1012370583548288620n)) + }) + }) + + describe('dgnETH/ETH+ - Yearn Vault - Mainnet', () => { + let curveOracle: CurveOracle + + beforeEach(async () => { + // Mainnet Fork only + await resetFork(hre, 21093000) + + const CurveOracleFactory = await ethers.getContractFactory('YearnCurveOracle') + curveOracle = await CurveOracleFactory.deploy( + '0x961Ad224fedDFa468c81acB3A9Cc2cC4731809f4', + '0x5ba541585d6297b756f08b7c61a7e37752123b4f', { oracleType: OracleType.STORED, rateProvider: ethers.constants.AddressZero, + staticValue: 0, timeout: 0, }, { oracleType: OracleType.STORED, rateProvider: ethers.constants.AddressZero, + staticValue: 0, timeout: 0, } ) }) it('log', async () => { - console.log(await curveOracle.getPrice()) - // 1.0161845170 - manual calc etherscan - // 1.0134000000 - curve ui - // 1.0323744085 - the output wtf + const price = await curveOracle.getPrice() + console.log(price) + expect(price).to.be.eq(BigNumber.from(1014465272399087787n)) }) }) - describe('dgnETH/ETH+ - Mainnet', () => { + describe('dgnETH/ETH+ - Curve LP - Mainnet', () => { let curveOracle: CurveOracle beforeEach(async () => { @@ -57,21 +94,22 @@ describeFork('Curve Oracle', () => { { oracleType: OracleType.STORED, rateProvider: ethers.constants.AddressZero, + staticValue: 0, timeout: 0, }, { oracleType: OracleType.STORED, rateProvider: ethers.constants.AddressZero, + staticValue: 0, timeout: 0, } ) }) it('log', async () => { - console.log(await curveOracle.getPrice()) - // 1.0034 - manual calc - // 1.0033 - curve ui - // 1.0036 - the output + const price = await curveOracle.getPrice() + console.log(price) + expect(price).to.be.eq(BigNumber.from(1003642593037842342n)) }) }) }) From 83d32b91eae9f37e28ddcec00c8e172219ab54de Mon Sep 17 00:00:00 2001 From: Akshat Mittal Date: Thu, 7 Nov 2024 17:03:33 +0530 Subject: [PATCH 07/17] nits --- .../oracles/curve-oracle/CurveOracle.sol | 2 +- .../exchange-rate/ExchangeRateOracle.sol | 4 +- test/oracles/CurveOracle.test.ts | 43 +++++++++++++++++-- 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/contracts/facade/oracles/curve-oracle/CurveOracle.sol b/contracts/facade/oracles/curve-oracle/CurveOracle.sol index 5e8ec4e3c..47bb4757b 100644 --- a/contracts/facade/oracles/curve-oracle/CurveOracle.sol +++ b/contracts/facade/oracles/curve-oracle/CurveOracle.sol @@ -73,7 +73,7 @@ contract CurveOracle { revert BadOracleValue(); } - if (block.timestamp - updateTime > oracleConfig.timeout) { + if (block.timestamp - updateTime > (oracleConfig.timeout + 60)) { revert BadOracleValue(); } diff --git a/contracts/facade/oracles/exchange-rate/ExchangeRateOracle.sol b/contracts/facade/oracles/exchange-rate/ExchangeRateOracle.sol index 16364716d..37bf9b44c 100644 --- a/contracts/facade/oracles/exchange-rate/ExchangeRateOracle.sol +++ b/contracts/facade/oracles/exchange-rate/ExchangeRateOracle.sol @@ -60,11 +60,11 @@ contract ExchangeRateOracle is IExchangeRateOracle { ) { return ( - uint80(block.timestamp), + uint80(block.number), int256(exchangeRate()), block.timestamp - 1, block.timestamp, - uint80(block.timestamp) + uint80(block.number) ); } diff --git a/test/oracles/CurveOracle.test.ts b/test/oracles/CurveOracle.test.ts index a9e3948b7..1f5e1fdfc 100644 --- a/test/oracles/CurveOracle.test.ts +++ b/test/oracles/CurveOracle.test.ts @@ -43,7 +43,44 @@ describeFork('Rate Oracles', () => { it('log', async () => { const price = await curveOracle.getPrice() - console.log(price) + // console.log(price) + expect(price).to.be.eq(BigNumber.from(1012370583548288620n)) + }) + }) + + describe('USD3/sDAI - Curve LP - Mainnet (w/ ExchangeRateOracle)', () => { + let curveOracle: CurveOracle + + beforeEach(async () => { + // Mainnet Fork only + await resetFork(hre, 21093000) + + const ExchangeRateOracleFactory = await ethers.getContractFactory('ExchangeRateOracle') + const exchangeRateOracle = await ExchangeRateOracleFactory.deploy( + '0x0d86883faf4ffd7aeb116390af37746f45b6f378' + ) + + const CurveOracleFactory = await ethers.getContractFactory('CurveOracle') + curveOracle = await CurveOracleFactory.deploy( + '0x0e84996ac18fcf6fe18c372520798ce0cdf892d4', + { + oracleType: OracleType.RTOKEN, + rateProvider: exchangeRateOracle.address, + staticValue: 0, + timeout: 0, + }, + { + oracleType: OracleType.CHAINLINK, + rateProvider: '0xAed0c38402a5d19df6E4c03F4E2DceD6e29c1ee9', // DAI/USD Chainlink + staticValue: 0, + timeout: 3600, + } + ) + }) + + it('log', async () => { + const price = await curveOracle.getPrice() + // console.log(price) expect(price).to.be.eq(BigNumber.from(1012370583548288620n)) }) }) @@ -76,7 +113,7 @@ describeFork('Rate Oracles', () => { it('log', async () => { const price = await curveOracle.getPrice() - console.log(price) + // console.log(price) expect(price).to.be.eq(BigNumber.from(1014465272399087787n)) }) }) @@ -108,7 +145,7 @@ describeFork('Rate Oracles', () => { it('log', async () => { const price = await curveOracle.getPrice() - console.log(price) + // console.log(price) expect(price).to.be.eq(BigNumber.from(1003642593037842342n)) }) }) From 47bf2ef695b6feeb354f5d43cad0f8584bdd757e Mon Sep 17 00:00:00 2001 From: Akshat Mittal Date: Wed, 24 Sep 2025 20:29:51 +0530 Subject: [PATCH 08/17] Update some oracles --- .../exchange-rate/ExchangeRateOracle.sol | 51 +++++++--- .../ExchangeRateOracleFactory.sol | 36 ------- .../exchange-rate/IExchangeRateOracle.sol | 4 +- .../exchange-rate/RateOracleFactory.sol | 42 ++++++++ .../exchange-rate/ReferenceRateOracle.sol | 97 +++++++++++++++++++ contracts/interfaces/IRToken.sol | 1 - 6 files changed, 177 insertions(+), 54 deletions(-) delete mode 100644 contracts/facade/oracles/exchange-rate/ExchangeRateOracleFactory.sol create mode 100644 contracts/facade/oracles/exchange-rate/RateOracleFactory.sol create mode 100644 contracts/facade/oracles/exchange-rate/ReferenceRateOracle.sol diff --git a/contracts/facade/oracles/exchange-rate/ExchangeRateOracle.sol b/contracts/facade/oracles/exchange-rate/ExchangeRateOracle.sol index 37bf9b44c..1192a036d 100644 --- a/contracts/facade/oracles/exchange-rate/ExchangeRateOracle.sol +++ b/contracts/facade/oracles/exchange-rate/ExchangeRateOracle.sol @@ -2,17 +2,14 @@ pragma solidity 0.8.19; import { FIX_ONE, divuu } from "../../../libraries/Fixed.sol"; +import { IERC20MetadataUpgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/IERC20MetadataUpgradeable.sol"; import { IExchangeRateOracle } from "./IExchangeRateOracle.sol"; - -interface IMinimalRToken { - function basketsNeeded() external view returns (uint192); - - function totalSupply() external view returns (uint256); -} +import { IAsset } from "../../../interfaces/IAsset.sol"; +import { IRToken } from "../../../interfaces/IRToken.sol"; /** * @title ExchangeRateOracle - * @notice An immutable Exchange Rate Oracle for an RToken + * @notice An immutable Exchange Rate Oracle for an RToken (eg: ETH+/ETH) * * ::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 @@ -28,29 +25,55 @@ interface IMinimalRToken { contract ExchangeRateOracle is IExchangeRateOracle { error MissingRToken(); - address public immutable rToken; + IRToken public immutable rToken; + uint256 public constant override version = 1; constructor(address _rToken) { // allow address(0) - rToken = _rToken; + 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) { - if (rToken == address(0)) { + if (address(rToken) == address(0)) { revert MissingRToken(); } - uint256 supply = IMinimalRToken(rToken).totalSupply(); + uint256 supply = IRToken(rToken).totalSupply(); if (supply == 0) { return FIX_ONE; } - return divuu(uint256(IMinimalRToken(rToken).basketsNeeded()), supply); + return divuu(uint256(IRToken(rToken).basketsNeeded()), supply); + } + + function getRoundData(uint80) + external + view + override + returns ( + uint80 roundId, + int256 answer, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ) + { + // NOTE: Ignores roundId completely, prefer using latestRoundData() + return this.latestRoundData(); } function latestRoundData() external view + override returns ( uint80 roundId, int256 answer, @@ -67,8 +90,4 @@ contract ExchangeRateOracle is IExchangeRateOracle { uint80(block.number) ); } - - function decimals() external pure returns (uint8) { - return 18; // RToken is always 18 decimals - } } diff --git a/contracts/facade/oracles/exchange-rate/ExchangeRateOracleFactory.sol b/contracts/facade/oracles/exchange-rate/ExchangeRateOracleFactory.sol deleted file mode 100644 index b9036cc44..000000000 --- a/contracts/facade/oracles/exchange-rate/ExchangeRateOracleFactory.sol +++ /dev/null @@ -1,36 +0,0 @@ -// SPDX-License-Identifier: BlueOak-1.0.0 -pragma solidity 0.8.19; - -import { ExchangeRateOracle } from "./ExchangeRateOracle.sol"; - -/** - * @title ExchangeRateOracleFactory - * @notice An immutable factory for RToken Exchange Rate Oracles - */ -contract ExchangeRateOracleFactory { - error OracleAlreadyDeployed(address oracle); - - event OracleDeployed(address indexed rToken, address indexed oracle); - - // {rtoken} => {oracle} - mapping(address => ExchangeRateOracle) public oracles; - - function deployOracle(address rToken) external returns (address) { - if (address(oracles[rToken]) != address(0)) { - revert OracleAlreadyDeployed(address(oracles[rToken])); - } - - ExchangeRateOracle oracle = new ExchangeRateOracle(rToken); - - if (rToken != address(0)) { - oracle.exchangeRate(); - oracle.latestRoundData(); - oracle.decimals(); - } - - oracles[rToken] = oracle; - emit OracleDeployed(address(rToken), address(oracle)); - - return address(oracle); - } -} diff --git a/contracts/facade/oracles/exchange-rate/IExchangeRateOracle.sol b/contracts/facade/oracles/exchange-rate/IExchangeRateOracle.sol index 0f8f87fd0..24c8a2928 100644 --- a/contracts/facade/oracles/exchange-rate/IExchangeRateOracle.sol +++ b/contracts/facade/oracles/exchange-rate/IExchangeRateOracle.sol @@ -1,6 +1,8 @@ // SPDX-License-Identifier: BlueOak-1.0.0 pragma solidity 0.8.19; -interface IExchangeRateOracle { +import { AggregatorV3Interface } from "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol"; + +interface IExchangeRateOracle is AggregatorV3Interface { function exchangeRate() external view returns (uint256); } diff --git a/contracts/facade/oracles/exchange-rate/RateOracleFactory.sol b/contracts/facade/oracles/exchange-rate/RateOracleFactory.sol new file mode 100644 index 000000000..13a5186ff --- /dev/null +++ b/contracts/facade/oracles/exchange-rate/RateOracleFactory.sol @@ -0,0 +1,42 @@ +// 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; + + function deployOracle(address rToken) external returns (Oracles memory oracles) { + if (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 }); + + if (rToken != address(0)) { + eOracle.latestRoundData(); + rOracle.latestRoundData(); + } + + oracleRegistry[rToken] = oracles; + emit OracleDeployed(rToken, oracles); + } +} diff --git a/contracts/facade/oracles/exchange-rate/ReferenceRateOracle.sol b/contracts/facade/oracles/exchange-rate/ReferenceRateOracle.sol new file mode 100644 index 000000000..be81fbf3c --- /dev/null +++ b/contracts/facade/oracles/exchange-rate/ReferenceRateOracle.sol @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.19; + +import { FIX_ONE, divuu } 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 { IMain } from "../../../interfaces/IMain.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) + * + * ::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: + * `rToken.status() == 0 && rToken.fullyCollateralized()` + * + * 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 MissingRToken(); + + IRToken public immutable rToken; + uint256 public constant override version = 1; + + constructor(address _rToken) { + // allow address(0) + rToken = IRToken(_rToken); + } + + 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"); + } + + function exchangeRate() public view returns (uint256) { + if (address(rToken) == address(0)) { + revert MissingRToken(); + } + + IMain main = rToken.main(); + IAssetRegistry assetRegistry = main.assetRegistry(); + IAsset rTokenAsset = assetRegistry.toAsset(IERC20(address(rToken))); + + (uint256 lower, uint256 upper) = rTokenAsset.price(); + require(lower > 0 && upper < type(uint192).max, "invalid price"); + + return (lower + upper) / 2; + } + + function getRoundData(uint80) + external + view + override + returns ( + uint80 roundId, + int256 answer, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ) + { + // NOTE: Ignores roundId completely, prefer using latestRoundData() + 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/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"; From e455eba30e2987596ee9e97f9d460a710d586977 Mon Sep 17 00:00:00 2001 From: Akshat Mittal Date: Wed, 24 Sep 2025 20:51:17 +0530 Subject: [PATCH 09/17] Lint --- contracts/facade/oracles/exchange-rate/ExchangeRateOracle.sol | 1 - contracts/facade/oracles/exchange-rate/IExchangeRateOracle.sol | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/contracts/facade/oracles/exchange-rate/ExchangeRateOracle.sol b/contracts/facade/oracles/exchange-rate/ExchangeRateOracle.sol index 1192a036d..dcb8c9bb7 100644 --- a/contracts/facade/oracles/exchange-rate/ExchangeRateOracle.sol +++ b/contracts/facade/oracles/exchange-rate/ExchangeRateOracle.sol @@ -2,7 +2,6 @@ pragma solidity 0.8.19; import { FIX_ONE, divuu } from "../../../libraries/Fixed.sol"; -import { IERC20MetadataUpgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/IERC20MetadataUpgradeable.sol"; import { IExchangeRateOracle } from "./IExchangeRateOracle.sol"; import { IAsset } from "../../../interfaces/IAsset.sol"; import { IRToken } from "../../../interfaces/IRToken.sol"; diff --git a/contracts/facade/oracles/exchange-rate/IExchangeRateOracle.sol b/contracts/facade/oracles/exchange-rate/IExchangeRateOracle.sol index 24c8a2928..1dbbb638b 100644 --- a/contracts/facade/oracles/exchange-rate/IExchangeRateOracle.sol +++ b/contracts/facade/oracles/exchange-rate/IExchangeRateOracle.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: BlueOak-1.0.0 pragma solidity 0.8.19; -import { AggregatorV3Interface } from "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol"; +import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol"; interface IExchangeRateOracle is AggregatorV3Interface { function exchangeRate() external view returns (uint256); From 6c566feae2d09b2f80068640a0337c07cae1e2a5 Mon Sep 17 00:00:00 2001 From: Akshat Mittal Date: Thu, 25 Sep 2025 00:16:47 +0530 Subject: [PATCH 10/17] Update comments --- .../exchange-rate/ReferenceRateOracle.sol | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/contracts/facade/oracles/exchange-rate/ReferenceRateOracle.sol b/contracts/facade/oracles/exchange-rate/ReferenceRateOracle.sol index be81fbf3c..dac068917 100644 --- a/contracts/facade/oracles/exchange-rate/ReferenceRateOracle.sol +++ b/contracts/facade/oracles/exchange-rate/ReferenceRateOracle.sol @@ -13,7 +13,21 @@ import { IAsset } from "../../../interfaces/IAsset.sol"; * @title ReferenceRateOracle * @notice An immutable Reference Rate Oracle for an RToken (eg: ETH+/USD) * - * ::Warning:: In the event of an RToken taking a loss in excess of the StRSR overcollateralization + * 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 underlying oracles. 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 + * oracle 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 over-collateralization * 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. @@ -53,11 +67,14 @@ contract ReferenceRateOracle is IExchangeRateOracle { IAsset rTokenAsset = assetRegistry.toAsset(IERC20(address(rToken))); (uint256 lower, uint256 upper) = rTokenAsset.price(); - require(lower > 0 && upper < type(uint192).max, "invalid price"); + require(lower != 0 && upper < type(uint192).max, "invalid price"); return (lower + upper) / 2; } + /** + * @dev Ignores roundId completely, prefer using latestRoundData() + */ function getRoundData(uint80) external view @@ -70,7 +87,6 @@ contract ReferenceRateOracle is IExchangeRateOracle { uint80 answeredInRound ) { - // NOTE: Ignores roundId completely, prefer using latestRoundData() return this.latestRoundData(); } From d2b5b20eb7b8becc90f0e29678fb349900480762 Mon Sep 17 00:00:00 2001 From: Akshat Mittal Date: Thu, 25 Sep 2025 00:20:09 +0530 Subject: [PATCH 11/17] Damn you star --- contracts/facade/oracles/exchange-rate/ReferenceRateOracle.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/facade/oracles/exchange-rate/ReferenceRateOracle.sol b/contracts/facade/oracles/exchange-rate/ReferenceRateOracle.sol index dac068917..2c6660928 100644 --- a/contracts/facade/oracles/exchange-rate/ReferenceRateOracle.sol +++ b/contracts/facade/oracles/exchange-rate/ReferenceRateOracle.sol @@ -18,7 +18,7 @@ import { IAsset } from "../../../interfaces/IAsset.sol"; * * ::Notice:: * The oracle does not call refresh() on the RToken or the underlying assets, so the price can be - * stale underlying oracles. This is generally not an issue for * active RTokens as they are + * stale underlying oracles. 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 * oracle for low-activity RTokens. * From 65058c4904be8b4b8cc83e5f8be1795e0d915e11 Mon Sep 17 00:00:00 2001 From: Akshat Mittal Date: Thu, 25 Sep 2025 00:23:12 +0530 Subject: [PATCH 12/17] Words --- .../facade/oracles/exchange-rate/ReferenceRateOracle.sol | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/contracts/facade/oracles/exchange-rate/ReferenceRateOracle.sol b/contracts/facade/oracles/exchange-rate/ReferenceRateOracle.sol index 2c6660928..983a4f68d 100644 --- a/contracts/facade/oracles/exchange-rate/ReferenceRateOracle.sol +++ b/contracts/facade/oracles/exchange-rate/ReferenceRateOracle.sol @@ -18,9 +18,8 @@ import { IAsset } from "../../../interfaces/IAsset.sol"; * * ::Notice:: * The oracle does not call refresh() on the RToken or the underlying assets, so the price can be - * stale underlying oracles. 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 - * oracle for low-activity RTokens. + * 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 From 6e7b28f2595e8e7425821537106fd1f19b441393 Mon Sep 17 00:00:00 2001 From: Taylor Brent Date: Wed, 1 Oct 2025 19:29:54 -0400 Subject: [PATCH 13/17] Oracle factory review (#1259) --- common/configuration.ts | 10 +- .../ExchangeRateOracle.sol | 33 ++-- .../IExchangeRateOracle.sol | 0 ...ateOracleFactory.sol => OracleFactory.sol} | 11 +- .../ReferenceRateOracle.sol | 63 +++++--- .../oracles/curve-oracle/CurveOracle.sol | 101 ------------ .../yearn-curve-oracle/YearnCurveOracle.sol | 32 ---- .../create-exchange-rate-oracle-factory.ts | 78 --------- tasks/deployment/create-oracle-factory.ts | 88 ++++++++++ tasks/index.ts | 2 +- test/oracles/CurveOracle.test.ts | 152 ------------------ 11 files changed, 165 insertions(+), 405 deletions(-) rename contracts/facade/oracles/{exchange-rate => }/ExchangeRateOracle.sol (69%) rename contracts/facade/oracles/{exchange-rate => }/IExchangeRateOracle.sol (100%) rename contracts/facade/oracles/{exchange-rate/RateOracleFactory.sol => OracleFactory.sol} (81%) rename contracts/facade/oracles/{exchange-rate => }/ReferenceRateOracle.sol (62%) delete mode 100644 contracts/facade/oracles/curve-oracle/CurveOracle.sol delete mode 100644 contracts/facade/oracles/yearn-curve-oracle/YearnCurveOracle.sol delete mode 100644 tasks/deployment/create-exchange-rate-oracle-factory.ts create mode 100644 tasks/deployment/create-oracle-factory.ts delete mode 100644 test/oracles/CurveOracle.test.ts 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/oracles/exchange-rate/ExchangeRateOracle.sol b/contracts/facade/oracles/ExchangeRateOracle.sol similarity index 69% rename from contracts/facade/oracles/exchange-rate/ExchangeRateOracle.sol rename to contracts/facade/oracles/ExchangeRateOracle.sol index dcb8c9bb7..185678046 100644 --- a/contracts/facade/oracles/exchange-rate/ExchangeRateOracle.sol +++ b/contracts/facade/oracles/ExchangeRateOracle.sol @@ -1,34 +1,47 @@ // SPDX-License-Identifier: BlueOak-1.0.0 pragma solidity 0.8.19; -import { FIX_ONE, divuu } from "../../../libraries/Fixed.sol"; +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"; +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: - * `rToken.status() == 0 && rToken.fullyCollateralized()` + * `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 MissingRToken(); + error ZeroAddress(); IRToken public immutable rToken; uint256 public constant override version = 1; constructor(address _rToken) { - // allow address(0) + if (_rToken == address(0)) { + revert ZeroAddress(); + } + rToken = IRToken(_rToken); } @@ -41,10 +54,6 @@ contract ExchangeRateOracle is IExchangeRateOracle { } function exchangeRate() public view returns (uint256) { - if (address(rToken) == address(0)) { - revert MissingRToken(); - } - uint256 supply = IRToken(rToken).totalSupply(); if (supply == 0) { return FIX_ONE; @@ -53,6 +62,9 @@ contract ExchangeRateOracle is IExchangeRateOracle { return divuu(uint256(IRToken(rToken).basketsNeeded()), supply); } + /** + * @dev Ignores roundId completely, prefer using latestRoundData() + */ function getRoundData(uint80) external view @@ -65,7 +77,6 @@ contract ExchangeRateOracle is IExchangeRateOracle { uint80 answeredInRound ) { - // NOTE: Ignores roundId completely, prefer using latestRoundData() return this.latestRoundData(); } diff --git a/contracts/facade/oracles/exchange-rate/IExchangeRateOracle.sol b/contracts/facade/oracles/IExchangeRateOracle.sol similarity index 100% rename from contracts/facade/oracles/exchange-rate/IExchangeRateOracle.sol rename to contracts/facade/oracles/IExchangeRateOracle.sol diff --git a/contracts/facade/oracles/exchange-rate/RateOracleFactory.sol b/contracts/facade/oracles/OracleFactory.sol similarity index 81% rename from contracts/facade/oracles/exchange-rate/RateOracleFactory.sol rename to contracts/facade/oracles/OracleFactory.sol index 13a5186ff..af727e734 100644 --- a/contracts/facade/oracles/exchange-rate/RateOracleFactory.sol +++ b/contracts/facade/oracles/OracleFactory.sol @@ -21,8 +21,11 @@ contract OracleFactory { // {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 (address(oracleRegistry[rToken].exchangeRateOracle) != address(0)) { + if ( + rToken == address(0) || address(oracleRegistry[rToken].exchangeRateOracle) != address(0) + ) { revert OracleAlreadyDeployed(rToken); } @@ -31,10 +34,8 @@ contract OracleFactory { oracles = Oracles({ exchangeRateOracle: eOracle, referenceRateOracle: rOracle }); - if (rToken != address(0)) { - eOracle.latestRoundData(); - rOracle.latestRoundData(); - } + eOracle.latestRoundData(); + rOracle.latestRoundData(); oracleRegistry[rToken] = oracles; emit OracleDeployed(rToken, oracles); diff --git a/contracts/facade/oracles/exchange-rate/ReferenceRateOracle.sol b/contracts/facade/oracles/ReferenceRateOracle.sol similarity index 62% rename from contracts/facade/oracles/exchange-rate/ReferenceRateOracle.sol rename to contracts/facade/oracles/ReferenceRateOracle.sol index 983a4f68d..a32dc47f8 100644 --- a/contracts/facade/oracles/exchange-rate/ReferenceRateOracle.sol +++ b/contracts/facade/oracles/ReferenceRateOracle.sol @@ -1,13 +1,12 @@ // SPDX-License-Identifier: BlueOak-1.0.0 pragma solidity 0.8.19; -import { FIX_ONE, divuu } from "../../../libraries/Fixed.sol"; +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 { IMain } from "../../../interfaces/IMain.sol"; -import { IAssetRegistry } from "../../../interfaces/IAssetRegistry.sol"; -import { IAsset } from "../../../interfaces/IAsset.sol"; +import { IRToken } from "../../interfaces/IRToken.sol"; +import { IAssetRegistry } from "../../interfaces/IAssetRegistry.sol"; +import { IAsset } from "../../interfaces/IAsset.sol"; /** * @title ReferenceRateOracle @@ -23,29 +22,30 @@ import { IAsset } from "../../../interfaces/IAsset.sol"; * * 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 over-collateralization - * 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. + * Chainlink style interfaces, and additionally can revert. * * As a consumer of this oracle, you may want to guard against this case by monitoring: - * `rToken.status() == 0 && rToken.fullyCollateralized()` + * `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 MissingRToken(); + error ZeroAddress(); - IRToken public immutable rToken; uint256 public constant override version = 1; + IRToken public immutable rToken; + IAssetRegistry public immutable assetRegistry; + constructor(address _rToken) { - // allow address(0) + if (_rToken == address(0)) { + revert ZeroAddress(); + } + rToken = IRToken(_rToken); + assetRegistry = IRToken(_rToken).main().assetRegistry(); } function decimals() external view override returns (uint8) { @@ -56,23 +56,37 @@ contract ReferenceRateOracle is IExchangeRateOracle { return string.concat(rToken.symbol(), " Reference Rate Oracle"); } + /** + * @dev Can revert + */ function exchangeRate() public view returns (uint256) { - if (address(rToken) == address(0)) { - revert MissingRToken(); - } - - IMain main = rToken.main(); - IAssetRegistry assetRegistry = main.assetRegistry(); + // cannot cache RTokenAsset IAsset rTokenAsset = assetRegistry.toAsset(IERC20(address(rToken))); (uint256 lower, uint256 upper) = rTokenAsset.price(); - require(lower != 0 && upper < type(uint192).max, "invalid 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 @@ -89,6 +103,9 @@ contract ReferenceRateOracle is IExchangeRateOracle { return this.latestRoundData(); } + /** + * @dev Can revert + */ function latestRoundData() external view diff --git a/contracts/facade/oracles/curve-oracle/CurveOracle.sol b/contracts/facade/oracles/curve-oracle/CurveOracle.sol deleted file mode 100644 index 47bb4757b..000000000 --- a/contracts/facade/oracles/curve-oracle/CurveOracle.sol +++ /dev/null @@ -1,101 +0,0 @@ -// SPDX-License-Identifier: BlueOak-1.0.0 -pragma solidity 0.8.19; - -import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol"; -import { IExchangeRateOracle } from "../exchange-rate/IExchangeRateOracle.sol"; - -interface ICurveStableSwapNG { - function coins(uint256 i) external view returns (address); - - function get_virtual_price() external view returns (uint256); - - function stored_rates() external view returns (uint256[] memory); -} - -/** - * @title CurveOracle - * @notice An immutable Exchange Rate Oracle for a StableSwapNG Curve LP Token, - * with one or more appreciating assets. Only for 2-asset Curve LP Tokens. - * @dev Does not account for native asset appreciation, only accounts for the - * appreciation in the Curve LP via trading volume. - * - * The oracles specified for the pool MUST be for the base unit, for example - * if the paired token is sDAI, you'd specify the oracle for DAI/USD. - */ -contract CurveOracle { - enum OracleType { - STORED, - STATIC, - RTOKEN, - CHAINLINK - } - - struct OracleConfig { - OracleType oracleType; - address rateProvider; - uint256 staticValue; - uint256 timeout; - } - - error BadOracleValue(); - error InvalidOracleType(); - - ICurveStableSwapNG public immutable curvePool; - OracleConfig public oracleConfig0; - OracleConfig public oracleConfig1; - - constructor( - address _curvePool, - OracleConfig memory _oracleConfig0, - OracleConfig memory _oracleConfig1 - ) { - curvePool = ICurveStableSwapNG(_curvePool); - oracleConfig0 = _oracleConfig0; - oracleConfig1 = _oracleConfig1; - } - - function _getTokenPrice(uint256 tokenId) internal view virtual returns (uint256) { - OracleConfig memory oracleConfig = tokenId == 0 ? oracleConfig0 : oracleConfig1; - OracleType oracleType = oracleConfig.oracleType; - - if (oracleType == OracleType.STORED) { - return curvePool.stored_rates()[tokenId]; - } else if (oracleType == OracleType.STATIC) { - return oracleConfig.staticValue; - } else if (oracleType == OracleType.RTOKEN) { - return IExchangeRateOracle(oracleConfig.rateProvider).exchangeRate(); - } else if (oracleType == OracleType.CHAINLINK) { - AggregatorV3Interface oracle = AggregatorV3Interface(oracleConfig.rateProvider); - uint8 decimals = oracle.decimals(); - (, int256 price, , uint256 updateTime, ) = oracle.latestRoundData(); - - if (price < 0) { - revert BadOracleValue(); - } - - if (block.timestamp - updateTime > (oracleConfig.timeout + 60)) { - revert BadOracleValue(); - } - - if (decimals == 18) { - return uint256(price); - } else if (decimals < 18) { - return uint256(price) * (10**(18 - decimals)); - } else { - return uint256(price) / (10**(decimals - 18)); - } - } - - revert InvalidOracleType(); - } - - function getPrice() public view virtual returns (uint256) { - uint256 token0Price = _getTokenPrice(0); - uint256 token1Price = _getTokenPrice(1); - - uint256 minPrice = token0Price < token1Price ? token0Price : token1Price; - uint256 virtualPrice = curvePool.get_virtual_price(); - - return (virtualPrice * minPrice) / 1e18; - } -} diff --git a/contracts/facade/oracles/yearn-curve-oracle/YearnCurveOracle.sol b/contracts/facade/oracles/yearn-curve-oracle/YearnCurveOracle.sol deleted file mode 100644 index dd06ff395..000000000 --- a/contracts/facade/oracles/yearn-curve-oracle/YearnCurveOracle.sol +++ /dev/null @@ -1,32 +0,0 @@ -// SPDX-License-Identifier: BlueOak-1.0.0 -pragma solidity 0.8.19; - -import { CurveOracle } from "../curve-oracle/CurveOracle.sol"; - -interface YearnVault { - function pricePerShare() external view returns (uint256); -} - -/** - * @title YearnCurveOracle - * @notice An immutable Exchange Rate Oracle for a Yearn Vault containing a Curve LP Token, - * with one or more appreciating assets. Only for 2-asset Curve LP Tokens. - */ -contract YearnCurveOracle is CurveOracle { - YearnVault public immutable yearnVault; - - constructor( - address _yearnVault, - address _curvePool, - OracleConfig memory _oracleConfig0, - OracleConfig memory _oracleConfig1 - ) CurveOracle(_curvePool, _oracleConfig0, _oracleConfig1) { - yearnVault = YearnVault(_yearnVault); - } - - function getPrice() public view virtual override returns (uint256) { - uint256 pricePerShare = yearnVault.pricePerShare(); - - return (CurveOracle.getPrice() * pricePerShare) / 1e18; - } -} diff --git a/tasks/deployment/create-exchange-rate-oracle-factory.ts b/tasks/deployment/create-exchange-rate-oracle-factory.ts deleted file mode 100644 index bff4baef9..000000000 --- a/tasks/deployment/create-exchange-rate-oracle-factory.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { getChainId } from '../../common/blockchain-utils' -import { task, types } from 'hardhat/config' -import { ExchangeRateOracleFactory } from '../../typechain' - -task('create-exchange-rate-oracle-factory', 'Deploys an ExchangeRateOracleFactory') - .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 ExchangeRateOracleFactory to ${hre.network.name} (${chainId}) with burner account ${wallet.address}` - ) - } - - const CurveOracleFactoryFactory = await hre.ethers.getContractFactory( - 'ExchangeRateOracleFactory' - ) - const oracleFactory = ( - await CurveOracleFactoryFactory.connect(wallet).deploy() - ) - await oracleFactory.deployed() - - if (!params.noOutput) { - console.log( - `Deployed ExchangeRateOracleFactory to ${hre.network.name} (${chainId}): ${oracleFactory.address}` - ) - console.log( - `Deploying dummy ExchangeRateOracle to ${hre.network.name} (${chainId}): ${oracleFactory.address}` - ) - } - - // Deploy dummy zero address oracle - const addr = await oracleFactory.callStatic.deployOracle(hre.ethers.constants.AddressZero) - await (await oracleFactory.deployOracle(hre.ethers.constants.AddressZero)).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 ExchangeRateOracleFactory ****************************************/ - console.time('Verifying ExchangeRateOracleFactory') - await hre.run('verify:verify', { - address: oracleFactory.address, - constructorArguments: [], - contract: - 'contracts/facade/factories/ExchangeRateOracleFactory.sol:ExchangeRateOracleFactory', - }) - console.timeEnd('Verifying ExchangeRateOracleFactory') - - console.time('Verifying ExchangeRateOracle') - await hre.run('verify:verify', { - address: addr, - constructorArguments: [hre.ethers.constants.AddressZero], - contract: 'contracts/facade/factories/ExchangeRateOracleFactory.sol:ExchangeRateOracle', - }) - console.timeEnd('Verifying ExchangeRateOracle') - - if (!params.noOutput) { - console.log('verified') - } - - return { oracleFactory: oracleFactory.address } - }) diff --git a/tasks/deployment/create-oracle-factory.ts b/tasks/deployment/create-oracle-factory.ts new file mode 100644 index 000000000..2c8b94d85 --- /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' + +const getRTokenAddr = (chainId: string): string => { + if (chainId == '1' || chainId == '31337') { + return networkConfig[chainId].tokens.eUSD! + } + 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 0ab2848a9..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-exchange-rate-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/CurveOracle.test.ts b/test/oracles/CurveOracle.test.ts deleted file mode 100644 index 1f5e1fdfc..000000000 --- a/test/oracles/CurveOracle.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import hre, { ethers } from 'hardhat' - -import { useEnv } from '#/utils/env' -import { resetFork } from '#/utils/chain' -import { CurveOracle } from '@typechain/index' -import { BigNumber } from 'ethers' -import { expect } from 'chai' - -enum OracleType { - STORED, - STATIC, - RTOKEN, - CHAINLINK, -} - -const describeFork = useEnv('FORK') ? describe : describe.skip - -describeFork('Rate Oracles', () => { - describe('USD3/sDAI - Curve LP - Mainnet', () => { - let curveOracle: CurveOracle - - beforeEach(async () => { - // Mainnet Fork only - await resetFork(hre, 21093000) - - const CurveOracleFactory = await ethers.getContractFactory('CurveOracle') - curveOracle = await CurveOracleFactory.deploy( - '0x0e84996ac18fcf6fe18c372520798ce0cdf892d4', - { - oracleType: OracleType.STATIC, - rateProvider: ethers.constants.AddressZero, - staticValue: BigNumber.from(10).pow(18), - timeout: 0, - }, - { - oracleType: OracleType.CHAINLINK, - rateProvider: '0xAed0c38402a5d19df6E4c03F4E2DceD6e29c1ee9', // DAI/USD Chainlink - staticValue: 0, - timeout: 3600, - } - ) - }) - - it('log', async () => { - const price = await curveOracle.getPrice() - // console.log(price) - expect(price).to.be.eq(BigNumber.from(1012370583548288620n)) - }) - }) - - describe('USD3/sDAI - Curve LP - Mainnet (w/ ExchangeRateOracle)', () => { - let curveOracle: CurveOracle - - beforeEach(async () => { - // Mainnet Fork only - await resetFork(hre, 21093000) - - const ExchangeRateOracleFactory = await ethers.getContractFactory('ExchangeRateOracle') - const exchangeRateOracle = await ExchangeRateOracleFactory.deploy( - '0x0d86883faf4ffd7aeb116390af37746f45b6f378' - ) - - const CurveOracleFactory = await ethers.getContractFactory('CurveOracle') - curveOracle = await CurveOracleFactory.deploy( - '0x0e84996ac18fcf6fe18c372520798ce0cdf892d4', - { - oracleType: OracleType.RTOKEN, - rateProvider: exchangeRateOracle.address, - staticValue: 0, - timeout: 0, - }, - { - oracleType: OracleType.CHAINLINK, - rateProvider: '0xAed0c38402a5d19df6E4c03F4E2DceD6e29c1ee9', // DAI/USD Chainlink - staticValue: 0, - timeout: 3600, - } - ) - }) - - it('log', async () => { - const price = await curveOracle.getPrice() - // console.log(price) - expect(price).to.be.eq(BigNumber.from(1012370583548288620n)) - }) - }) - - describe('dgnETH/ETH+ - Yearn Vault - Mainnet', () => { - let curveOracle: CurveOracle - - beforeEach(async () => { - // Mainnet Fork only - await resetFork(hre, 21093000) - - const CurveOracleFactory = await ethers.getContractFactory('YearnCurveOracle') - curveOracle = await CurveOracleFactory.deploy( - '0x961Ad224fedDFa468c81acB3A9Cc2cC4731809f4', - '0x5ba541585d6297b756f08b7c61a7e37752123b4f', - { - oracleType: OracleType.STORED, - rateProvider: ethers.constants.AddressZero, - staticValue: 0, - timeout: 0, - }, - { - oracleType: OracleType.STORED, - rateProvider: ethers.constants.AddressZero, - staticValue: 0, - timeout: 0, - } - ) - }) - - it('log', async () => { - const price = await curveOracle.getPrice() - // console.log(price) - expect(price).to.be.eq(BigNumber.from(1014465272399087787n)) - }) - }) - - describe('dgnETH/ETH+ - Curve LP - Mainnet', () => { - let curveOracle: CurveOracle - - beforeEach(async () => { - // Mainnet Fork only - await resetFork(hre, 21093000) - - const CurveOracleFactory = await ethers.getContractFactory('CurveOracle') - curveOracle = await CurveOracleFactory.deploy( - '0x5ba541585d6297b756f08b7c61a7e37752123b4f', - { - oracleType: OracleType.STORED, - rateProvider: ethers.constants.AddressZero, - staticValue: 0, - timeout: 0, - }, - { - oracleType: OracleType.STORED, - rateProvider: ethers.constants.AddressZero, - staticValue: 0, - timeout: 0, - } - ) - }) - - it('log', async () => { - const price = await curveOracle.getPrice() - // console.log(price) - expect(price).to.be.eq(BigNumber.from(1003642593037842342n)) - }) - }) -}) From f371dd239a3acdc0d32e216e59d876a39241bc55 Mon Sep 17 00:00:00 2001 From: Taylor Brent Date: Wed, 1 Oct 2025 20:09:02 -0400 Subject: [PATCH 14/17] test OracleFactory --- .github/workflows/tests.yml | 14 +++++ package.json | 1 + tasks/deployment/create-oracle-factory.ts | 2 +- test/oracles/OracleFactory.test.ts | 65 +++++++++++++++++++++++ 4 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 test/oracles/OracleFactory.test.ts diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7a629bde1..6e5a3cc44 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -335,3 +335,17 @@ 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' diff --git a/package.json b/package.json index 138316629..1118d5282 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-oracle-factory.ts b/tasks/deployment/create-oracle-factory.ts index 2c8b94d85..e2738661d 100644 --- a/tasks/deployment/create-oracle-factory.ts +++ b/tasks/deployment/create-oracle-factory.ts @@ -3,7 +3,7 @@ import { task, types } from 'hardhat/config' import { OracleFactory } from '../../typechain' import { networkConfig } from '../../common/configuration' -const getRTokenAddr = (chainId: string): string => { +export const getRTokenAddr = (chainId: string): string => { if (chainId == '1' || chainId == '31337') { return networkConfig[chainId].tokens.eUSD! } 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') + }) + }) +}) From 0a28265f1a8cf53147b0461bce1d3ea6d24ff344 Mon Sep 17 00:00:00 2001 From: Taylor Brent Date: Wed, 1 Oct 2025 20:12:39 -0400 Subject: [PATCH 15/17] workflow --- .github/workflows/tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6e5a3cc44..3d47f3357 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -348,4 +348,5 @@ jobs: - run: yarn install --immutable - run: yarn test:oracles env: + MAINNET_RPC_URL: https://eth-mainnet.g.alchemy.com/v2/${{ secrets.ALCHEMY_MAINNET_KEY }} NODE_OPTIONS: '--max-old-space-size=32768' From 19b4eb56b0385922231ae2ce109167692860e786 Mon Sep 17 00:00:00 2001 From: Taylor Brent Date: Wed, 1 Oct 2025 20:20:37 -0400 Subject: [PATCH 16/17] workflow --- .github/workflows/tests.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3d47f3357..a15a3f72f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -348,5 +348,7 @@ jobs: - run: yarn install --immutable - run: yarn test:oracles env: - MAINNET_RPC_URL: https://eth-mainnet.g.alchemy.com/v2/${{ secrets.ALCHEMY_MAINNET_KEY }} 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 From 2c5d2c2021c0db2d8aa7406d548d1be565a94edf Mon Sep 17 00:00:00 2001 From: Taylor Brent Date: Wed, 1 Oct 2025 20:44:46 -0400 Subject: [PATCH 17/17] whoops, ETH+ --- tasks/deployment/create-oracle-factory.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tasks/deployment/create-oracle-factory.ts b/tasks/deployment/create-oracle-factory.ts index e2738661d..933c640df 100644 --- a/tasks/deployment/create-oracle-factory.ts +++ b/tasks/deployment/create-oracle-factory.ts @@ -5,7 +5,7 @@ import { networkConfig } from '../../common/configuration' export const getRTokenAddr = (chainId: string): string => { if (chainId == '1' || chainId == '31337') { - return networkConfig[chainId].tokens.eUSD! + return networkConfig[chainId].tokens.ETHPLUS! } if (chainId == '8453') { return networkConfig[chainId].tokens.bsdETH!