diff --git a/common/configuration.ts b/common/configuration.ts index 9e3b6ef73..7627b43df 100644 --- a/common/configuration.ts +++ b/common/configuration.ts @@ -160,6 +160,11 @@ export interface ITokens { waeroWETHWELL?: string waeroWETHDEGEN?: string + // Ether.fi + weETH?: string + eETH?: string + KING?: string + // RTokens eUSD?: string ETHPLUS?: string @@ -340,6 +345,9 @@ export const networkConfig: { [key: string]: INetworkConfig } = { maStETH: '0xAdc10669354aAd42A581E6F6cC8990B540AA5689', // our wrapper RLUSD: '0x8292Bb45bf1Ee4d140127049757C2E0fF06317eD', aEthRLUSD: '0xFa82580c16A31D0c1bC632A36F82e83EfEF3Eec0', + weETH: '0xCd5fE23C85820F7B72D0926FC9b05b43E359b7ee', + eETH: '0x35fA164735182de50811E8e2E824cFb9B6118ac2', + KING: '0x8F08B70456eb22f6109F57b8fafE862ED28E6040', }, chainlinkFeeds: { RSR: '0x759bBC1be8F90eE6457C44abc7d443842a976d02', @@ -372,6 +380,7 @@ export const networkConfig: { [key: string]: INetworkConfig } = { USDS: '0xfF30586cD0F29eD462364C7e81375FC0C71219b1', OETHETH: '0x703118C4CbccCBF2AB31913e0f8075fbbb15f563', // OETH/ETH RLUSD: '0x26C46B7aD0012cA71F2298ada567dC9Af14E7f2A', + weETH: '0x5c9C449BbC9a6075A2c061dF312a35fd1E05fF22', // weETH/ETH }, AAVE_INCENTIVES: '0xd784927Ff2f95ba542BfC824c8a8a98F3495f6b5', AAVE_EMISSIONS_MGR: '0xEE56e2B3D491590B5b31738cC34d5232F378a8D5', diff --git a/contracts/plugins/assets/etherfi/KingAsset.sol b/contracts/plugins/assets/etherfi/KingAsset.sol new file mode 100644 index 000000000..29b7c9dea --- /dev/null +++ b/contracts/plugins/assets/etherfi/KingAsset.sol @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.28; + +import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol"; +import "../../../libraries/Fixed.sol"; +import "../Asset.sol"; +import "../OracleLib.sol"; +import "./vendor/IKing.sol"; + +/** + * @title KingAsset + * @notice Asset plugin for King token using ETH as intermediate pricing unit + * tok = KING + * UoA = USD + * Pricing: KING/USD = (ETH/KING from fairValueOf) * (USD/ETH from oracle) + */ +contract KingAsset is IAsset, Asset { + using FixLib for uint192; + using OracleLib for AggregatorV3Interface; + + /// @param priceTimeout_ {s} The number of seconds over which savedHighPrice decays to 0 + /// @param ethUsdChainlinkFeed_ {UoA/ref} ETH/USD price feed + /// @param oracleError_ {1} The % the oracle feed can be off by + /// @param erc20_ The King ERC20 token + /// @param maxTradeVolume_ {UoA} The max trade volume, in UoA + /// @param oracleTimeout_ {s} The number of seconds until the oracle becomes invalid + constructor( + uint48 priceTimeout_, + AggregatorV3Interface ethUsdChainlinkFeed_, + uint192 oracleError_, + IERC20Metadata erc20_, + uint192 maxTradeVolume_, + uint48 oracleTimeout_ + ) + Asset( + priceTimeout_, + ethUsdChainlinkFeed_, + oracleError_, + erc20_, + maxTradeVolume_, + oracleTimeout_ + ) + { + // Validation is handled by parent Asset contract + } + + /// Can revert, used by other contract functions in order to catch errors + /// Should not return FIX_MAX for low + /// Should only return FIX_MAX for high if low is 0 + /// Should NOT be manipulable by MEV + /// @return low {UoA/tok} The low price estimate + /// @return high {UoA/tok} The high price estimate + function tryPrice() + external + view + virtual + override + returns ( + uint192 low, + uint192 high, + uint192 + ) + { + // Note: "ref" in this context refers to ETH, used as intermediate pricing unit + // {UoA/ref} + uint192 ethUsdPrice = chainlinkFeed.price(oracleTimeout); + + // {ref/tok} + (uint256 ethValue, ) = IKing(address(erc20)).fairValueOf(10**erc20Decimals); + uint192 ethPerKing = _safeWrap(ethValue); + + // {UoA/tok} = {UoA/ref} * {ref/tok} + uint192 p = ethUsdPrice.mul(ethPerKing); + uint192 err = p.mul(oracleError, CEIL); + // assert(low <= high); obviously true just by inspection + return (p - err, p + err, 0); + } +} diff --git a/contracts/plugins/assets/etherfi/README.md b/contracts/plugins/assets/etherfi/README.md new file mode 100644 index 000000000..712c1b425 --- /dev/null +++ b/contracts/plugins/assets/etherfi/README.md @@ -0,0 +1,35 @@ +# Ether.fi weETH Collateral Plugin + +## Summary + +This plugin allows `weETH` holders to use their tokens as collateral in the Reserve Protocol. + +As described in the [Ether.fi Documentation](https://etherfi.gitbook.io/etherfi), Ether.fi is a decentralized, non-custodial liquid restaking protocol that consists of two tokens: `eETH` and `weETH`. + +Upon depositing ETH into the Ether.fi protocol, users receive `eETH` - a rebasing liquid staking token that earns staking and restaking rewards. The eETH token automatically rebases to reflect accrued rewards. Users can wrap their eETH into `weETH` (wrapped eETH), which is a non-rebasing token suitable for use in DeFi protocols and as collateral. + +`weETH` accrues revenue from **staking and restaking rewards** by **increasing** the exchange rate of `eETH` per `weETH`. This exchange rate grows over time as the Ether.fi protocol's validators earn consensus layer rewards and participate in restaking through EigenLayer. + +`eETH` contract: + +`weETH` contract: + +### Rewards + +Rewards come in the form of KING tokens, which will be distributed via an off-chain procedure and sent to the BackingManager. + +KING token: `https://etherscan.io/address/0x8F08B70456eb22f6109F57b8fafE862ED28E6040` + +## Implementation + +### Units + +| tok | ref | target | UoA | +| ----- | ---- | ------ | --- | +| weETH | eETH | ETH | USD | + +### Functions + +#### refPerTok {ref/tok} + +This function returns the rate of `eETH/weETH`, obtained from the [getRate()](https://etherscan.io/address/0xCd5fE23C85820F7B72D0926FC9b05b43E359b7ee#readProxyContract) function in the weETH contract. diff --git a/contracts/plugins/assets/etherfi/WeEthCollateral.sol b/contracts/plugins/assets/etherfi/WeEthCollateral.sol new file mode 100644 index 000000000..c8f114368 --- /dev/null +++ b/contracts/plugins/assets/etherfi/WeEthCollateral.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.28; + +import "@openzeppelin/contracts/utils/math/Math.sol"; +import "../../../libraries/Fixed.sol"; +import "../AppreciatingFiatCollateral.sol"; +import "../OracleLib.sol"; +import "./vendor/IWeETH.sol"; + +/** + * @title weETH Collateral + * @notice Collateral plugin for Ether.fi weETH + * tok = weETH + * ref = eETH (pegged to ETH 1:1) + * tar = ETH + * UoA = USD + */ +contract WeEthCollateral is AppreciatingFiatCollateral { + using OracleLib for AggregatorV3Interface; + using FixLib for uint192; + + AggregatorV3Interface public immutable targetPerTokChainlinkFeed; + uint48 public immutable targetPerTokChainlinkTimeout; + + /// @param config.chainlinkFeed {UoA/target} price of ETH in USD terms + /// @param _targetPerTokChainlinkFeed {target/tok} price of weETH in ETH terms + constructor( + CollateralConfig memory config, + uint192 revenueHiding, + AggregatorV3Interface _targetPerTokChainlinkFeed, + uint48 _targetPerTokChainlinkTimeout + ) AppreciatingFiatCollateral(config, revenueHiding) { + require(config.defaultThreshold != 0, "defaultThreshold zero"); + require(address(_targetPerTokChainlinkFeed) != address(0), "missing targetPerTok feed"); + require(_targetPerTokChainlinkTimeout != 0, "targetPerTokChainlinkTimeout zero"); + + targetPerTokChainlinkFeed = _targetPerTokChainlinkFeed; + targetPerTokChainlinkTimeout = _targetPerTokChainlinkTimeout; + maxOracleTimeout = uint48(Math.max(maxOracleTimeout, _targetPerTokChainlinkTimeout)); + } + + /// Can revert, used by other contract functions in order to catch errors + /// @return low {UoA/tok} The low price estimate + /// @return high {UoA/tok} The high price estimate + /// @return pegPrice {target/ref} The actual price observed in the peg + function tryPrice() + external + view + override + returns ( + uint192 low, + uint192 high, + uint192 pegPrice + ) + { + uint192 targetPerTok = targetPerTokChainlinkFeed.price(targetPerTokChainlinkTimeout); + + // {UoA/tok} = {UoA/target} * {target/tok} + uint192 p = chainlinkFeed.price(oracleTimeout).mul(targetPerTok); + uint192 err = p.mul(oracleError, CEIL); + + high = p + err; + low = p - err; + // assert(low <= high); obviously true just by inspection + + // {target/ref} = {target/tok} / {ref/tok} + pegPrice = targetPerTok.div(underlyingRefPerTok()); + } + + /// @return {ref/tok} Quantity of whole reference units per whole collateral tokens + function underlyingRefPerTok() public view override returns (uint192) { + return _safeWrap(IWeETH(address(erc20)).getRate()); + } +} diff --git a/contracts/plugins/assets/etherfi/vendor/IKing.sol b/contracts/plugins/assets/etherfi/vendor/IKing.sol new file mode 100644 index 000000000..29c754460 --- /dev/null +++ b/contracts/plugins/assets/etherfi/vendor/IKing.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.28; + +import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; + +// External interface for King token +interface IKing is IERC20Metadata { + /// @notice Returns the fair value in ETH and USD for an amount of KING tokens + /// @param vaultTokenShares The amount of KING tokens + /// @return ethValue The ETH value of the given KING amount + /// @return usdValue The USD value of the given KING amount + function fairValueOf(uint256 vaultTokenShares) + external + view + returns (uint256 ethValue, uint256 usdValue); +} diff --git a/contracts/plugins/assets/etherfi/vendor/ILiquidityPool.sol b/contracts/plugins/assets/etherfi/vendor/ILiquidityPool.sol new file mode 100644 index 000000000..ae01c728d --- /dev/null +++ b/contracts/plugins/assets/etherfi/vendor/ILiquidityPool.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.28; + +// External interface for Ether.fi's LiquidityPool contract +interface ILiquidityPool { + function amountForShare(uint256 _share) external view returns (uint256); + + function sharesForAmount(uint256 _amount) external view returns (uint256); + + function getTotalPooledEther() external view returns (uint256); + + function rebase(int128 _accruedRewards) external; +} diff --git a/contracts/plugins/assets/etherfi/vendor/IWeETH.sol b/contracts/plugins/assets/etherfi/vendor/IWeETH.sol new file mode 100644 index 000000000..5e334ddee --- /dev/null +++ b/contracts/plugins/assets/etherfi/vendor/IWeETH.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.28; + +import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; + +// External interface for weETH +interface IWeETH is IERC20Metadata { + function getRate() external view returns (uint256); + + function getWeETHByeETH(uint256 _eETHAmount) external view returns (uint256); + + function getEETHByWeETH(uint256 _weETHAmount) external view returns (uint256); +} diff --git a/contracts/plugins/mocks/UnpricedKingAssetMock.sol b/contracts/plugins/mocks/UnpricedKingAssetMock.sol new file mode 100644 index 000000000..592220a0f --- /dev/null +++ b/contracts/plugins/mocks/UnpricedKingAssetMock.sol @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.28; + +import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol"; +import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import "../assets/etherfi/KingAsset.sol"; +import "../assets/OracleLib.sol"; + +// Unpriced KingAsset mock for testing +contract UnpricedKingAssetMock is KingAsset { + using FixLib for uint192; + using OracleLib for AggregatorV3Interface; + + bool public unpriced = false; + + /// @param priceTimeout_ {s} The number of seconds over which savedHighPrice decays to 0 + /// @param chainlinkFeed_ Feed units: {UoA/tok} + /// @param oracleError_ {1} The % the oracle feed can be off by + /// @param maxTradeVolume_ {UoA} The max trade volume, in UoA + /// @param oracleTimeout_ {s} The number of seconds until a oracle value becomes invalid + constructor( + uint48 priceTimeout_, + AggregatorV3Interface chainlinkFeed_, + uint192 oracleError_, + IERC20Metadata erc20_, + uint192 maxTradeVolume_, + uint48 oracleTimeout_ + ) + KingAsset( + priceTimeout_, + chainlinkFeed_, + oracleError_, + erc20_, + maxTradeVolume_, + oracleTimeout_ + ) + {} + + /// tryPrice: mock unpriced by returning (0, FIX_MAX) + function tryPrice() + external + view + override + returns ( + uint192 low, + uint192 high, + uint192 + ) + { + // If unpriced is marked, return 0, FIX_MAX + if (unpriced) return (0, FIX_MAX, 0); + + uint192 ethUsdPrice = chainlinkFeed.price(oracleTimeout); // {UoA/ref} + (uint256 ethValue, ) = IKing(address(erc20)).fairValueOf(10**erc20Decimals); + uint192 ethPerKing = _safeWrap(ethValue); // {ref/tok} + uint192 p = ethUsdPrice.mul(ethPerKing); // {UoA/tok} + uint192 delta = p.mul(oracleError, CEIL); + return (p - delta, p + delta, 0); + } + + function setUnpriced(bool on) external { + unpriced = on; + } +} diff --git a/contracts/plugins/mocks/WeETHMock.sol b/contracts/plugins/mocks/WeETHMock.sol new file mode 100644 index 000000000..a3b288813 --- /dev/null +++ b/contracts/plugins/mocks/WeETHMock.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.28; + +import "./ERC20Mock.sol"; + +contract WeEthMock is ERC20Mock { + uint256 private _rate; + + constructor() ERC20Mock("Mock WeETH", "WeEth") {} + + // Mock function for testing + function setRate(uint256 mockRate) external { + _rate = mockRate; + } + + function getRate() external view returns (uint256) { + return _rate; + } +} diff --git a/scripts/addresses/1-tmp-assets-collateral.json b/scripts/addresses/1-tmp-assets-collateral.json index aa2ffacd6..07229367d 100644 --- a/scripts/addresses/1-tmp-assets-collateral.json +++ b/scripts/addresses/1-tmp-assets-collateral.json @@ -3,7 +3,8 @@ "stkAAVE": "0xFDE702794298DB19e2a235782B82aD88053F7335", "COMP": "0xA32a92073fEB7ed31081656DeFF34518FB5194b9", "CRV": "0x69841bA9E09019acA0d16Ae9c9724D25d51F6956", - "CVX": "0x2635c3B92c8451F9D1e75BD61FCF87D1eCdf0ad0" + "CVX": "0x2635c3B92c8451F9D1e75BD61FCF87D1eCdf0ad0", + "KING": "0xe64ca4AC2401D6D57cEE942B9ee01494814803f1" }, "collateral": { "DAI": "0x8A782e182EeE2299B3DB733659ea764A5a97AdC5", @@ -55,7 +56,8 @@ "sUSDS": "0x4FD189996b5344Eb4CF9c749b97C7424D399d24e", "wOETH": "0xBFAc3e99263B7aE9704eC1c879f7c0a57C6b53e1", "pyUSD": "0x9A65173df5D5B86E26300Cc9cA5Ff378be6DAeA5", - "saEthRLUSD": "0xb1e61f452CFcF6609C2F4088EC36B4c8dd1806b5" + "saEthRLUSD": "0xb1e61f452CFcF6609C2F4088EC36B4c8dd1806b5", + "weETH": "0x9dc6cEFC09b0917c78a05148d45f6e6594e227de" }, "erc20s": { "stkAAVE": "0x4da27a545c0c5B758a6BA100e3a049001de870f5", @@ -111,6 +113,8 @@ "sUSDS": "0xa3931d71877C0E7a3148CB7Eb4463524FEc27fbD", "wOETH": "0xDcEe70654261AF21C44c093C300eD3Bb97b78192", "pyUSD": "0x6c3ea9036406852006290770bedfcaba0e23a0e8", - "saEthRLUSD": "0x4C813CE4e2FF315f0213563A994c20BBF4637444" + "saEthRLUSD": "0x4C813CE4e2FF315f0213563A994c20BBF4637444", + "weETH": "0xCd5fE23C85820F7B72D0926FC9b05b43E359b7ee", + "KING": "0x8F08B70456eb22f6109F57b8fafE862ED28E6040" } } diff --git a/scripts/addresses/mainnet-4.2.0/1-tmp-assets-collateral.json b/scripts/addresses/mainnet-4.2.0/1-tmp-assets-collateral.json index aa2ffacd6..07229367d 100644 --- a/scripts/addresses/mainnet-4.2.0/1-tmp-assets-collateral.json +++ b/scripts/addresses/mainnet-4.2.0/1-tmp-assets-collateral.json @@ -3,7 +3,8 @@ "stkAAVE": "0xFDE702794298DB19e2a235782B82aD88053F7335", "COMP": "0xA32a92073fEB7ed31081656DeFF34518FB5194b9", "CRV": "0x69841bA9E09019acA0d16Ae9c9724D25d51F6956", - "CVX": "0x2635c3B92c8451F9D1e75BD61FCF87D1eCdf0ad0" + "CVX": "0x2635c3B92c8451F9D1e75BD61FCF87D1eCdf0ad0", + "KING": "0xe64ca4AC2401D6D57cEE942B9ee01494814803f1" }, "collateral": { "DAI": "0x8A782e182EeE2299B3DB733659ea764A5a97AdC5", @@ -55,7 +56,8 @@ "sUSDS": "0x4FD189996b5344Eb4CF9c749b97C7424D399d24e", "wOETH": "0xBFAc3e99263B7aE9704eC1c879f7c0a57C6b53e1", "pyUSD": "0x9A65173df5D5B86E26300Cc9cA5Ff378be6DAeA5", - "saEthRLUSD": "0xb1e61f452CFcF6609C2F4088EC36B4c8dd1806b5" + "saEthRLUSD": "0xb1e61f452CFcF6609C2F4088EC36B4c8dd1806b5", + "weETH": "0x9dc6cEFC09b0917c78a05148d45f6e6594e227de" }, "erc20s": { "stkAAVE": "0x4da27a545c0c5B758a6BA100e3a049001de870f5", @@ -111,6 +113,8 @@ "sUSDS": "0xa3931d71877C0E7a3148CB7Eb4463524FEc27fbD", "wOETH": "0xDcEe70654261AF21C44c093C300eD3Bb97b78192", "pyUSD": "0x6c3ea9036406852006290770bedfcaba0e23a0e8", - "saEthRLUSD": "0x4C813CE4e2FF315f0213563A994c20BBF4637444" + "saEthRLUSD": "0x4C813CE4e2FF315f0213563A994c20BBF4637444", + "weETH": "0xCd5fE23C85820F7B72D0926FC9b05b43E359b7ee", + "KING": "0x8F08B70456eb22f6109F57b8fafE862ED28E6040" } } diff --git a/scripts/deploy.ts b/scripts/deploy.ts index a6bc835c2..e2b90c3b7 100644 --- a/scripts/deploy.ts +++ b/scripts/deploy.ts @@ -95,7 +95,9 @@ async function main() { 'phase2-assets/assets/deploy_cvx.ts', 'phase2-assets/collaterals/deploy_sky_susds.ts', 'phase2-assets/collaterals/deploy_origin_oeth.ts', - 'phase2-assets/collaterals/deploy_pyusd.ts' + 'phase2-assets/collaterals/deploy_pyusd.ts', + 'phase2-assets/collaterals/deploy_weeth.ts', + 'phase2-assets/assets/deploy_king.ts' ) } else if (chainId == '8453' || chainId == '84531') { // Base L2 chains diff --git a/scripts/deployment/phase2-assets/assets/deploy_king.ts b/scripts/deployment/phase2-assets/assets/deploy_king.ts new file mode 100644 index 000000000..0dacddd83 --- /dev/null +++ b/scripts/deployment/phase2-assets/assets/deploy_king.ts @@ -0,0 +1,70 @@ +import fs from 'fs' +import hre from 'hardhat' +import { getChainId } from '../../../../common/blockchain-utils' +import { networkConfig } from '../../../../common/configuration' +import { fp } from '../../../../common/numbers' +import { + getDeploymentFile, + getDeploymentFilename, + getAssetCollDeploymentFilename, + IAssetCollDeployments, + fileExists, +} from '../../../deployment/common' +import { KingAsset } from '../../../../typechain' +import { priceTimeout } from '../../../deployment/utils' +import { ETH_ORACLE_TIMEOUT } from '../../../../test/plugins/individual-collateral/etherfi/constants' + +async function main() { + // ==== Read Configuration ==== + const [deployer] = await hre.ethers.getSigners() + const chainId = await getChainId(hre) + + console.log(`Deploying KING asset to network ${hre.network.name} (${chainId}) + with burner account: ${deployer.address}`) + + if (!networkConfig[chainId]) { + throw new Error(`Missing network configuration for ${hre.network.name}`) + } + + // Get phase1 deployment + const phase1File = getDeploymentFilename(chainId) + if (!fileExists(phase1File)) { + throw new Error(`${phase1File} doesn't exist yet. Run phase 1`) + } + // Check previous step completed + const assetCollDeploymentFilename = getAssetCollDeploymentFilename(chainId) + const assetCollDeployments = getDeploymentFile(assetCollDeploymentFilename) + + const deployedAssets: string[] = [] + + /******** Deploy KING asset **************************/ + + const KingAssetFactory = await hre.ethers.getContractFactory('KingAsset') + const kingAsset = await KingAssetFactory.connect(deployer).deploy( + priceTimeout, + networkConfig[chainId].chainlinkFeeds.ETH!, + fp('0.04').toString(), // 4% Oracle error + networkConfig[chainId].tokens.KING!, + fp('1e5').toString(), // $100K + ETH_ORACLE_TIMEOUT + ) + await kingAsset.deployed() + await (await kingAsset.refresh({ gasLimit: 3_000_000 })).wait() + + assetCollDeployments.assets.KING = kingAsset.address + assetCollDeployments.erc20s.KING = networkConfig[chainId].tokens.KING + deployedAssets.push(kingAsset.address) + + /**************************************************************/ + + fs.writeFileSync(assetCollDeploymentFilename, JSON.stringify(assetCollDeployments, null, 2)) + + console.log(`Deployed KING asset to ${hre.network.name} (${chainId}): + New deployments: ${deployedAssets} + Deployment file: ${assetCollDeploymentFilename}`) +} + +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/scripts/deployment/phase2-assets/collaterals/deploy_weeth.ts b/scripts/deployment/phase2-assets/collaterals/deploy_weeth.ts new file mode 100644 index 000000000..a5414007b --- /dev/null +++ b/scripts/deployment/phase2-assets/collaterals/deploy_weeth.ts @@ -0,0 +1,95 @@ +import fs from 'fs' +import hre from 'hardhat' +import { getChainId } from '../../../../common/blockchain-utils' +import { networkConfig } from '../../../../common/configuration' +import { fp } from '../../../../common/numbers' +import { expect } from 'chai' +import { CollateralStatus } from '../../../../common/constants' +import { + getDeploymentFile, + getAssetCollDeploymentFilename, + IAssetCollDeployments, + getDeploymentFilename, + fileExists, +} from '../../common' +import { priceTimeout, combinedError } from '../../utils' +import { WeEthCollateral } from '../../../../typechain' +import { + ETH_ORACLE_ERROR, + ETH_ORACLE_TIMEOUT, + WEETH_ORACLE_ERROR, + WEETH_ORACLE_TIMEOUT, + DELAY_UNTIL_DEFAULT, +} from '../../../../test/plugins/individual-collateral/etherfi/constants' +import { ContractFactory } from 'ethers' + +async function main() { + // ==== Read Configuration ==== + const [deployer] = await hre.ethers.getSigners() + + const chainId = await getChainId(hre) + + console.log(`Deploying Collateral to network ${hre.network.name} (${chainId}) + with burner account: ${deployer.address}`) + + if (!networkConfig[chainId]) { + throw new Error(`Missing network configuration for ${hre.network.name}`) + } + + // Get phase1 deployment + const phase1File = getDeploymentFilename(chainId) + if (!fileExists(phase1File)) { + throw new Error(`${phase1File} doesn't exist yet. Run phase 1`) + } + // Check previous step completed + const assetCollDeploymentFilename = getAssetCollDeploymentFilename(chainId) + const assetCollDeployments = getDeploymentFile(assetCollDeploymentFilename) + + const deployedCollateral: string[] = [] + + /******** Deploy EtherFI weETH Collateral - weETH **************************/ + + const WeEthCollateralFactory: ContractFactory = + await hre.ethers.getContractFactory('WeEthCollateral') + + const oracleError = combinedError(ETH_ORACLE_ERROR, WEETH_ORACLE_ERROR) // 0.5% & 0.5% + + const collateral = await WeEthCollateralFactory.connect( + deployer + ).deploy( + { + priceTimeout: priceTimeout.toString(), + chainlinkFeed: networkConfig[chainId].chainlinkFeeds.ETH, + oracleError: oracleError.toString(), + erc20: networkConfig[chainId].tokens.weETH, + maxTradeVolume: fp('1e6').toString(), // $1m, + oracleTimeout: ETH_ORACLE_TIMEOUT.toString(), // 1 hr, + targetName: hre.ethers.utils.formatBytes32String('ETH'), + defaultThreshold: fp('0.02').add(WEETH_ORACLE_ERROR).toString(), // 2.5% + delayUntilDefault: DELAY_UNTIL_DEFAULT.toString(), // 72h + }, + fp('1e-4').toString(), // revenueHiding = 0.01% + networkConfig[chainId].chainlinkFeeds.weETH, // targetPerTokChainlinkFeed + WEETH_ORACLE_TIMEOUT.toString() // targetPerTokChainlinkTimeout - 24h + ) + await collateral.deployed() + await (await collateral.refresh()).wait() + expect(await collateral.status()).to.equal(CollateralStatus.SOUND) + + console.log(`Deployed WeETH to ${hre.network.name} (${chainId}): ${collateral.address}`) + + assetCollDeployments.collateral.weETH = collateral.address + assetCollDeployments.erc20s.weETH = networkConfig[chainId].tokens.weETH + deployedCollateral.push(collateral.address.toString()) + + fs.writeFileSync(assetCollDeploymentFilename, JSON.stringify(assetCollDeployments, null, 2)) + + console.log(`Deployed collateral to ${hre.network.name} (${chainId}) + New deployments: ${deployedCollateral} + Deployment file: ${assetCollDeploymentFilename}`) +} + +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/scripts/verification/assets/verify_king.ts b/scripts/verification/assets/verify_king.ts new file mode 100644 index 000000000..0779ade48 --- /dev/null +++ b/scripts/verification/assets/verify_king.ts @@ -0,0 +1,49 @@ +import hre from 'hardhat' + +import { getChainId } from '../../../common/blockchain-utils' +import { developmentChains, networkConfig } from '../../../common/configuration' +import { + getAssetCollDeploymentFilename, + getDeploymentFile, + IAssetCollDeployments, +} from '../../deployment/common' +import { verifyContract } from '../../deployment/utils' +import { fp } from '../../../common/numbers' + +let deployments: IAssetCollDeployments + +async function main() { + // ********** Read config ********** + const chainId = await getChainId(hre) + if (!networkConfig[chainId]) { + throw new Error(`Missing network configuration for ${hre.network.name}`) + } + + if (developmentChains.includes(hre.network.name)) { + throw new Error(`Cannot verify contracts for development chain ${hre.network.name}`) + } + + deployments = getDeploymentFile(getAssetCollDeploymentFilename(chainId)) + + const kingAsset = await hre.ethers.getContractAt('KingAsset', deployments.assets.KING!) + + /** ******************** Verify KING Asset ****************************************/ + await verifyContract( + chainId, + deployments.assets.KING, + [ + (await kingAsset.priceTimeout()).toString(), + await kingAsset.chainlinkFeed(), + fp('0.04').toString(), // 4% oracle error + await kingAsset.erc20(), + (await kingAsset.maxTradeVolume()).toString(), + (await kingAsset.oracleTimeout()).toString(), + ], + 'contracts/plugins/assets/etherfi/KingAsset.sol:KingAsset' + ) +} + +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/scripts/verification/collateral-plugins/verify_weeth.ts b/scripts/verification/collateral-plugins/verify_weeth.ts new file mode 100644 index 000000000..ccf2d00b3 --- /dev/null +++ b/scripts/verification/collateral-plugins/verify_weeth.ts @@ -0,0 +1,63 @@ +import hre from 'hardhat' +import { getChainId } from '../../../common/blockchain-utils' +import { developmentChains, networkConfig } from '../../../common/configuration' +import { fp } from '../../../common/numbers' +import { + getDeploymentFile, + getAssetCollDeploymentFilename, + IAssetCollDeployments, +} from '../../deployment/common' +import { + ETH_ORACLE_TIMEOUT, + ETH_ORACLE_ERROR, + DELAY_UNTIL_DEFAULT, + WEETH_ORACLE_ERROR, + WEETH_ORACLE_TIMEOUT, +} from '../../../test/plugins/individual-collateral/etherfi/constants' +import { priceTimeout, verifyContract, combinedError } from '../../deployment/utils' + +let deployments: IAssetCollDeployments + +async function main() { + // ********** Read config ********** + const chainId = await getChainId(hre) + if (!networkConfig[chainId]) { + throw new Error(`Missing network configuration for ${hre.network.name}`) + } + + if (developmentChains.includes(hre.network.name)) { + throw new Error(`Cannot verify contracts for development chain ${hre.network.name}`) + } + + const assetCollDeploymentFilename = getAssetCollDeploymentFilename(chainId) + deployments = getDeploymentFile(assetCollDeploymentFilename) + + /******** Verify WeETH - weETH **************************/ + const oracleError = combinedError(ETH_ORACLE_ERROR, WEETH_ORACLE_ERROR) // 0.5% & 0.5% + await verifyContract( + chainId, + deployments.collateral.weETH, + [ + { + priceTimeout: priceTimeout.toString(), + chainlinkFeed: networkConfig[chainId].chainlinkFeeds.ETH, + oracleError: oracleError.toString(), // 0.5% & 0.5%, + erc20: networkConfig[chainId].tokens.weETH, + maxTradeVolume: fp('1e6').toString(), // $1m, + oracleTimeout: ETH_ORACLE_TIMEOUT.toString(), // 1 hr, + targetName: hre.ethers.utils.formatBytes32String('ETH'), + defaultThreshold: fp('0.02').add(WEETH_ORACLE_ERROR).toString(), // 2.5% + delayUntilDefault: DELAY_UNTIL_DEFAULT.toString(), // 72h + }, + fp('1e-4'), // revenueHiding = 0.01% + networkConfig[chainId].chainlinkFeeds.weETH, // targetPerTokChainlinkFeed + WEETH_ORACLE_TIMEOUT.toString(), // targetPerTokChainlinkTimeout + ], + 'contracts/plugins/assets/etherfi/WeEthCollateral.sol:WeEthCollateral' + ) +} + +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/scripts/verify_etherscan.ts b/scripts/verify_etherscan.ts index 86346d03d..dec65add7 100644 --- a/scripts/verify_etherscan.ts +++ b/scripts/verify_etherscan.ts @@ -78,7 +78,9 @@ async function main() { 'collateral-plugins/verify_apxeth.ts', 'collateral-plugins/verify_USDe.ts', 'collateral-plugins/verify_susds.ts', - 'collateral-plugins/verify_oeth.ts' + 'collateral-plugins/verify_oeth.ts', + 'collateral-plugins/verify_weeth.ts', + 'assets/verify_king.ts' ) } else if (chainId == '8453' || chainId == '84531') { // Base L2 chains diff --git a/test/plugins/individual-collateral/etherfi/KingAsset.test.ts b/test/plugins/individual-collateral/etherfi/KingAsset.test.ts new file mode 100644 index 000000000..6609f536e --- /dev/null +++ b/test/plugins/individual-collateral/etherfi/KingAsset.test.ts @@ -0,0 +1,509 @@ +import { expect } from 'chai' +import { Wallet, ContractFactory, BigNumber } from 'ethers' +import hre, { ethers } from 'hardhat' +import { networkConfig } from '../../../../common/configuration' +import { getChainId } from '../../../../common/blockchain-utils' +import { advanceTime, getLatestBlockTimestamp, advanceToTimestamp } from '../../../utils/time' +import { ZERO_ADDRESS, ONE_ADDRESS, MAX_UINT192 } from '../../../../common/constants' +import { bn, fp } from '../../../../common/numbers' +import { + expectDecayedPrice, + expectExactPrice, + expectPrice, + expectUnpriced, + setInvalidOracleAnsweredRound, + setInvalidOracleTimestamp, + setOraclePrice, +} from '../../../utils/oracles' +import { + Asset, + InvalidMockV3Aggregator, + KingAsset, + ERC20Mock, + UnpricedKingAssetMock, + MockV3Aggregator, +} from '../../../../typechain' +import { VERSION } from '../../../fixtures' +import { useEnv } from '#/utils/env' +import { + KING, + ETH_USD_PRICE_FEED, + ETH_ORACLE_ERROR, + ETH_ORACLE_TIMEOUT, + PRICE_TIMEOUT, + FORK_BLOCK, +} from './constants' + +let chainId: string + +// Setup test environment +const setup = async (blockNumber: number) => { + // Use Mainnet fork + await hre.network.provider.request({ + method: 'hardhat_reset', + params: [ + { + forking: { + jsonRpcUrl: useEnv('MAINNET_RPC_URL'), + blockNumber: blockNumber, + }, + }, + ], + }) +} + +const describeFork = useEnv('FORK') ? describe : describe.skip + +const MAX_TRADE_VOLUME = fp('1e6') +const DECAY_DELAY = ETH_ORACLE_TIMEOUT.add(310) + +describeFork('King Asset #fast', () => { + // Tokens + let king: ERC20Mock + + // Assets + let kingAsset: KingAsset + + // Main + let wallet: Wallet + + // Factory + let KingAssetFactory: ContractFactory + + // Oracle + let ethUsdOracle: MockV3Aggregator + + // ETH/USD price + let ethPrice: BigNumber + + before(async () => { + await setup(FORK_BLOCK) + ;[wallet] = (await ethers.getSigners()) as unknown as Wallet[] + + chainId = await getChainId(hre) + + if (!networkConfig[chainId]) { + throw new Error(`Missing network configuration for ${hre.network.name}`) + } + }) + + beforeEach(async () => { + await setup(FORK_BLOCK) + + // Set King token + king = await ethers.getContractAt('ERC20Mock', KING) + + // Get ETH/USD price from oracle + const ethOracle = await ethers.getContractAt('AggregatorV3Interface', ETH_USD_PRICE_FEED) + ethPrice = (await ethOracle.latestRoundData()).answer + + // Deploy MockV3Aggregator + const MockV3AggregatorFactory = await ethers.getContractFactory('MockV3Aggregator') + ethUsdOracle = await MockV3AggregatorFactory.deploy(8, ethPrice) + await ethUsdOracle.deployed() + + // Update answer to set fresh timestamp + await ethUsdOracle.updateAnswer(ethPrice) + + // Deploy KingAsset + KingAssetFactory = await ethers.getContractFactory('KingAsset') + kingAsset = ( + await KingAssetFactory.deploy( + PRICE_TIMEOUT, + ethUsdOracle.address, + ETH_ORACLE_ERROR, + king.address, + MAX_TRADE_VOLUME, + ETH_ORACLE_TIMEOUT + ) + ) + await kingAsset.deployed() + await kingAsset.refresh() + }) + + describe('Deployment', () => { + it('Deployment should setup King asset correctly', async () => { + // KING Asset + expect(await kingAsset.isCollateral()).to.equal(false) + expect(await kingAsset.erc20()).to.equal(king.address) + expect(await king.decimals()).to.equal(18) + expect(await kingAsset.version()).to.equal(VERSION) + expect(await kingAsset.maxTradeVolume()).to.equal(MAX_TRADE_VOLUME) + // price is approx $506 usd at block 23841545 + await expectPrice(kingAsset.address, fp('506.18'), ETH_ORACLE_ERROR, true, bn('1e4')) + await expect(kingAsset.claimRewards()).to.not.emit(kingAsset, 'RewardsClaimed') + }) + }) + + describe('Prices', () => { + it('Should increase price when ETH/USD price increases', async () => { + // Get initial prices + const [initialLow, initialHigh] = await kingAsset.price() + + // Increase Eth/USD Oracle price + const newEthPrice = ethPrice.mul(110).div(100) + await setOraclePrice(kingAsset.address, newEthPrice) + + // Get new prices + const [newLow, newHigh] = await kingAsset.price() + + // Verify prices increased (both low and high) + expect(newLow).to.be.gt(initialLow) + expect(newHigh).to.be.gt(initialHigh) + }) + + it('Should become unpriced if price is zero', async () => { + const kingInitPrice = await kingAsset.price() + + // Update values in Oracles to 0 + await setOraclePrice(kingAsset.address, bn('0')) + + // Fallback prices should be initial prices + await expectExactPrice(kingAsset.address, kingInitPrice) + + // Advance past oracle timeout + await advanceTime(DECAY_DELAY.add(1).toString()) + await setOraclePrice(kingAsset.address, bn('0')) + await kingAsset.refresh() + + // Prices should be decaying + await expectDecayedPrice(kingAsset.address) + + // After price timeout, should be unpriced + await advanceTime(PRICE_TIMEOUT.toString()) + await setOraclePrice(kingAsset.address, bn('0')) + + // Should be unpriced now + await expectUnpriced(kingAsset.address) + }) + + it('Should calculate trade min correctly', async () => { + // Check initial values + expect(await kingAsset.maxTradeVolume()).to.equal(MAX_TRADE_VOLUME) + + // Reduce price - maintains max size + await setOraclePrice(kingAsset.address, ethPrice.div(2)) // half + expect(await kingAsset.maxTradeVolume()).to.equal(MAX_TRADE_VOLUME) + }) + + it('Should remain at saved price if oracle is stale', async () => { + // Save initial price + const initialPrice = await kingAsset.price() + + await advanceTime(DECAY_DELAY.sub(12).toString()) + + // lastSave should not be block timestamp after refresh + await kingAsset.refresh() + expect(await kingAsset.lastSave()).to.not.equal(await getLatestBlockTimestamp()) + + // Check price is still at saved price + await expectExactPrice(kingAsset.address, initialPrice) + }) + + it('Should remain at saved price in case of invalid timestamp', async () => { + // Save initial price + const initialPrice = await kingAsset.price() + + await setInvalidOracleTimestamp(kingAsset.address) + + // lastSave should not be block timestamp after refresh + await kingAsset.refresh() + expect(await kingAsset.lastSave()).to.not.equal(await getLatestBlockTimestamp()) + + // Check price is still at saved price + await expectExactPrice(kingAsset.address, initialPrice) + }) + + it('Should remain at saved price in case of invalid answered round', async () => { + // Save initial price + const initialPrice = await kingAsset.price() + + await setInvalidOracleAnsweredRound(kingAsset.address) + + // lastSave should not be block timestamp after refresh + await kingAsset.refresh() + expect(await kingAsset.lastSave()).to.not.equal(await getLatestBlockTimestamp()) + + // Check price is still at saved price + await expectExactPrice(kingAsset.address, initialPrice) + }) + + it('Should be able to refresh saved prices', async () => { + // Check initial prices + let currBlockTimestamp: number = await getLatestBlockTimestamp() + let [lowPrice, highPrice] = await kingAsset.price() + expect(await kingAsset.savedLowPrice()).to.equal(lowPrice) + expect(await kingAsset.savedHighPrice()).to.equal(highPrice) + expect(await kingAsset.lastSave()).to.equal(currBlockTimestamp) + + // Refresh saved prices again + await kingAsset.refresh() + + // Check values remain but timestamp was updated + const [lowPrice2, highPrice2] = await kingAsset.price() + expect(lowPrice2).to.equal(lowPrice) + expect(highPrice2).to.equal(highPrice) + expect(await kingAsset.savedLowPrice()).to.equal(lowPrice2) + expect(await kingAsset.savedHighPrice()).to.equal(highPrice2) + currBlockTimestamp = await getLatestBlockTimestamp() + expect(await kingAsset.lastSave()).to.equal(currBlockTimestamp) + + // Increase Eth/USD Oracle price + const newEthPrice = ethPrice.mul(120).div(100) + await setOraclePrice(kingAsset.address, newEthPrice) + + // Before calling refresh we still have the old saved values + ;[lowPrice, highPrice] = await kingAsset.price() + expect(await kingAsset.savedLowPrice()).to.be.lt(lowPrice) + expect(await kingAsset.savedHighPrice()).to.be.lt(highPrice) + + // Refresh prices - Should save new values + await kingAsset.refresh() + + // Check new prices were stored + ;[lowPrice, highPrice] = await kingAsset.price() + expect(await kingAsset.savedLowPrice()).to.equal(lowPrice) + expect(await kingAsset.savedHighPrice()).to.equal(highPrice) + currBlockTimestamp = await getLatestBlockTimestamp() + expect(await kingAsset.lastSave()).to.equal(currBlockTimestamp) + + expect(lowPrice).to.be.gt(lowPrice2) + expect(highPrice).to.be.gt(highPrice2) + }) + + it('Should not save prices if try/price returns unpriced', async () => { + const UnpricedKingAssetFactory = await ethers.getContractFactory('UnpricedKingAssetMock') + const unpricedKingAsset: UnpricedKingAssetMock = ( + await UnpricedKingAssetFactory.deploy( + PRICE_TIMEOUT, + await kingAsset.chainlinkFeed(), + ETH_ORACLE_ERROR, + king.address, + MAX_TRADE_VOLUME, + ETH_ORACLE_TIMEOUT + ) + ) + + // Save prices + await unpricedKingAsset.refresh() + + // Check initial prices + let currBlockTimestamp: number = await getLatestBlockTimestamp() + let [lowPrice, highPrice] = await unpricedKingAsset.price() + expect(await unpricedKingAsset.savedLowPrice()).to.equal(lowPrice) + expect(await unpricedKingAsset.savedHighPrice()).to.equal(highPrice) + expect(await unpricedKingAsset.lastSave()).to.be.equal(currBlockTimestamp) + + // Refresh saved prices + await unpricedKingAsset.refresh() + + // Check values remain but timestamp was updated + const [lowPrice2, highPrice2] = await unpricedKingAsset.price() + expect(lowPrice2).to.equal(lowPrice) + expect(highPrice2).to.equal(highPrice) + ;[lowPrice, highPrice] = await unpricedKingAsset.price() + expect(await unpricedKingAsset.savedLowPrice()).to.equal(lowPrice) + expect(await unpricedKingAsset.savedHighPrice()).to.equal(highPrice) + currBlockTimestamp = await getLatestBlockTimestamp() + expect(await unpricedKingAsset.lastSave()).to.equal(currBlockTimestamp) + + // Set as unpriced so it returns 0,FIX MAX in try/price + await unpricedKingAsset.setUnpriced(true) + + // Check that now is unpriced + await expectUnpriced(unpricedKingAsset.address) + + // Refreshing would not save the new rates + await unpricedKingAsset.refresh() + expect(await unpricedKingAsset.savedLowPrice()).to.equal(lowPrice) + expect(await unpricedKingAsset.savedHighPrice()).to.equal(highPrice) + expect(await unpricedKingAsset.lastSave()).to.equal(currBlockTimestamp) + }) + + it('Should not revert on refresh if stale', async () => { + // Check initial prices + const startBlockTimestamp: number = await getLatestBlockTimestamp() + const [prevLowPrice, prevHighPrice] = await kingAsset.price() + await expectPrice(kingAsset.address, fp('506.18'), ETH_ORACLE_ERROR, true, bn('1e4')) + expect(await kingAsset.savedLowPrice()).to.equal(prevLowPrice) + expect(await kingAsset.savedHighPrice()).to.equal(prevHighPrice) + expect(await kingAsset.lastSave()).to.equal(startBlockTimestamp) + + // Set invalid oracle + await setInvalidOracleTimestamp(kingAsset.address) + + // Check price - uses still previous prices + await kingAsset.refresh() + let [lowPrice, highPrice] = await kingAsset.price() + expect(lowPrice).to.equal(prevLowPrice) + expect(highPrice).to.equal(prevHighPrice) + expect(await kingAsset.savedLowPrice()).to.equal(prevLowPrice) + expect(await kingAsset.savedHighPrice()).to.equal(prevHighPrice) + expect(await kingAsset.lastSave()).to.equal(startBlockTimestamp) + + // Check price - no update on prices/timestamp + await kingAsset.refresh() + ;[lowPrice, highPrice] = await kingAsset.price() + expect(lowPrice).to.equal(prevLowPrice) + expect(highPrice).to.equal(prevHighPrice) + expect(await kingAsset.savedLowPrice()).to.equal(prevLowPrice) + expect(await kingAsset.savedHighPrice()).to.equal(prevHighPrice) + expect(await kingAsset.lastSave()).to.equal(startBlockTimestamp) + }) + + it('Reverts if Chainlink feed reverts or runs out of gas', async () => { + const InvalidMockV3AggregatorFactory = await ethers.getContractFactory( + 'InvalidMockV3Aggregator' + ) + const invalidChainlinkFeed: InvalidMockV3Aggregator = ( + await InvalidMockV3AggregatorFactory.deploy(8, bn('1e8')) + ) + + const invalidKingAsset: Asset = ( + await KingAssetFactory.deploy( + PRICE_TIMEOUT, + invalidChainlinkFeed.address, + ETH_ORACLE_ERROR, + king.address, + MAX_TRADE_VOLUME, + ETH_ORACLE_TIMEOUT + ) + ) + + // Reverting with no reason + await invalidChainlinkFeed.setSimplyRevert(true) + await expect(invalidKingAsset.price()).to.be.reverted + await expect(invalidKingAsset.refresh()).to.be.reverted + + // Runnning out of gas (same error) + await invalidChainlinkFeed.setSimplyRevert(false) + await expect(invalidKingAsset.price()).to.be.reverted + await expect(invalidKingAsset.refresh()).to.be.reverted + }) + + it('Bubbles error up if Chainlink feed reverts for explicit reason', async () => { + // Applies to all collateral as well + const InvalidMockV3AggregatorFactory = await ethers.getContractFactory( + 'InvalidMockV3Aggregator' + ) + const invalidChainlinkFeed: InvalidMockV3Aggregator = ( + await InvalidMockV3AggregatorFactory.deploy(8, bn('1e8')) + ) + + const invalidKingAsset: Asset = ( + await KingAssetFactory.deploy( + PRICE_TIMEOUT, + invalidChainlinkFeed.address, + ETH_ORACLE_ERROR, + king.address, + MAX_TRADE_VOLUME, + ETH_ORACLE_TIMEOUT + ) + ) + + // Reverting with reason + await invalidChainlinkFeed.setRevertWithExplicitError(true) + await expect(invalidKingAsset.tryPrice()).to.be.revertedWith('oracle explicit error') + }) + + it('Should handle price decay correctly', async () => { + await kingAsset.refresh() + + // Check prices + const startBlockTimestamp: number = await getLatestBlockTimestamp() + const [prevLowPrice, prevHighPrice] = await kingAsset.price() + expect(await kingAsset.savedLowPrice()).to.equal(prevLowPrice) + expect(await kingAsset.savedHighPrice()).to.equal(prevHighPrice) + expect(await kingAsset.lastSave()).to.equal(startBlockTimestamp) + + // Set invalid oracle + await setInvalidOracleTimestamp(kingAsset.address) + + // Check unpriced - uses still previous prices + const [lowPrice, highPrice] = await kingAsset.price() + expect(lowPrice).to.equal(prevLowPrice) + expect(highPrice).to.equal(prevHighPrice) + expect(await kingAsset.savedLowPrice()).to.equal(prevLowPrice) + expect(await kingAsset.savedHighPrice()).to.equal(prevHighPrice) + expect(await kingAsset.lastSave()).to.equal(startBlockTimestamp) + + // At first price doesn't decrease + const [lowPrice2, highPrice2] = await kingAsset.price() + expect(lowPrice2).to.eq(lowPrice) + expect(highPrice2).to.eq(highPrice) + + // Advance past oracleTimeout + await advanceTime(DECAY_DELAY.toString()) + + // Now price widens + const [lowPrice3, highPrice3] = await kingAsset.price() + expect(lowPrice3).to.be.lt(lowPrice2) + expect(highPrice3).to.be.gt(highPrice2) + + // Advance block, price keeps widening + await advanceToTimestamp((await getLatestBlockTimestamp()) + 12) + const [lowPrice4, highPrice4] = await kingAsset.price() + expect(lowPrice4).to.be.lt(lowPrice3) + expect(highPrice4).to.be.gt(highPrice3) + + // Advance blocks beyond PRICE_TIMEOUT; price should be [O, FIX_MAX] + await advanceTime(PRICE_TIMEOUT.toNumber()) + + // Lot price returns 0 once time elapses + const [lowPrice5, highPrice5] = await kingAsset.price() + expect(lowPrice5).to.be.lt(lowPrice4) + expect(highPrice5).to.be.gt(highPrice4) + expect(lowPrice5).to.be.equal(bn(0)) + expect(highPrice5).to.be.equal(MAX_UINT192) + }) + + it('lotPrice (deprecated) is equal to price()', async () => { + for (const asset of [kingAsset]) { + const lotPrice = await asset.lotPrice() + const price = await asset.price() + expect(price.length).to.equal(2) + expect(lotPrice.length).to.equal(price.length) + expect(lotPrice[0]).to.equal(price[0]) + expect(lotPrice[1]).to.equal(price[1]) + } + }) + }) + + describe('Constructor validation', () => { + it('Should not allow price timeout to be zero', async () => { + await expect( + KingAssetFactory.deploy(0, ONE_ADDRESS, 0, ONE_ADDRESS, MAX_TRADE_VOLUME, 0) + ).to.be.revertedWith('price timeout zero') + }) + it('Should not allow missing chainlink feed', async () => { + await expect( + KingAssetFactory.deploy(1, ZERO_ADDRESS, 0, ONE_ADDRESS, MAX_TRADE_VOLUME, 1) + ).to.be.revertedWith('missing chainlink feed') + }) + it('Should not allow missing erc20', async () => { + await expect( + KingAssetFactory.deploy(1, ONE_ADDRESS, 1, ZERO_ADDRESS, MAX_TRADE_VOLUME, 1) + ).to.be.revertedWith('missing erc20') + }) + it('Should not allow 0 oracleError', async () => { + await expect( + KingAssetFactory.deploy(1, ONE_ADDRESS, 0, ONE_ADDRESS, MAX_TRADE_VOLUME, 1) + ).to.be.revertedWith('oracle error out of range') + }) + it('Should not allow FIX_ONE oracleError', async () => { + await expect( + KingAssetFactory.deploy(1, ONE_ADDRESS, fp('1'), ONE_ADDRESS, MAX_TRADE_VOLUME, 1) + ).to.be.revertedWith('oracle error out of range') + }) + it('Should not allow 0 oracleTimeout', async () => { + await expect( + KingAssetFactory.deploy(1, ONE_ADDRESS, 1, ONE_ADDRESS, MAX_TRADE_VOLUME, 0) + ).to.be.revertedWith('oracleTimeout zero') + }) + it('Should not allow maxTradeVolume to be zero', async () => { + await expect( + KingAssetFactory.deploy(1, ONE_ADDRESS, 1, ONE_ADDRESS, 0, 1) + ).to.be.revertedWith('invalid max trade volume') + }) + }) +}) diff --git a/test/plugins/individual-collateral/etherfi/WeEthCollateral.test.ts b/test/plugins/individual-collateral/etherfi/WeEthCollateral.test.ts new file mode 100644 index 000000000..1f2261059 --- /dev/null +++ b/test/plugins/individual-collateral/etherfi/WeEthCollateral.test.ts @@ -0,0 +1,382 @@ +import collateralTests from '../collateralTests' +import { CollateralFixtureContext, CollateralOpts, MintCollateralFunc } from '../pluginTestTypes' +import { resetFork, mintWEETH, accrueRewards } from './helpers' +import hre, { ethers } from 'hardhat' +import { expect } from 'chai' +import { ContractFactory, BigNumberish, BigNumber } from 'ethers' +import { + ERC20Mock, + MockV3Aggregator, + MockV3Aggregator__factory, + TestICollateral, + IWeETH, + WeEthMock, + WETH9, +} from '../../../../typechain' +import { pushOracleForward } from '../../../utils/oracles' +import { advanceBlocks, advanceTime, getLatestBlockTimestamp } from '../../../utils/time' +import { bn, fp } from '../../../../common/numbers' +import { CollateralStatus, ZERO_ADDRESS, MAX_UINT48 } from '../../../../common/constants' +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' +import { + KING, + ETH_ORACLE_ERROR, + ETH_ORACLE_TIMEOUT, + PRICE_TIMEOUT, + MAX_TRADE_VOL, + DEFAULT_THRESHOLD, + DELAY_UNTIL_DEFAULT, + WETH, + EETH, + WEETH, + ETH_USD_PRICE_FEED, + WEETH_ETH_PRICE_FEED, + WEETH_ORACLE_TIMEOUT, +} from './constants' + +/* + Define interfaces +*/ + +interface WeEthCollateralFixtureContext extends CollateralFixtureContext { + weth: WETH9 + eEth: ERC20Mock + weEth: IWeETH + targetPerTokChainlinkFeed: MockV3Aggregator +} + +interface WeEthCollateralFixtureContextMock extends WeEthCollateralFixtureContext { + weEthMock: WeEthMock +} + +interface WeEthCollateralOpts extends CollateralOpts { + targetPerTokChainlinkFeed?: string + targetPerTokChainlinkTimeout?: BigNumberish +} + +/* + Define deployment functions +*/ + +export const defaultWeEthCollateralOpts: WeEthCollateralOpts = { + erc20: WEETH, + targetName: ethers.utils.formatBytes32String('ETH'), + rewardERC20: ZERO_ADDRESS, + priceTimeout: PRICE_TIMEOUT, + chainlinkFeed: ETH_USD_PRICE_FEED, + oracleTimeout: ETH_ORACLE_TIMEOUT, + oracleError: ETH_ORACLE_ERROR, + maxTradeVolume: MAX_TRADE_VOL, + defaultThreshold: DEFAULT_THRESHOLD, + delayUntilDefault: DELAY_UNTIL_DEFAULT, + targetPerTokChainlinkFeed: WEETH_ETH_PRICE_FEED, + targetPerTokChainlinkTimeout: WEETH_ORACLE_TIMEOUT, + revenueHiding: fp('0'), +} + +export const deployCollateral = async ( + opts: WeEthCollateralOpts = {} +): Promise => { + opts = { ...defaultWeEthCollateralOpts, ...opts } + + const WeEthCollateralFactory: ContractFactory = await ethers.getContractFactory('WeEthCollateral') + + const collateral = await WeEthCollateralFactory.deploy( + { + erc20: opts.erc20, + targetName: opts.targetName, + priceTimeout: opts.priceTimeout, + chainlinkFeed: opts.chainlinkFeed, + oracleError: opts.oracleError, + oracleTimeout: opts.oracleTimeout, + maxTradeVolume: opts.maxTradeVolume, + defaultThreshold: opts.defaultThreshold, + delayUntilDefault: opts.delayUntilDefault, + }, + opts.revenueHiding, + opts.targetPerTokChainlinkFeed, + opts.targetPerTokChainlinkTimeout, + { gasLimit: 2000000000 } + ) + await collateral.deployed() + + // Push forward chainlink feed + await pushOracleForward(opts.chainlinkFeed!) + await pushOracleForward(opts.targetPerTokChainlinkFeed!) + + // sometimes we are trying to test a negative test case and we want this to fail silently + // fortunately this syntax fails silently because our tools are terrible + await expect(collateral.refresh()) + + return collateral +} + +const chainlinkDefaultAnswer = bn('1600e8') +const refPerTokChainlinkDefaultAnswer = fp('1.0283') + +type Fixture = () => Promise + +const makeCollateralFixtureContext = ( + alice: SignerWithAddress, + opts: WeEthCollateralOpts = {} +): Fixture => { + const collateralOpts = { ...defaultWeEthCollateralOpts, ...opts } + + const makeCollateralFixtureContext = async () => { + const MockV3AggregatorFactory = ( + await ethers.getContractFactory('MockV3Aggregator') + ) + + const chainlinkFeed = ( + await MockV3AggregatorFactory.deploy(8, chainlinkDefaultAnswer) + ) + + const targetPerTokChainlinkFeed = ( + await MockV3AggregatorFactory.deploy(18, refPerTokChainlinkDefaultAnswer) + ) + collateralOpts.chainlinkFeed = chainlinkFeed.address + collateralOpts.targetPerTokChainlinkFeed = targetPerTokChainlinkFeed.address + + const weth = (await ethers.getContractAt('WETH9', WETH)) as WETH9 + const eEth = (await ethers.getContractAt('ERC20Mock', EETH)) as ERC20Mock + const weEth = (await ethers.getContractAt('IWeETH', WEETH)) as IWeETH + const rewardToken = (await ethers.getContractAt('ERC20Mock', KING)) as ERC20Mock + const collateral = await deployCollateral(collateralOpts) + + return { + alice, + collateral, + chainlinkFeed, + weth, + eEth, + weEth, + targetPerTokChainlinkFeed, + tok: weEth, + rewardToken, + } + } + + return makeCollateralFixtureContext +} + +const deployCollateralWeEthMockContext = async ( + opts: WeEthCollateralOpts = {} +): Promise => { + const collateralOpts = { ...defaultWeEthCollateralOpts, ...opts } + + const MockV3AggregatorFactory = ( + await ethers.getContractFactory('MockV3Aggregator') + ) + const chainlinkFeed = await MockV3AggregatorFactory.deploy(8, bn('1e8')) + collateralOpts.chainlinkFeed = chainlinkFeed.address + + const MockFactory = await ethers.getContractFactory('WeEthMock') + const erc20 = (await MockFactory.deploy()) as WeEthMock + const currentRate = await (await ethers.getContractAt('IWeETH', WEETH)).getRate() + await erc20.setRate(currentRate) + + const targetPerTokChainlinkFeed = ( + await MockV3AggregatorFactory.deploy(18, refPerTokChainlinkDefaultAnswer) + ) + collateralOpts.targetPerTokChainlinkFeed = targetPerTokChainlinkFeed.address + + const weth = (await ethers.getContractAt('WETH9', WETH)) as WETH9 + const eEth = (await ethers.getContractAt('ERC20Mock', EETH)) as ERC20Mock + const weEth = (await ethers.getContractAt('IWeETH', WEETH)) as IWeETH + + collateralOpts.erc20 = erc20.address + const collateral = await deployCollateral(collateralOpts) + + return { + weth, + collateral, + chainlinkFeed, + targetPerTokChainlinkFeed, + eEth, + weEth, + weEthMock: erc20, + tok: erc20, + } +} + +/* + Define helper functions +*/ + +const mintCollateralTo: MintCollateralFunc = async ( + ctx: WeEthCollateralFixtureContext, + amount: BigNumberish, + user: SignerWithAddress, + recipient: string +) => { + await mintWEETH(ctx.weEth, user, amount, recipient) +} + +const changeTargetPerRef = async (ctx: WeEthCollateralFixtureContext, percentChange: BigNumber) => { + // We leave the actual refPerTok exchange where it is and just change {target/tok} + { + const lastRound = await ctx.targetPerTokChainlinkFeed.latestRoundData() + const nextAnswer = lastRound.answer.add(lastRound.answer.mul(percentChange).div(100)) + await ctx.targetPerTokChainlinkFeed.updateAnswer(nextAnswer) + } +} + +const reduceTargetPerRef = async ( + ctx: WeEthCollateralFixtureContext, + pctDecrease: BigNumberish +) => { + await changeTargetPerRef(ctx, bn(pctDecrease).mul(-1)) +} + +const increaseTargetPerRef = async ( + ctx: WeEthCollateralFixtureContext, + pctDecrease: BigNumberish +) => { + await changeTargetPerRef(ctx, bn(pctDecrease)) +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const reduceRefPerTok = async (ctx: WeEthCollateralFixtureContext, pctDecrease: BigNumberish) => { + await hre.network.provider.send('evm_mine', []) +} + +// prettier-ignore +const increaseRefPerTok = async ( + ctx: WeEthCollateralFixtureContext, + pctIncrease: BigNumberish +) => { + // Get current rate + const currentRate = await ctx.weEth.getRate() + + // Calculate reward amount needed to increase rate by pctIncrease + const rewardAmount = currentRate.mul(pctIncrease).div(100) + + // Accrue rewards through LiquidityPool.rebase() + await accrueRewards(rewardAmount) + + await advanceBlocks(86400 / 12) + await advanceTime(86400) + + // Push chainlink oracles forward so that tryPrice() still works + const latestRoundData = await ctx.chainlinkFeed.latestRoundData() + await ctx.chainlinkFeed.updateAnswer(latestRoundData.answer) + + // Adjust weETH/ETH chainlink price as well to reflect the new rate + const lastRound = await ctx.targetPerTokChainlinkFeed.latestRoundData() + const nextAnswer = lastRound.answer.add(lastRound.answer.mul(pctIncrease).div(100)) + await ctx.targetPerTokChainlinkFeed.updateAnswer(nextAnswer) +} + +const getExpectedPrice = async (ctx: WeEthCollateralFixtureContext): Promise => { + const clData = await ctx.chainlinkFeed.latestRoundData() + const clDecimals = await ctx.chainlinkFeed.decimals() + + const clRptData = await ctx.targetPerTokChainlinkFeed.latestRoundData() + const clRptDecimals = await ctx.targetPerTokChainlinkFeed.decimals() + + return clData.answer + .mul(bn(10).pow(18 - clDecimals)) + .mul(clRptData.answer.mul(bn(10).pow(18 - clRptDecimals))) + .div(fp('1')) +} + +/* + Define collateral-specific tests +*/ + +const collateralSpecificConstructorTests = () => { + it('does not allow missing targetPerTok chainlink feed', async () => { + await expect( + deployCollateral({ targetPerTokChainlinkFeed: ethers.constants.AddressZero }) + ).to.be.revertedWith('missing targetPerTok feed') + }) + + it('does not allow targetPerTok oracle timeout at 0', async () => { + await expect(deployCollateral({ targetPerTokChainlinkTimeout: 0 })).to.be.revertedWith( + 'targetPerTokChainlinkTimeout zero' + ) + }) +} + +// eslint-disable-next-line @typescript-eslint/no-empty-function +const collateralSpecificStatusTests = () => { + it('does revenue hiding correctly', async () => { + const { collateral, weEthMock } = await deployCollateralWeEthMockContext({ + revenueHiding: fp('0.01'), + }) + + const currentRate = await (await ethers.getContractAt('IWeETH', WEETH)).getRate() + + // Should remain SOUND after a 1% decrease + let refPerTok = await collateral.refPerTok() + const newRate = currentRate.sub(currentRate.div(100)) + await weEthMock.setRate(newRate) + await collateral.refresh() + expect(await collateral.status()).to.equal(CollateralStatus.SOUND) + + // refPerTok should be unchanged + expect(await collateral.refPerTok()).to.be.closeTo(refPerTok, refPerTok.div(bn('1e3'))) // within 1-part-in-1-thousand + + // Should become DISABLED if drops another 1% + refPerTok = await collateral.refPerTok() + await weEthMock.setRate(newRate.sub(newRate.div(100))) + await collateral.refresh() + expect(await collateral.status()).to.equal(CollateralStatus.DISABLED) + + // refPerTok should have fallen 1% + refPerTok = refPerTok.sub(refPerTok.div(100)) + expect(await collateral.refPerTok()).to.be.closeTo(refPerTok, refPerTok.div(bn('1e3'))) // within 1-part-in-1-thousand + }) + + it('enters DISABLED state when refPerTok() decreases', async () => { + const { collateral, weEthMock } = await deployCollateralWeEthMockContext({ + revenueHiding: fp('0.01'), + }) + + // Check initial state + expect(await collateral.status()).to.equal(CollateralStatus.SOUND) + expect(await collateral.whenDefault()).to.equal(MAX_UINT48) + await expect(collateral.refresh()).to.not.emit(collateral, 'CollateralStatusChanged') + + // Should default instantly after 10% drop (beyond revenue hiding threshold) + const currentRate = await weEthMock.getRate() + await weEthMock.setRate(currentRate.sub(currentRate.mul(10).div(100))) + await expect(collateral.refresh()).to.emit(collateral, 'CollateralStatusChanged') + expect(await collateral.status()).to.equal(CollateralStatus.DISABLED) + expect(await collateral.whenDefault()).to.equal(await getLatestBlockTimestamp()) + }) +} + +// eslint-disable-next-line @typescript-eslint/no-empty-function +const beforeEachRewardsTest = async () => {} + +/* + Run the test suite +*/ + +const opts = { + deployCollateral, + collateralSpecificConstructorTests, + collateralSpecificStatusTests, + beforeEachRewardsTest, + makeCollateralFixtureContext, + mintCollateralTo, + reduceTargetPerRef, + increaseTargetPerRef, + reduceRefPerTok, + increaseRefPerTok, + getExpectedPrice, + itClaimsRewards: it.skip, + itChecksTargetPerRefDefault: it, + itChecksTargetPerRefDefaultUp: it, + itChecksRefPerTokDefault: it.skip, // implemented in this file + itChecksPriceChanges: it, + itChecksNonZeroDefaultThreshold: it, + itHasRevenueHiding: it.skip, // implemented in this file + resetFork, + collateralName: 'WeETH', + chainlinkDefaultAnswer, + itIsPricedByPeg: true, +} + +collateralTests(opts) diff --git a/test/plugins/individual-collateral/etherfi/constants.ts b/test/plugins/individual-collateral/etherfi/constants.ts new file mode 100644 index 000000000..087889139 --- /dev/null +++ b/test/plugins/individual-collateral/etherfi/constants.ts @@ -0,0 +1,28 @@ +import { bn, fp } from '../../../../common/numbers' +import { networkConfig } from '../../../../common/configuration' + +// Mainnet Addresses +export const RSR = networkConfig['31337'].tokens.RSR as string +export const ETH_USD_PRICE_FEED = networkConfig['31337'].chainlinkFeeds.ETH as string +export const WEETH = networkConfig['31337'].tokens.weETH as string +export const EETH = networkConfig['31337'].tokens.eETH as string + +export const KING = networkConfig['31337'].tokens.KING as string + +export const WEETH_ETH_PRICE_FEED = networkConfig['31337'].chainlinkFeeds.weETH as string +export const WETH = networkConfig['31337'].tokens.WETH as string +export const WEETH_WHALE = '0xBdfa7b7893081B35Fb54027489e2Bc7A38275129' +export const LIQUIDITY_POOL = '0x308861A430be4cce5502d0A12724771Fc6DaF216' +export const MEMBERSHIP_MANAGER = '0x3d320286E014C3e1ce99Af6d6B00f0C1D63E3000' + +export const PRICE_TIMEOUT = bn('604800') // 1 week +export const ETH_ORACLE_TIMEOUT = bn(3600) // 1 hour in seconds +export const ETH_ORACLE_ERROR = fp('0.005') +export const WEETH_ORACLE_ERROR = fp('0.005') // 0.5% +export const WEETH_ORACLE_TIMEOUT = bn(86400) // 24h + +export const DEFAULT_THRESHOLD = fp('0.05') // 5% +export const DELAY_UNTIL_DEFAULT = bn(259200) // 72h +export const MAX_TRADE_VOL = bn(1000) + +export const FORK_BLOCK = 23841545 diff --git a/test/plugins/individual-collateral/etherfi/helpers.ts b/test/plugins/individual-collateral/etherfi/helpers.ts new file mode 100644 index 000000000..03a03a323 --- /dev/null +++ b/test/plugins/individual-collateral/etherfi/helpers.ts @@ -0,0 +1,34 @@ +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' +import { IWeETH } from '../../../../typechain' +import { whileImpersonating } from '../../../utils/impersonation' +import { BigNumberish } from 'ethers' +import { FORK_BLOCK, WEETH_WHALE, LIQUIDITY_POOL, MEMBERSHIP_MANAGER } from './constants' +import { getResetFork } from '../helpers' +import { ethers } from 'hardhat' + +export const mintWEETH = async ( + weETH: IWeETH, + account: SignerWithAddress, + amount: BigNumberish, + recipient: string +) => { + // transfer from a weETH whale + await whileImpersonating(WEETH_WHALE, async (weEthWhale) => { + await weETH.connect(weEthWhale).transfer(recipient, amount) + }) +} + +/** + * Simulate reward accrual in the Ether.fi protocol + * This increases the weETH exchange rate by calling rebase() on the LiquidityPool + */ +export const accrueRewards = async (rewardAmount: BigNumberish) => { + const liquidityPool = await ethers.getContractAt('ILiquidityPool', LIQUIDITY_POOL) + + // Call rebase() as the MembershipManager to accrue rewards + await whileImpersonating(MEMBERSHIP_MANAGER, async (membershipManagerSigner) => { + await liquidityPool.connect(membershipManagerSigner).rebase(rewardAmount) + }) +} + +export const resetFork = getResetFork(FORK_BLOCK)