diff --git a/contracts/interfaces/IStETH.sol b/contracts/interfaces/IStETH.sol new file mode 100644 index 0000000..f4255e9 --- /dev/null +++ b/contracts/interfaces/IStETH.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: agpl-3.0 +pragma solidity 0.8.4; + +import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; + +interface IStETH is IERC20Metadata { + function getPooledEthByShares(uint256 _sharesAmount) external view returns (uint256); + + function getSharesByPooledEth(uint256 _pooledEthAmount) external view returns (uint256); + + function submit(address _referral) external payable returns (uint256); +} diff --git a/contracts/interfaces/IWstETH.sol b/contracts/interfaces/IWstETH.sol new file mode 100644 index 0000000..50e2569 --- /dev/null +++ b/contracts/interfaces/IWstETH.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: agpl-3.0 +pragma solidity 0.8.4; + +import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; + +/** + * @dev Interface for interacting with WstETH contract + * Note Not a comprehensive interface + */ +interface IWstETH is IERC20Metadata { + function stETH() external returns (address); + + function wrap(uint256 _stETHAmount) external returns (uint256); + + function unwrap(uint256 _wstETHAmount) external returns (uint256); + + function getWstETHByStETH(uint256 _stETHAmount) external view returns (uint256); + + function getStETHByWstETH(uint256 _wstETHAmount) external view returns (uint256); + + function stEthPerToken() external view returns (uint256); + + function tokensPerStEth() external view returns (uint256); +} diff --git a/contracts/libraries/helpers/Errors.sol b/contracts/libraries/helpers/Errors.sol index 08ff700..4c9da21 100644 --- a/contracts/libraries/helpers/Errors.sol +++ b/contracts/libraries/helpers/Errors.sol @@ -25,6 +25,7 @@ library Errors { string public constant MATH_MULTIPLICATION_OVERFLOW = "200"; string public constant MATH_ADDITION_OVERFLOW = "201"; string public constant MATH_DIVISION_BY_ZERO = "202"; + string public constant MATH_NUMBER_OVERFLOW = "203"; //validation & check errors string public constant VL_INVALID_AMOUNT = "301"; // 'Amount must be greater than 0' diff --git a/contracts/misc/WstETHPriceAggregator.sol b/contracts/misc/WstETHPriceAggregator.sol new file mode 100644 index 0000000..28d6cc8 --- /dev/null +++ b/contracts/misc/WstETHPriceAggregator.sol @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: agpl-3.0 +pragma solidity 0.8.4; + +import {AggregatorInterface} from "@chainlink/contracts/src/v0.8/interfaces/AggregatorInterface.sol"; +import {AggregatorV3Interface} from "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol"; +import {AggregatorV2V3Interface} from "@chainlink/contracts/src/v0.8/interfaces/AggregatorV2V3Interface.sol"; + +import "../interfaces/IWstETH.sol"; + +import {Errors} from "../libraries/helpers/Errors.sol"; + +/** + * @title wstETH price aggregator + * @notice A custom price aggregator that calculates the price for wstETH / ETH + */ +contract WstETHPriceAggregator is AggregatorV2V3Interface { + /// @notice Version of the price feed + uint256 private constant _version = 1; + + /// @notice Description of the price feed + string private constant _description = "wstETH / ETH"; + + /// @notice Chainlink stETH / ETH price feed + address public stETHtoETHPriceAggregator; + + /// @notice Number of decimals for the stETH / ETH price feed + uint8 public stETHtoETHPriceAggregatorDecimals; + + /// @notice WstETH contract address + address public wstETH; + + /// @notice Scale for WstETH contract + int256 private _wstETHScale; + + constructor(address stETHtoETHPriceAggregator_, address wstETH_) { + stETHtoETHPriceAggregator = stETHtoETHPriceAggregator_; + stETHtoETHPriceAggregatorDecimals = AggregatorV3Interface(stETHtoETHPriceAggregator_).decimals(); + wstETH = wstETH_; + + // Note: Safe to convert directly to an int256 because wstETH.decimals == 18 + _wstETHScale = int256(10**IWstETH(wstETH).decimals()); + + require(stETHtoETHPriceAggregatorDecimals == 18, Errors.RC_INVALID_DECIMALS); + } + + function signed256(uint256 n) internal pure returns (int256) { + require(n <= uint256(type(int256).max), Errors.MATH_NUMBER_OVERFLOW); + return int256(n); + } + + // AggregatorInterface + + function latestAnswer() external view override returns (int256) { + int256 stETHPrice = AggregatorInterface(stETHtoETHPriceAggregator).latestAnswer(); + int256 scaledPrice = _convertStETHPrice(stETHPrice); + return scaledPrice; + } + + function latestTimestamp() external view override returns (uint256) { + return AggregatorInterface(stETHtoETHPriceAggregator).latestTimestamp(); + } + + function latestRound() external view override returns (uint256) { + return AggregatorInterface(stETHtoETHPriceAggregator).latestRound(); + } + + function getAnswer(uint256 roundId) external view override returns (int256) { + int256 stETHPrice = AggregatorInterface(stETHtoETHPriceAggregator).getAnswer(roundId); + int256 scaledPrice = _convertStETHPrice(stETHPrice); + return scaledPrice; + } + + function getTimestamp(uint256 roundId) external view override returns (uint256) { + return AggregatorInterface(stETHtoETHPriceAggregator).getTimestamp(roundId); + } + + // AggregatorV3Interface + + function decimals() external view override returns (uint8) { + return stETHtoETHPriceAggregatorDecimals; + } + + function description() external pure override returns (string memory) { + return _description; + } + + function version() external pure override returns (uint256) { + return _version; + } + + function getRoundData(uint80 _roundId) + external + view + override + returns ( + uint80 roundId, + int256 answer, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ) + { + ( + uint80 roundId_, + int256 stETHPrice, + uint256 startedAt_, + uint256 updatedAt_, + uint80 answeredInRound_ + ) = AggregatorV3Interface(stETHtoETHPriceAggregator).getRoundData(_roundId); + int256 scaledPrice = _convertStETHPrice(stETHPrice); + return (roundId_, scaledPrice, startedAt_, updatedAt_, answeredInRound_); + } + + function latestRoundData() + external + view + override + returns ( + uint80 roundId, + int256 answer, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ) + { + ( + uint80 roundId_, + int256 stETHPrice, + uint256 startedAt_, + uint256 updatedAt_, + uint80 answeredInRound_ + ) = AggregatorV3Interface(stETHtoETHPriceAggregator).latestRoundData(); + int256 scaledPrice = _convertStETHPrice(stETHPrice); + return (roundId_, scaledPrice, startedAt_, updatedAt_, answeredInRound_); + } + + function _convertStETHPrice(int256 stETHPrice) internal view returns (int256) { + uint256 tokensPerStEth = IWstETH(wstETH).tokensPerStEth(); + int256 scaledPrice = (stETHPrice * _wstETHScale) / signed256(tokensPerStEth); + return scaledPrice; + } +} diff --git a/contracts/mock/MockStETH.sol b/contracts/mock/MockStETH.sol new file mode 100644 index 0000000..e57f8c5 --- /dev/null +++ b/contracts/mock/MockStETH.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: agpl-3.0 +pragma solidity 0.8.4; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {IStETH} from "../interfaces/IStETH.sol"; + +contract MockStETH is IStETH, ERC20 { + uint256 public constant RATIO_FACTOR = 10000; + + uint256 internal _shareRatio; + + constructor() ERC20("Liquid staked Ether 2.0", "stETH") { + // 100% = 1e4 + _shareRatio = RATIO_FACTOR; + } + + function setShareRatio(uint256 ratio_) public { + _shareRatio = ratio_; + } + + function etShareRatio() public view returns (uint256) { + return _shareRatio; + } + + function balanceOf(address _account) public view override(IERC20, ERC20) returns (uint256) { + return getPooledEthByShares(super.balanceOf(_account)); + } + + function getPooledEthByShares(uint256 _sharesAmount) public view override returns (uint256) { + return (_sharesAmount * _shareRatio) / RATIO_FACTOR; + } + + function getSharesByPooledEth(uint256 _pooledEthAmount) public view override returns (uint256) { + return (_pooledEthAmount * RATIO_FACTOR) / _shareRatio; + } + + function submit( + address /*_referral*/ + ) public payable override returns (uint256) { + require(msg.value != 0, "ZERO_DEPOSIT"); + uint256 sharesAmount = getSharesByPooledEth(msg.value); + _mint(msg.sender, sharesAmount); + return sharesAmount; + } +} diff --git a/contracts/mock/WstETH.sol b/contracts/mock/WstETH.sol new file mode 100644 index 0000000..e442933 --- /dev/null +++ b/contracts/mock/WstETH.sol @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: agpl-3.0 +pragma solidity 0.8.4; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {IStETH} from "../interfaces/IStETH.sol"; + +/** + * @title StETH token wrapper with static balances. + * @dev It's an ERC20 token that represents the account's share of the total + * supply of stETH tokens. WstETH token's balance only changes on transfers, + * unlike StETH that is also changed when oracles report staking rewards and + * penalties. It's a "power user" token for DeFi protocols which don't + * support rebasable tokens. + * + * The contract is also a trustless wrapper that accepts stETH tokens and mints + * wstETH in return. Then the user unwraps, the contract burns user's wstETH + * and sends user locked stETH in return. + * + * The contract provides the staking shortcut: user can send ETH with regular + * transfer and get wstETH in return. The contract will send ETH to Lido submit + * method, staking it and wrapping the received stETH. + * + */ +contract WstETH is ERC20 { + IStETH public stETH; + + /** + * @param _stETH address of the StETH token to wrap + */ + constructor(IStETH _stETH) ERC20("Wrapped liquid staked Ether 2.0", "wstETH") { + stETH = _stETH; + } + + /** + * @notice Exchanges stETH to wstETH + * @param _stETHAmount amount of stETH to wrap in exchange for wstETH + * @dev Requirements: + * - `_stETHAmount` must be non-zero + * - msg.sender must approve at least `_stETHAmount` stETH to this + * contract. + * - msg.sender must have at least `_stETHAmount` of stETH. + * User should first approve _stETHAmount to the WstETH contract + * @return Amount of wstETH user receives after wrap + */ + function wrap(uint256 _stETHAmount) external returns (uint256) { + require(_stETHAmount > 0, "wstETH: can't wrap zero stETH"); + uint256 wstETHAmount = stETH.getSharesByPooledEth(_stETHAmount); + _mint(msg.sender, wstETHAmount); + stETH.transferFrom(msg.sender, address(this), _stETHAmount); + return wstETHAmount; + } + + /** + * @notice Exchanges wstETH to stETH + * @param _wstETHAmount amount of wstETH to uwrap in exchange for stETH + * @dev Requirements: + * - `_wstETHAmount` must be non-zero + * - msg.sender must have at least `_wstETHAmount` wstETH. + * @return Amount of stETH user receives after unwrap + */ + function unwrap(uint256 _wstETHAmount) external returns (uint256) { + require(_wstETHAmount > 0, "wstETH: zero amount unwrap not allowed"); + uint256 stETHAmount = stETH.getPooledEthByShares(_wstETHAmount); + _burn(msg.sender, _wstETHAmount); + stETH.transfer(msg.sender, stETHAmount); + return stETHAmount; + } + + /** + * @notice Shortcut to stake ETH and auto-wrap returned stETH + */ + receive() external payable { + uint256 shares = stETH.submit{value: msg.value}(address(0)); + _mint(msg.sender, shares); + } + + /** + * @notice Get amount of wstETH for a given amount of stETH + * @param _stETHAmount amount of stETH + * @return Amount of wstETH for a given stETH amount + */ + function getWstETHByStETH(uint256 _stETHAmount) external view returns (uint256) { + return stETH.getSharesByPooledEth(_stETHAmount); + } + + /** + * @notice Get amount of stETH for a given amount of wstETH + * @param _wstETHAmount amount of wstETH + * @return Amount of stETH for a given wstETH amount + */ + function getStETHByWstETH(uint256 _wstETHAmount) external view returns (uint256) { + return stETH.getPooledEthByShares(_wstETHAmount); + } + + /** + * @notice Get amount of stETH for a one wstETH + * @return Amount of stETH for 1 wstETH + */ + function stEthPerToken() external view returns (uint256) { + return stETH.getPooledEthByShares(1 ether); + } + + /** + * @notice Get amount of wstETH for a one stETH + * @return Amount of wstETH for a 1 stETH + */ + function tokensPerStEth() external view returns (uint256) { + return stETH.getSharesByPooledEth(1 ether); + } +} diff --git a/deployments/deployed-contracts-goerli.json b/deployments/deployed-contracts-goerli.json index 937f538..fcf1276 100644 --- a/deployments/deployed-contracts-goerli.json +++ b/deployments/deployed-contracts-goerli.json @@ -168,5 +168,13 @@ }, "UniswapV3DebtSwapAdapter": { "address": "0x06a41eC387810a0dC9FE3042180D4617207C6E27" + }, + "WstETHPriceAggregator": { + "address": "0xa7e842d5e85284c0da225FC75cc44d2E21736376", + "deployer": "0xafF5C36642385b6c7Aaf7585eC785aB2316b5db6" + }, + "rateStrategyWSTETH240130": { + "address": "0xDfd5815010E599c4487cA808844A213A7160552f", + "deployer": "0xafF5C36642385b6c7Aaf7585eC785aB2316b5db6" } } \ No newline at end of file diff --git a/helpers/contracts-deployments.ts b/helpers/contracts-deployments.ts index a54351a..334710d 100644 --- a/helpers/contracts-deployments.ts +++ b/helpers/contracts-deployments.ts @@ -64,6 +64,7 @@ import { MockerERC721Wrapper, ChainlinkAggregatorHelperFactory, UniswapV3DebtSwapAdapterFactory, + WstETHPriceAggregatorFactory, } from "../types"; import { withSaveAndVerify, @@ -681,3 +682,8 @@ export const deployUniswapV3DebtSwapAdapter = async (verify?: boolean) => { await rawInsertContractAddressInDb(eContractid.UniswapV3DebtSwapAdapterImpl, adapterImpl.address); return withSaveAndVerify(adapterImpl, eContractid.UniswapV3DebtSwapAdapter, [], verify); }; + +export const deployWstETHPriceAggregator = async (stETHAgg: string, wstETH: string, verify?: boolean) => { + const aggregator = await new WstETHPriceAggregatorFactory(await getDeploySigner()).deploy(stETHAgg, wstETH); + return withSaveAndVerify(aggregator, eContractid.WstETHPriceAggregator, [stETHAgg, wstETH], verify); +}; diff --git a/helpers/types.ts b/helpers/types.ts index c769ab4..8c789b8 100644 --- a/helpers/types.ts +++ b/helpers/types.ts @@ -89,6 +89,7 @@ export enum eContractid { KodaGatewayImpl = "KodaGatewayImpl", UniswapV3DebtSwapAdapter = "UniswapV3DebtSwapAdapter", UniswapV3DebtSwapAdapterImpl = "UniswapV3DebtSwapAdapterImpl", + WstETHPriceAggregator = "WstETHPriceAggregator", } export enum ProtocolLoanState { diff --git a/tasks/helpers/price-aggregator.ts b/tasks/helpers/price-aggregator.ts new file mode 100644 index 0000000..b730abe --- /dev/null +++ b/tasks/helpers/price-aggregator.ts @@ -0,0 +1,23 @@ +import { task } from "hardhat/config"; +import { ConfigNames, loadPoolConfig } from "../../helpers/configuration"; +import { getLendPoolAddressesProvider } from "../../helpers/contracts-getters"; +import { eContractid, eNetwork } from "../../helpers/types"; +import { deployWstETHPriceAggregator } from "../../helpers/contracts-deployments"; +import { notFalsyOrZeroAddress, waitForTx } from "../../helpers/misc-utils"; +import { getEthersSignerByAddress } from "../../helpers/contracts-helpers"; + +task("helpers:deploy:WstETHPriceAggregator", "Add and config new price aggregator") + .addFlag("verify", "Verify contracts at Etherscan") + .addParam("pool", `Pool name to retrieve configuration, supported: ${Object.values(ConfigNames)}`) + .addParam("stethagg", "Address of stETH-ETH aggregator contract") + .addParam("wsteth", "Address of wstETH token contract") + .setAction(async ({ verify, pool, stethagg, wsteth }, DRE) => { + await DRE.run("set-DRE"); + await DRE.run("compile"); + + const network = DRE.network.name as eNetwork; + const poolConfig = loadPoolConfig(pool); + + const aggregator = await deployWstETHPriceAggregator(stethagg, wsteth, verify); + console.log("Aggregator address:", aggregator.address); + }); diff --git a/test/wsteth-price-aggregator.spec.ts b/test/wsteth-price-aggregator.spec.ts new file mode 100644 index 0000000..c29575e --- /dev/null +++ b/test/wsteth-price-aggregator.spec.ts @@ -0,0 +1,80 @@ +import { BigNumber as BN } from "ethers"; +import BigNumber from "bignumber.js"; + +import { expect } from "chai"; +import { makeSuite, TestEnv } from "./helpers/make-suite"; +import { BendPools, iBendPoolAssets, IReserveParams } from "../helpers/types"; +import { waitForTx } from "../helpers/misc-utils"; +import { + MockChainlinkOracle, + MockChainlinkOracleFactory, + MockStETH, + MockStETHFactory, + WstETH, + WstETHFactory, + WstETHPriceAggregator, + WstETHPriceAggregatorFactory, +} from "../types"; +import { configuration as actionsConfiguration } from "./helpers/actions"; +import { configuration as calculationsConfiguration } from "./helpers/utils/calculations"; +import { getReservesConfigByPool } from "../helpers/configuration"; + +makeSuite("Price Aggregator: wstETH / ETH", (testEnv: TestEnv) => { + let mockStETH: MockStETH; + let mockWstETH: WstETH; + let mockStETHtoETHAggregator: MockChainlinkOracle; + let wstETHPriceAggregator: WstETHPriceAggregator; + + before("Before: set config", async () => { + // Sets BigNumber for this suite, instead of globally + BigNumber.config({ DECIMAL_PLACES: 0, ROUNDING_MODE: BigNumber.ROUND_DOWN }); + + actionsConfiguration.skipIntegrityCheck = false; //set this to true to execute solidity-coverage + + calculationsConfiguration.reservesParams = >( + getReservesConfigByPool(BendPools.proto) + ); + + mockStETH = await new MockStETHFactory(testEnv.deployer.signer).deploy(); + mockWstETH = await new WstETHFactory(testEnv.deployer.signer).deploy(mockStETH.address); + mockStETHtoETHAggregator = await new MockChainlinkOracleFactory(testEnv.deployer.signer).deploy(18); + + wstETHPriceAggregator = await new WstETHPriceAggregatorFactory(testEnv.deployer.signer).deploy( + mockStETHtoETHAggregator.address, + mockWstETH.address + ); + + await testEnv.mockReserveOracle.addAggregator(mockWstETH.address, wstETHPriceAggregator.address); + }); + after("After: reset config", () => { + // Reset BigNumber + BigNumber.config({ DECIMAL_PLACES: 20, ROUNDING_MODE: BigNumber.ROUND_HALF_UP }); + }); + + it("Query wstETH price", async () => { + const { mockReserveOracle } = testEnv; + + const wstethUnit = BN.from(10).pow(await mockWstETH.decimals()); + + let stethPrice = BN.from("999692734093987600"); + await waitForTx(await mockStETHtoETHAggregator.mockAddAnswer(1, stethPrice, "1", "1", "1")); + + const tokenShare = await mockWstETH.tokensPerStEth(); + const wstethPrice = stethPrice.mul(wstethUnit).div(tokenShare); + + const law1 = await wstETHPriceAggregator.latestAnswer(); + expect(law1).to.be.eq(wstethPrice, "latestAnswer not match"); + + const gaw1 = await wstETHPriceAggregator.getAnswer(1); + expect(gaw1).to.be.eq(wstethPrice, "getAnswer not match"); + + const lrd1 = await wstETHPriceAggregator.latestRoundData(); + expect(lrd1.answer).to.be.eq(wstethPrice, "latestRoundData not match"); + + const grd1 = await wstETHPriceAggregator.getRoundData(1); + expect(grd1.answer).to.be.eq(wstethPrice, "getRoundData not match"); + + const priceInRO1 = await mockReserveOracle.getAssetPrice(mockWstETH.address); + expect(priceInRO1).to.be.eq(wstethPrice, "getAssetPrice not match"); + }); +});