From 193a11ee8a4c359def10a063662af3f6a26d66d8 Mon Sep 17 00:00:00 2001 From: Sam MacPherson Date: Tue, 4 Feb 2025 17:27:49 -0700 Subject: [PATCH 1/6] add rseth oracle --- foundry.toml | 2 +- src/RSETHExchangeRateOracle.sol | 46 +++++++++++++ test/RSETHExchangeRateOracle.t.sol | 90 ++++++++++++++++++++++++++ test/SparkLendMainnetIntegration.t.sol | 28 ++++++++ 4 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 src/RSETHExchangeRateOracle.sol create mode 100644 test/RSETHExchangeRateOracle.t.sol diff --git a/foundry.toml b/foundry.toml index b59466f..2a2f8c3 100644 --- a/foundry.toml +++ b/foundry.toml @@ -5,7 +5,7 @@ libs = ["lib"] solc_version = "0.8.25" optimizer = true optimizer_runs = 200 -evm_version = "paris" +evm_version = "cancun" remappings = [ "ds-test/=lib/forge-std/lib/ds-test/src/", "erc20-helpers/=lib/erc20-helpers/src/", diff --git a/src/RSETHExchangeRateOracle.sol b/src/RSETHExchangeRateOracle.sol new file mode 100644 index 0000000..a74ee13 --- /dev/null +++ b/src/RSETHExchangeRateOracle.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.0; + +import { IPriceSource } from "./interfaces/IPriceSource.sol"; + +interface IKelpDAORestakedEthOracle { + function rsETHPrice() external view returns (uint256); +} + +/** + * @title RSETHExchangeRateOracle + * @dev Provides rsETH / USD by multiplying the rsETH exchange rate by ETH / USD. + * This provides a "non-market" price. Any depeg event will be ignored. + */ +contract RSETHExchangeRateOracle { + + /// @notice KelpDAO restaked eth rate source oracle contract. + IKelpDAORestakedEthOracle public immutable oracle; + + /// @notice The price source for ETH / USD. + IPriceSource public immutable ethSource; + + constructor(address _oracle, address _ethSource) { + // 8 decimals required as AaveOracle assumes this + require(IPriceSource(_ethSource).decimals() == 8, "RSETHExchangeRateOracle/invalid-decimals"); + + oracle = IKelpDAORestakedEthOracle(_oracle); + ethSource = IPriceSource(_ethSource); + } + + function latestAnswer() external view returns (int256) { + int256 ethUsd = ethSource.latestAnswer(); + int256 exchangeRate = int256(oracle.rsETHPrice()); + + if (ethUsd <= 0 || exchangeRate <= 0) { + return 0; + } + + return exchangeRate * ethUsd / 1e18; + } + + function decimals() external pure returns (uint8) { + return 8; + } + +} diff --git a/test/RSETHExchangeRateOracle.t.sol b/test/RSETHExchangeRateOracle.t.sol new file mode 100644 index 0000000..04173f2 --- /dev/null +++ b/test/RSETHExchangeRateOracle.t.sol @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.0; + +import "forge-std/Test.sol"; + +import { PriceSourceMock } from "./mocks/PriceSourceMock.sol"; + +import { RSETHExchangeRateOracle } from "../src/RSETHExchangeRateOracle.sol"; + +contract RSETHOracleMock { + + uint256 exchangeRate; + + constructor(uint256 _exchangeRate) { + exchangeRate = _exchangeRate; + } + + function rsETHPrice() external view returns (uint256) { + return exchangeRate; + } + + function setExchangeRate(uint256 _exchangeRate) external { + exchangeRate = _exchangeRate; + } + +} + +contract RSETHExchangeRateOracleTest is Test { + + RSETHOracleMock rsethOracle; + PriceSourceMock ethSource; + + RSETHExchangeRateOracle oracle; + + function setUp() public { + rsethOracle = new RSETHOracleMock(1.2e18); + ethSource = new PriceSourceMock(2000e8, 8); + oracle = new RSETHExchangeRateOracle(address(rsethOracle), address(ethSource)); + } + + function test_constructor() public { + assertEq(address(oracle.oracle()), address(rsethOracle)); + assertEq(address(oracle.ethSource()), address(ethSource)); + assertEq(oracle.decimals(), 8); + } + + function test_invalid_decimals() public { + ethSource.setLatestAnswer(2000e18); + ethSource.setDecimals(18); + vm.expectRevert("RSETHExchangeRateOracle/invalid-decimals"); + new RSETHExchangeRateOracle(address(rsethOracle), address(ethSource)); + } + + function test_latestAnswer_zeroEthUsd() public { + ethSource.setLatestAnswer(0); + assertEq(oracle.latestAnswer(), 0); + } + + function test_latestAnswer_negativeEthUsd() public { + ethSource.setLatestAnswer(-1); + assertEq(oracle.latestAnswer(), 0); + } + + function test_latestAnswer_zeroExchangeRate() public { + rsethOracle.setExchangeRate(0); + assertEq(oracle.latestAnswer(), 0); + } + + function test_latestAnswer_negativeExchangeRate() public { + // RETH ER can't go negative, but it can have a silent overflow + assertLt(int256(uint256(int256(-1))), 0); + rsethOracle.setExchangeRate(uint256(int256(-1))); + assertEq(oracle.latestAnswer(), 0); + } + + function test_latestAnswer() public { + // 1.2 * 2000 = 2400 + assertEq(oracle.latestAnswer(), 2400e8); + + // 1 * 2000 = 2000 + rsethOracle.setExchangeRate(1e18); + assertEq(oracle.latestAnswer(), 2000e8); + + // 0.5 * 1200 = 600 + rsethOracle.setExchangeRate(0.5e18); + ethSource.setLatestAnswer(1200e8); + assertEq(oracle.latestAnswer(), 600e8); + } + +} diff --git a/test/SparkLendMainnetIntegration.t.sol b/test/SparkLendMainnetIntegration.t.sol index f842c63..8fa63a2 100644 --- a/test/SparkLendMainnetIntegration.t.sol +++ b/test/SparkLendMainnetIntegration.t.sol @@ -21,6 +21,7 @@ import { RateTargetKinkInterestRateStrategy } from "src/RateTargetKinkInterestRa import { RETHExchangeRateOracle } from "src/RETHExchangeRateOracle.sol"; import { WSTETHExchangeRateOracle } from "src/WSTETHExchangeRateOracle.sol"; import { WEETHExchangeRateOracle } from "src/WEETHExchangeRateOracle.sol"; +import { RSETHExchangeRateOracle } from "src/RSETHExchangeRateOracle.sol"; interface ITollLike { function kiss(address) external; @@ -45,6 +46,7 @@ contract SparkLendMainnetIntegrationTest is Test { address WSTETH = 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0; address RETH = 0xae78736Cd615f374D3085123A210448E74Fc6393; address WEETH = 0xCd5fE23C85820F7B72D0926FC9b05b43E359b7ee; + address RSETH = 0xA1290d69c65A6Fe4DF752f95823fae25cB99e5A7; address SUSDS = 0xa3931d71877C0E7a3148CB7Eb4463524FEc27fbD; address ETH_IRM = 0xD7A8461e6aF708a086D8285f8fD900309336347c; @@ -54,6 +56,7 @@ contract SparkLendMainnetIntegrationTest is Test { address ETHUSD_ORACLE = 0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419; address RETH_ORACLE = 0x05225Cd708bCa9253789C1374e4337a019e99D56; address WSTETH_ORACLE = 0x8B6851156023f4f5A66F68BEA80851c3D905Ac93; + address RSETH_ORACLE = 0x349A73444b1a310BAe67ef67973022020d70020d; address LST_RATE_SOURCE = 0x08669C836F41AEaD03e3EF81a59f3b8e72EC417A; @@ -344,6 +347,31 @@ contract SparkLendMainnetIntegrationTest is Test { assertEq(aaveOracle.getSourceOfAsset(WEETH), address(oracle)); } + function test_rseth_market_oracle() public { + RSETHExchangeRateOracle oracle = new RSETHExchangeRateOracle(RSETH_ORACLE, ETHUSD_ORACLE); + + vm.expectRevert(); // Not setup yet + assertEq(aaveOracle.getAssetPrice(RSETH), 0); + assertEq(aaveOracle.getSourceOfAsset(RSETH), address(0)); + + address[] memory assets = new address[](1); + assets[0] = RSETH; + address[] memory sources = new address[](1); + sources[0] = address(oracle); + + vm.prank(ADMIN); + aaveOracle.setAssetSources( + assets, + sources + ); + + // Nothing is special about this number, it just happens to be the price at this block + uint256 price = 3144.93204796e8; + + assertEq(aaveOracle.getAssetPrice(RSETH), price); + assertEq(aaveOracle.getSourceOfAsset(RSETH), address(oracle)); + } + /**********************************************************************************************/ /*** Helper Functions ***/ /**********************************************************************************************/ From 37f4caba962bc6fb47da80f87b16827e246a0c44 Mon Sep 17 00:00:00 2001 From: Sam MacPherson Date: Tue, 4 Feb 2025 17:47:34 -0700 Subject: [PATCH 2/6] add ezeth oracle --- src/EZETHExchangeRateOracle.sol | 56 ++++++++++++++++ test/EZETHExchangeRateOracle.t.sol | 92 ++++++++++++++++++++++++++ test/SparkLendMainnetIntegration.t.sol | 28 ++++++++ 3 files changed, 176 insertions(+) create mode 100644 src/EZETHExchangeRateOracle.sol create mode 100644 test/EZETHExchangeRateOracle.t.sol diff --git a/src/EZETHExchangeRateOracle.sol b/src/EZETHExchangeRateOracle.sol new file mode 100644 index 0000000..0ddc1dc --- /dev/null +++ b/src/EZETHExchangeRateOracle.sol @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.0; + +import { IPriceSource } from "./interfaces/IPriceSource.sol"; + +interface IEZETHExchangeRateOracle { + function calculateTVLs() external view returns (uint256[][] memory, uint256[] memory, uint256); + function ezETH() external view returns (address); +} + +interface IEZETH { + function totalSupply() external view returns (uint256); +} + +/** + * @title EZETHExchangeRateOracle + * @dev Provides ezETH / USD by multiplying the ezETH exchange rate by ETH / USD. + * This provides a "non-market" price. Any depeg event will be ignored. + */ +contract EZETHExchangeRateOracle { + + /// @notice Renzo restaked eth rate source oracle contract. + IEZETHExchangeRateOracle public immutable oracle; + + /// @notice Renzo restaked eth token contract. + IEZETH public immutable ezETH; + + /// @notice The price source for ETH / USD. + IPriceSource public immutable ethSource; + + constructor(address _oracle, address _ethSource) { + // 8 decimals required as AaveOracle assumes this + require(IPriceSource(_ethSource).decimals() == 8, "EZETHExchangeRateOracle/invalid-decimals"); + + oracle = IEZETHExchangeRateOracle(_oracle); + ezETH = IEZETH(oracle.ezETH()); + ethSource = IPriceSource(_ethSource); + } + + function latestAnswer() external view returns (int256) { + int256 ethUsd = ethSource.latestAnswer(); + (, , uint256 tvl) = oracle.calculateTVLs(); + int256 exchangeRate = int256(((tvl * 1e18) / ezETH.totalSupply())); + + if (ethUsd <= 0 || exchangeRate <= 0) { + return 0; + } + + return exchangeRate * ethUsd / 1e18; + } + + function decimals() external pure returns (uint8) { + return 8; + } + +} diff --git a/test/EZETHExchangeRateOracle.t.sol b/test/EZETHExchangeRateOracle.t.sol new file mode 100644 index 0000000..bed6f9a --- /dev/null +++ b/test/EZETHExchangeRateOracle.t.sol @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.0; + +import "forge-std/Test.sol"; + +import { PriceSourceMock } from "./mocks/PriceSourceMock.sol"; + +import { EZETHExchangeRateOracle } from "../src/EZETHExchangeRateOracle.sol"; + +// This can be both the oracle and the ezETH token for the purposes of this unit testing contract +contract EZETHOracleMock { + + uint256 exchangeRate; + + constructor(uint256 _exchangeRate) { + exchangeRate = _exchangeRate; + } + + function calculateTVLs() external view returns (uint256[][] memory , uint256[] memory, uint256 _exchangeRate) { + _exchangeRate = exchangeRate; + } + + function totalSupply() external view returns (uint256) { + return 1e18; + } + + function ezETH() external view returns (address) { + return address(this); + } + + function setExchangeRate(uint256 _exchangeRate) external { + exchangeRate = _exchangeRate; + } + +} + +contract EZETHExchangeRateOracleTest is Test { + + EZETHOracleMock ezethOracle; + PriceSourceMock ethSource; + + EZETHExchangeRateOracle oracle; + + function setUp() public { + ezethOracle = new EZETHOracleMock(1.2e18); + ethSource = new PriceSourceMock(2000e8, 8); + oracle = new EZETHExchangeRateOracle(address(ezethOracle), address(ethSource)); + } + + function test_constructor() public { + assertEq(address(oracle.oracle()), address(ezethOracle)); + assertEq(address(oracle.ethSource()), address(ethSource)); + assertEq(oracle.decimals(), 8); + } + + function test_invalid_decimals() public { + ethSource.setLatestAnswer(2000e18); + ethSource.setDecimals(18); + vm.expectRevert("EZETHExchangeRateOracle/invalid-decimals"); + new EZETHExchangeRateOracle(address(ezethOracle), address(ethSource)); + } + + function test_latestAnswer_zeroEthUsd() public { + ethSource.setLatestAnswer(0); + assertEq(oracle.latestAnswer(), 0); + } + + function test_latestAnswer_negativeEthUsd() public { + ethSource.setLatestAnswer(-1); + assertEq(oracle.latestAnswer(), 0); + } + + function test_latestAnswer_zeroExchangeRate() public { + ezethOracle.setExchangeRate(0); + assertEq(oracle.latestAnswer(), 0); + } + + function test_latestAnswer() public { + // 1.2 * 2000 = 2400 + assertEq(oracle.latestAnswer(), 2400e8); + + // 1 * 2000 = 2000 + ezethOracle.setExchangeRate(1e18); + assertEq(oracle.latestAnswer(), 2000e8); + + // 0.5 * 1200 = 600 + ezethOracle.setExchangeRate(0.5e18); + ethSource.setLatestAnswer(1200e8); + assertEq(oracle.latestAnswer(), 600e8); + } + +} diff --git a/test/SparkLendMainnetIntegration.t.sol b/test/SparkLendMainnetIntegration.t.sol index 8fa63a2..4befe5b 100644 --- a/test/SparkLendMainnetIntegration.t.sol +++ b/test/SparkLendMainnetIntegration.t.sol @@ -22,6 +22,7 @@ import { RETHExchangeRateOracle } from "src/RETHExchangeRateOracle.s import { WSTETHExchangeRateOracle } from "src/WSTETHExchangeRateOracle.sol"; import { WEETHExchangeRateOracle } from "src/WEETHExchangeRateOracle.sol"; import { RSETHExchangeRateOracle } from "src/RSETHExchangeRateOracle.sol"; +import { EZETHExchangeRateOracle } from "src/EZETHExchangeRateOracle.sol"; interface ITollLike { function kiss(address) external; @@ -47,6 +48,7 @@ contract SparkLendMainnetIntegrationTest is Test { address RETH = 0xae78736Cd615f374D3085123A210448E74Fc6393; address WEETH = 0xCd5fE23C85820F7B72D0926FC9b05b43E359b7ee; address RSETH = 0xA1290d69c65A6Fe4DF752f95823fae25cB99e5A7; + address EZETH = 0xbf5495Efe5DB9ce00f80364C8B423567e58d2110; address SUSDS = 0xa3931d71877C0E7a3148CB7Eb4463524FEc27fbD; address ETH_IRM = 0xD7A8461e6aF708a086D8285f8fD900309336347c; @@ -57,6 +59,7 @@ contract SparkLendMainnetIntegrationTest is Test { address RETH_ORACLE = 0x05225Cd708bCa9253789C1374e4337a019e99D56; address WSTETH_ORACLE = 0x8B6851156023f4f5A66F68BEA80851c3D905Ac93; address RSETH_ORACLE = 0x349A73444b1a310BAe67ef67973022020d70020d; + address EZETH_ORACLE = 0x74a09653A083691711cF8215a6ab074BB4e99ef5; address LST_RATE_SOURCE = 0x08669C836F41AEaD03e3EF81a59f3b8e72EC417A; @@ -372,6 +375,31 @@ contract SparkLendMainnetIntegrationTest is Test { assertEq(aaveOracle.getSourceOfAsset(RSETH), address(oracle)); } + function test_ezeth_market_oracle() public { + EZETHExchangeRateOracle oracle = new EZETHExchangeRateOracle(EZETH_ORACLE, ETHUSD_ORACLE); + + vm.expectRevert(); // Not setup yet + assertEq(aaveOracle.getAssetPrice(EZETH), 0); + assertEq(aaveOracle.getSourceOfAsset(EZETH), address(0)); + + address[] memory assets = new address[](1); + assets[0] = EZETH; + address[] memory sources = new address[](1); + sources[0] = address(oracle); + + vm.prank(ADMIN); + aaveOracle.setAssetSources( + assets, + sources + ); + + // Nothing is special about this number, it just happens to be the price at this block + uint256 price = 3135.93175044e8; + + assertEq(aaveOracle.getAssetPrice(EZETH), price); + assertEq(aaveOracle.getSourceOfAsset(EZETH), address(oracle)); + } + /**********************************************************************************************/ /*** Helper Functions ***/ /**********************************************************************************************/ From 80575bedf964f2c55b2ebc911a05a6721b81e79a Mon Sep 17 00:00:00 2001 From: Sam MacPherson Date: Tue, 4 Feb 2025 17:49:09 -0700 Subject: [PATCH 3/6] update readme --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 42397e5..2d251ba 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,10 @@ Please note all these oracles are designed for consumption by `AaveOracle` which [WEETHExchangeRateOracle](https://github.com/marsfoundation/sparklend-advanced/blob/master/src/WEETHExchangeRateOracle.sol): Provides weETH/USD by multiplying the weETH exchange rate by ETH/USD. Used for: weETH market +[RSETHExchangeRateOracle](https://github.com/marsfoundation/sparklend-advanced/blob/master/src/RSETHExchangeRateOracle.sol): Provides rsETH/USD by multiplying the rsETH exchange rate by ETH/USD. Used for: rsETH market + +[EZETHExchangeRateOracle](https://github.com/marsfoundation/sparklend-advanced/blob/master/src/EZETHExchangeRateOracle.sol): Provides ezETH/USD by multiplying the ezETH exchange rate by ETH/USD. Used for: ezETH market + [MorphoUpgradableOracle](https://github.com/marsfoundation/sparklend-advanced/blob/master/src/MorphoUpgradableOracle.sol): Allows Spark Governance to change an oracle for Morpho Blue markets. Planned to be used in Morpho Blue. ### Custom Interest Rate Strategies From 4091b5eca017aaa933d4baaf282d9cf1dc225205 Mon Sep 17 00:00:00 2001 From: Sam MacPherson Date: Tue, 11 Feb 2025 13:03:54 -0700 Subject: [PATCH 4/6] review fixes --- src/EZETHExchangeRateOracle.sol | 2 +- test/EZETHExchangeRateOracle.t.sol | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/EZETHExchangeRateOracle.sol b/src/EZETHExchangeRateOracle.sol index 0ddc1dc..f9cee5c 100644 --- a/src/EZETHExchangeRateOracle.sol +++ b/src/EZETHExchangeRateOracle.sol @@ -39,7 +39,7 @@ contract EZETHExchangeRateOracle { function latestAnswer() external view returns (int256) { int256 ethUsd = ethSource.latestAnswer(); - (, , uint256 tvl) = oracle.calculateTVLs(); + ( ,, uint256 tvl ) = oracle.calculateTVLs(); int256 exchangeRate = int256(((tvl * 1e18) / ezETH.totalSupply())); if (ethUsd <= 0 || exchangeRate <= 0) { diff --git a/test/EZETHExchangeRateOracle.t.sol b/test/EZETHExchangeRateOracle.t.sol index bed6f9a..1c978c6 100644 --- a/test/EZETHExchangeRateOracle.t.sol +++ b/test/EZETHExchangeRateOracle.t.sol @@ -20,7 +20,7 @@ contract EZETHOracleMock { _exchangeRate = exchangeRate; } - function totalSupply() external view returns (uint256) { + function totalSupply() external pure returns (uint256) { return 1e18; } @@ -74,7 +74,7 @@ contract EZETHExchangeRateOracleTest is Test { ezethOracle.setExchangeRate(0); assertEq(oracle.latestAnswer(), 0); } - + function test_latestAnswer() public { // 1.2 * 2000 = 2400 assertEq(oracle.latestAnswer(), 2400e8); From fae09d50f4683aa7e479ca4b24c85dccb2fdd7ce Mon Sep 17 00:00:00 2001 From: Sam MacPherson Date: Tue, 11 Feb 2025 13:11:20 -0700 Subject: [PATCH 5/6] remove unused dirs from coverage --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4cb1c9..35db92a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,7 +64,7 @@ jobs: - name: Filter directories run: | sudo apt update && sudo apt install -y lcov - lcov --remove lcov.info 'test/*' 'script/*' --output-file lcov.info --rc lcov_branch_coverage=1 + lcov --remove lcov.info 'test/*' --output-file lcov.info --rc lcov_branch_coverage=1 # This step posts a detailed coverage report as a comment and deletes previous comments on # each push. The below step is used to fail coverage if the specified coverage threshold is From e5fa948962eabab7f70cdd623d5df404956d5d5b Mon Sep 17 00:00:00 2001 From: Sam MacPherson Date: Tue, 11 Feb 2025 13:41:15 -0700 Subject: [PATCH 6/6] review fixes --- src/EZETHExchangeRateOracle.sol | 4 ++-- test/EZETHExchangeRateOracle.t.sol | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/EZETHExchangeRateOracle.sol b/src/EZETHExchangeRateOracle.sol index f9cee5c..827bc17 100644 --- a/src/EZETHExchangeRateOracle.sol +++ b/src/EZETHExchangeRateOracle.sol @@ -39,8 +39,8 @@ contract EZETHExchangeRateOracle { function latestAnswer() external view returns (int256) { int256 ethUsd = ethSource.latestAnswer(); - ( ,, uint256 tvl ) = oracle.calculateTVLs(); - int256 exchangeRate = int256(((tvl * 1e18) / ezETH.totalSupply())); + ( ,, uint256 tvl ) = oracle.calculateTVLs(); + int256 exchangeRate = int256(tvl * 1e18 / ezETH.totalSupply()); if (ethUsd <= 0 || exchangeRate <= 0) { return 0; diff --git a/test/EZETHExchangeRateOracle.t.sol b/test/EZETHExchangeRateOracle.t.sol index 1c978c6..7dd306f 100644 --- a/test/EZETHExchangeRateOracle.t.sol +++ b/test/EZETHExchangeRateOracle.t.sol @@ -16,7 +16,7 @@ contract EZETHOracleMock { exchangeRate = _exchangeRate; } - function calculateTVLs() external view returns (uint256[][] memory , uint256[] memory, uint256 _exchangeRate) { + function calculateTVLs() external view returns (uint256[][] memory, uint256[] memory, uint256 _exchangeRate) { _exchangeRate = exchangeRate; }