From ae372514ba2c42d4a2ac8ac12f1fc684a6b20095 Mon Sep 17 00:00:00 2001 From: Sean Casey Date: Wed, 21 Aug 2024 20:15:18 -0400 Subject: [PATCH] feat: chainlink-like wsteth price feed --- .../ChainlinkLikeWstethPriceFeed.sol | 71 +++++++++++++++ tests/bases/IntegrationTest.sol | 2 +- tests/interfaces/external/ILidoSteth.sol | 2 + .../lido/ChainlinkLikeWstethPriceFeed.t.sol | 87 +++++++++++++++++++ .../protocols/lido/WstethPriceFeed.t.sol | 2 +- tests/utils/Constants.sol | 2 +- 6 files changed, 163 insertions(+), 3 deletions(-) create mode 100644 contracts/release/infrastructure/price-feeds/primitives/ChainlinkLikeWstethPriceFeed.sol create mode 100644 tests/tests/protocols/lido/ChainlinkLikeWstethPriceFeed.t.sol diff --git a/contracts/release/infrastructure/price-feeds/primitives/ChainlinkLikeWstethPriceFeed.sol b/contracts/release/infrastructure/price-feeds/primitives/ChainlinkLikeWstethPriceFeed.sol new file mode 100644 index 000000000..9a0237b86 --- /dev/null +++ b/contracts/release/infrastructure/price-feeds/primitives/ChainlinkLikeWstethPriceFeed.sol @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: GPL-3.0 + +/* + This file is part of the Enzyme Protocol. + + (c) Enzyme Foundation + + For the full license information, please view the LICENSE + file that was distributed with this source code. +*/ + +pragma solidity 0.8.19; + +import {IChainlinkAggregator} from "../../../../external-interfaces/IChainlinkAggregator.sol"; +import {ILidoSteth} from "../../../../external-interfaces/ILidoSteth.sol"; + +/// @title ChainlinkLikeWstethPriceFeed Contract +/// @author Enzyme Foundation +/// @notice Price feed for Lido wrapped stETH (wstETH), wrapped in a Chainlink-like interface and quoted in ETH +/// @dev Relies on a stETH/ETH feed with the Chainlink aggregator interface for the intermediary conversion step +contract ChainlinkLikeWstethPriceFeed is IChainlinkAggregator { + uint8 private constant CHAINLINK_AGGREGATOR_ETH_QUOTE_DECIMALS = 18; + uint256 private constant STETH_UNIT = 10 ** 18; + uint256 private constant WSTETH_UNIT = 10 ** 18; + + ILidoSteth private immutable STETH; + IChainlinkAggregator private immutable STETH_ETH_CHAINLINK_AGGREGATOR; + + error ChainlinkLikeWstethPriceFeed__NegativeAnswer(); + + constructor(ILidoSteth _steth, IChainlinkAggregator _stethEthChainlinkAggregator) { + STETH = _steth; + STETH_ETH_CHAINLINK_AGGREGATOR = _stethEthChainlinkAggregator; + } + + /// @notice The number of precision decimals in the aggregator answer + /// @return decimals_ The number of decimals + function decimals() external pure override returns (uint8 decimals_) { + return CHAINLINK_AGGREGATOR_ETH_QUOTE_DECIMALS; + } + + /// @notice Returns Chainlink-like latest round data for the wstETH/ETH pair + /// @return roundId_ Unused + /// @return answer_ The price of wstETH quoted in ETH + /// @return startedAt_ The `startedAt_` value returned by the Chainlink stETH/ETH aggregator `latestRoundData()` + /// @return updatedAt_ The `updatedAt_` value returned by the Chainlink stETH/ETH aggregator `latestRoundData()` + /// @return answeredInRound_ Unused + /// @dev Does not pass through round-related values, to avoid misinterpretation + function latestRoundData() + external + view + override + returns (uint80, int256 answer_, uint256 startedAt_, uint256 updatedAt_, uint80) + { + // steth-per-wsteth rate + uint256 stethPerWsteth = STETH.getPooledEthByShares({_sharesAmount: WSTETH_UNIT}); + + // eth-per-steth rate + int256 ethPerStethAnswer; + (, ethPerStethAnswer, startedAt_, updatedAt_,) = STETH_ETH_CHAINLINK_AGGREGATOR.latestRoundData(); + + if (ethPerStethAnswer < 0) { + revert ChainlinkLikeWstethPriceFeed__NegativeAnswer(); + } + + // eth-per-wsteth rate + answer_ = int256(uint256(ethPerStethAnswer) * stethPerWsteth / STETH_UNIT); + + return (uint80(0), answer_, startedAt_, updatedAt_, uint80(0)); + } +} diff --git a/tests/bases/IntegrationTest.sol b/tests/bases/IntegrationTest.sol index bca8faecd..5c647c6a5 100644 --- a/tests/bases/IntegrationTest.sol +++ b/tests/bases/IntegrationTest.sol @@ -169,7 +169,7 @@ abstract contract IntegrationTest is CoreUtils { } function setUpMainnetEnvironment(uint256 _forkBlock) internal { - vm.createSelectFork("mainnet", _forkBlock); + vm.createSelectFork({urlOrAlias: "mainnet", blockNumber: _forkBlock}); v4ReleaseContracts = getV4MainnetReleaseContracts(); diff --git a/tests/interfaces/external/ILidoSteth.sol b/tests/interfaces/external/ILidoSteth.sol index ae1cf03cd..b3238aad6 100644 --- a/tests/interfaces/external/ILidoSteth.sol +++ b/tests/interfaces/external/ILidoSteth.sol @@ -2,5 +2,7 @@ pragma solidity >=0.6.0 <0.9.0; interface ILidoSteth { + function getPooledEthByShares(uint256 _sharesAmount) external view returns (uint256 ethAmount_); + function submit(address _referral) external payable; } diff --git a/tests/tests/protocols/lido/ChainlinkLikeWstethPriceFeed.t.sol b/tests/tests/protocols/lido/ChainlinkLikeWstethPriceFeed.t.sol new file mode 100644 index 000000000..b0f975533 --- /dev/null +++ b/tests/tests/protocols/lido/ChainlinkLikeWstethPriceFeed.t.sol @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity 0.8.19; + +import {IntegrationTest} from "tests/bases/IntegrationTest.sol"; +import {IChainlinkAggregator} from "tests/interfaces/external/IChainlinkAggregator.sol"; +import {IERC20} from "tests/interfaces/external/IERC20.sol"; +import {ILidoSteth} from "tests/interfaces/external/ILidoSteth.sol"; + +contract ChainlinkLikeWstethPriceFeedTest is IntegrationTest { + IChainlinkAggregator wstethAggregator; + IChainlinkAggregator originalStethEthAggregator = IChainlinkAggregator(ETHEREUM_STETH_ETH_AGGREGATOR); + + function setUp() public override { + vm.createSelectFork("mainnet", ETHEREUM_BLOCK_LATEST); + + wstethAggregator = __deployWstethAggregator(); + } + + // DEPLOYMENT HELPERS + + function __deployWstethAggregator() private returns (IChainlinkAggregator wstethAggregator_) { + bytes memory args = abi.encode(ETHEREUM_STETH, ETHEREUM_STETH_ETH_AGGREGATOR); + + address addr = deployCode("ChainlinkLikeWstethPriceFeed.sol", args); + + return IChainlinkAggregator(addr); + } + + // TESTS + + function test_decimals_success() public { + assertEq(wstethAggregator.decimals(), CHAINLINK_AGGREGATOR_DECIMALS_ETH, "Incorrect decimals"); + } + + function test_latestRoundData_successWithForkData() public { + // Query return data of stETH/ETH aggregator and the simulated wstETH/ETH aggregator + (,, uint256 originalStartedAt, uint256 originalUpdatedAt,) = originalStethEthAggregator.latestRoundData(); + ( + uint80 wstethRoundId, + int256 wstethAnswer, + uint256 wstethStartedAt, + uint256 wstethUpdatedAt, + uint80 wstethAnsweredInRound + ) = wstethAggregator.latestRoundData(); + + // startedAt and updatedAt should be passed-through as-is + assertEq(wstethStartedAt, originalStartedAt, "Incorrect startedAt"); + assertEq(wstethUpdatedAt, originalUpdatedAt, "Incorrect updatedAt"); + + // Round values should be empty + assertEq(wstethRoundId, 0, "Non-zero roundId"); + assertEq(wstethAnsweredInRound, 0, "Non-zero roundData"); + + // Rate: 1.17 ETH/wstETH, on June 24th, 2024 + // https://www.coingecko.com/en/coins/wrapped-steth + uint256 expectedWstethEthRate = 1.17e18; + uint256 halfPercent = WEI_ONE_PERCENT / 2; + assertApproxEqRel(uint256(wstethAnswer), expectedWstethEthRate, halfPercent, "Incorrect rate"); + } + + function test_latestRoundData_successWithAlteredRates() public { + // Mock return values of stETH and wstETH sources to be: + // - eth-per-steth rate is 5e18 + // - steth-per-wsteth rate is 2e18 + // Expected eth-per-wsteth rate: 10e18 + uint256 ethPerStethRate = 5e18; + uint256 stethPerWstethRate = 2e18; + uint256 expectedEthPerWstethRate = 10e18; + + // Mock call on the Chainlink aggregator + vm.mockCall({ + callee: address(originalStethEthAggregator), + data: abi.encodeWithSelector(IChainlinkAggregator.latestRoundData.selector), + returnData: abi.encode(1, ethPerStethRate, 345, 456, 2) + }); + + // Mock call in stETH + vm.mockCall({ + callee: ETHEREUM_STETH, + data: abi.encodeWithSelector(ILidoSteth.getPooledEthByShares.selector, assetUnit(IERC20(ETHEREUM_WSTETH))), + returnData: abi.encode(stethPerWstethRate) + }); + + (, int256 wstethAnswer,,,) = wstethAggregator.latestRoundData(); + assertEq(uint256(wstethAnswer), expectedEthPerWstethRate, "Incorrect rate"); + } +} diff --git a/tests/tests/protocols/lido/WstethPriceFeed.t.sol b/tests/tests/protocols/lido/WstethPriceFeed.t.sol index 81bdaf141..3207238c4 100644 --- a/tests/tests/protocols/lido/WstethPriceFeed.t.sol +++ b/tests/tests/protocols/lido/WstethPriceFeed.t.sol @@ -43,7 +43,7 @@ abstract contract WstethPriceFeedTestBase is IntegrationTest { _valueInterpreter: IValueInterpreter(getValueInterpreterAddressForVersion(version)), _tokenAddress: ETHEREUM_STETH, _skipIfRegistered: true, - _aggregatorAddress: ETHEREUM_STEH_ETH_AGGREGATOR, + _aggregatorAddress: ETHEREUM_STETH_ETH_AGGREGATOR, _rateAsset: IChainlinkPriceFeedMixinProd.RateAsset.ETH }); addDerivative({ diff --git a/tests/utils/Constants.sol b/tests/utils/Constants.sol index da4b59340..c36e6f134 100644 --- a/tests/utils/Constants.sol +++ b/tests/utils/Constants.sol @@ -96,7 +96,7 @@ abstract contract Constants { address internal constant ETHEREUM_DAI_ETH_AGGREGATOR = 0x773616E4d11A78F511299002da57A0a94577F1f4; address internal constant ETHEREUM_ETH_USD_AGGREGATOR = 0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419; address internal constant ETHEREUM_MLN_ETH_AGGREGATOR = 0xDaeA8386611A157B08829ED4997A8A62B557014C; - address internal constant ETHEREUM_STEH_ETH_AGGREGATOR = 0x86392dC19c0b719886221c78AB11eb8Cf5c52812; + address internal constant ETHEREUM_STETH_ETH_AGGREGATOR = 0x86392dC19c0b719886221c78AB11eb8Cf5c52812; address internal constant ETHEREUM_USDC_ETH_AGGREGATOR = 0x986b5E1e1755e3C2440e960477f25201B0a8bbD4; address internal constant ETHEREUM_USDT_ETH_AGGREGATOR = 0xEe9F2375b4bdF6387aa8265dD4FB8F16512A1d46; address internal constant ETHEREUM_WEETH_ETH_AGGREGATOR = 0x5c9C449BbC9a6075A2c061dF312a35fd1E05fF22;