diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8c1d16e8d..227b0b97f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -187,3 +187,30 @@ jobs: TS_NODE_SKIP_IGNORE: true MAINNET_RPC_URL: https://eth-mainnet.alchemyapi.io/v2/${{ secrets.ALCHEMY_MAINNET_KEY }} FORK_NETWORK: mainnet + + monitor-tests: + name: 'Monitor Tests (Mainnet)' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 16.x + cache: 'yarn' + - run: yarn install --immutable + - name: 'Cache hardhat network fork' + uses: actions/cache@v3 + with: + path: cache/hardhat-network-fork + key: hardhat-network-fork-${{ runner.os }}-${{ hashFiles('test/integration/fork-block-numbers.ts') }} + restore-keys: | + hardhat-network-fork-${{ runner.os }}- + hardhat-network-fork- + - run: npx hardhat test ./test/monitor/*.test.ts + env: + NODE_OPTIONS: '--max-old-space-size=8192' + TS_NODE_SKIP_IGNORE: true + MAINNET_RPC_URL: https://eth-mainnet.alchemyapi.io/v2/${{ secrets.ALCHEMY_MAINNET_KEY }} + FORK_NETWORK: mainnet + FORK: 1 + PROTO_IMPL: 1 diff --git a/.openzeppelin/base_8453.json b/.openzeppelin/base_8453.json index 6c4c4a367..0d90ea97c 100644 --- a/.openzeppelin/base_8453.json +++ b/.openzeppelin/base_8453.json @@ -3144,6 +3144,190 @@ } } } + }, + "83264eb95f2f9ab0055f3cdf3d195b52003b35099a624ee29920f6a83be6b884": { + "address": "0xD45a441F334f6f27CDDA3728414FD26Cc5798E66", + "txHash": "0xcce3cfb75dad5e947efeab8a30cd981ca578d96f7a8bee1512a86b2849a0fa24", + "layout": { + "solcVersion": "0.8.19", + "storage": [ + { + "label": "_initialized", + "offset": 0, + "slot": "0", + "type": "t_uint8", + "contract": "Initializable", + "src": "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol:62", + "retypedFrom": "bool" + }, + { + "label": "_initializing", + "offset": 1, + "slot": "0", + "type": "t_bool", + "contract": "Initializable", + "src": "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol:67" + }, + { + "label": "__gap", + "offset": 0, + "slot": "1", + "type": "t_array(t_uint256)50_storage", + "contract": "ContextUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/utils/ContextUpgradeable.sol:36" + }, + { + "label": "_owner", + "offset": 0, + "slot": "51", + "type": "t_address", + "contract": "OwnableUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol:22" + }, + { + "label": "__gap", + "offset": 0, + "slot": "52", + "type": "t_array(t_uint256)49_storage", + "contract": "OwnableUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol:94" + }, + { + "label": "__gap", + "offset": 0, + "slot": "101", + "type": "t_array(t_uint256)50_storage", + "contract": "ERC1967UpgradeUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/proxy/ERC1967/ERC1967UpgradeUpgradeable.sol:211" + }, + { + "label": "__gap", + "offset": 0, + "slot": "151", + "type": "t_array(t_uint256)50_storage", + "contract": "UUPSUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol:107" + } + ], + "types": { + "t_address": { + "label": "address", + "numberOfBytes": "20" + }, + "t_array(t_uint256)49_storage": { + "label": "uint256[49]", + "numberOfBytes": "1568" + }, + "t_array(t_uint256)50_storage": { + "label": "uint256[50]", + "numberOfBytes": "1600" + }, + "t_bool": { + "label": "bool", + "numberOfBytes": "1" + }, + "t_uint256": { + "label": "uint256", + "numberOfBytes": "32" + }, + "t_uint8": { + "label": "uint8", + "numberOfBytes": "1" + } + } + } + }, + "07b40b651527d3b3c3f0d1fb77a991853411f5b7fd564a45478bb03e177adcae": { + "address": "0x69c20aD99eb1054cd7Da2809572205186975dA17", + "txHash": "0x05c19fbc6774d5e85aadba888cc56e0764a104c1da7e3fa9f0774dfba8a46215", + "layout": { + "solcVersion": "0.8.19", + "storage": [ + { + "label": "_initialized", + "offset": 0, + "slot": "0", + "type": "t_uint8", + "contract": "Initializable", + "src": "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol:62", + "retypedFrom": "bool" + }, + { + "label": "_initializing", + "offset": 1, + "slot": "0", + "type": "t_bool", + "contract": "Initializable", + "src": "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol:67" + }, + { + "label": "__gap", + "offset": 0, + "slot": "1", + "type": "t_array(t_uint256)50_storage", + "contract": "ContextUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/utils/ContextUpgradeable.sol:36" + }, + { + "label": "_owner", + "offset": 0, + "slot": "51", + "type": "t_address", + "contract": "OwnableUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol:22" + }, + { + "label": "__gap", + "offset": 0, + "slot": "52", + "type": "t_array(t_uint256)49_storage", + "contract": "OwnableUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol:94" + }, + { + "label": "__gap", + "offset": 0, + "slot": "101", + "type": "t_array(t_uint256)50_storage", + "contract": "ERC1967UpgradeUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/proxy/ERC1967/ERC1967UpgradeUpgradeable.sol:211" + }, + { + "label": "__gap", + "offset": 0, + "slot": "151", + "type": "t_array(t_uint256)50_storage", + "contract": "UUPSUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol:107" + } + ], + "types": { + "t_address": { + "label": "address", + "numberOfBytes": "20" + }, + "t_array(t_uint256)49_storage": { + "label": "uint256[49]", + "numberOfBytes": "1568" + }, + "t_array(t_uint256)50_storage": { + "label": "uint256[50]", + "numberOfBytes": "1600" + }, + "t_bool": { + "label": "bool", + "numberOfBytes": "1" + }, + "t_uint256": { + "label": "uint256", + "numberOfBytes": "32" + }, + "t_uint8": { + "label": "uint8", + "numberOfBytes": "1" + } + } + } } } } diff --git a/.openzeppelin/mainnet.json b/.openzeppelin/mainnet.json index 8ae572183..d0dae943c 100644 --- a/.openzeppelin/mainnet.json +++ b/.openzeppelin/mainnet.json @@ -3747,10 +3747,7 @@ }, "t_enum(TradeKind)25002": { "label": "enum TradeKind", - "members": [ - "DUTCH_AUCTION", - "BATCH_AUCTION" - ], + "members": ["DUTCH_AUCTION", "BATCH_AUCTION"], "numberOfBytes": "1" }, "t_mapping(t_contract(IERC20)15191,t_contract(ITrade)27151)": { @@ -4043,11 +4040,7 @@ }, "t_enum(CollateralStatus)24460": { "label": "enum CollateralStatus", - "members": [ - "SOUND", - "IFFY", - "DISABLED" - ], + "members": ["SOUND", "IFFY", "DISABLED"], "numberOfBytes": "1" }, "t_mapping(t_bytes32,t_bytes32)": { @@ -6340,10 +6333,7 @@ }, "t_enum(TradeKind)17751": { "label": "enum TradeKind", - "members": [ - "DUTCH_AUCTION", - "BATCH_AUCTION" - ], + "members": ["DUTCH_AUCTION", "BATCH_AUCTION"], "numberOfBytes": "1" }, "t_mapping(t_contract(IERC20)11113,t_contract(ITrade)19704)": { @@ -6652,6 +6642,190 @@ } } } + }, + "f0632c54f5763a16d6d87d14d0e7a80a079e8b998507fa1d081ee3b631c3961c": { + "address": "0xA42850A760151bb3ACF17E7f8643EB4d864bF7a6", + "txHash": "0xfa37e2544175813e2b4308c62f14f05f336a62ea25c94dd9346f710449498d0c", + "layout": { + "solcVersion": "0.8.19", + "storage": [ + { + "label": "_initialized", + "offset": 0, + "slot": "0", + "type": "t_uint8", + "contract": "Initializable", + "src": "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol:62", + "retypedFrom": "bool" + }, + { + "label": "_initializing", + "offset": 1, + "slot": "0", + "type": "t_bool", + "contract": "Initializable", + "src": "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol:67" + }, + { + "label": "__gap", + "offset": 0, + "slot": "1", + "type": "t_array(t_uint256)50_storage", + "contract": "ContextUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/utils/ContextUpgradeable.sol:36" + }, + { + "label": "_owner", + "offset": 0, + "slot": "51", + "type": "t_address", + "contract": "OwnableUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol:22" + }, + { + "label": "__gap", + "offset": 0, + "slot": "52", + "type": "t_array(t_uint256)49_storage", + "contract": "OwnableUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol:94" + }, + { + "label": "__gap", + "offset": 0, + "slot": "101", + "type": "t_array(t_uint256)50_storage", + "contract": "ERC1967UpgradeUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/proxy/ERC1967/ERC1967UpgradeUpgradeable.sol:211" + }, + { + "label": "__gap", + "offset": 0, + "slot": "151", + "type": "t_array(t_uint256)50_storage", + "contract": "UUPSUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol:107" + } + ], + "types": { + "t_address": { + "label": "address", + "numberOfBytes": "20" + }, + "t_array(t_uint256)49_storage": { + "label": "uint256[49]", + "numberOfBytes": "1568" + }, + "t_array(t_uint256)50_storage": { + "label": "uint256[50]", + "numberOfBytes": "1600" + }, + "t_bool": { + "label": "bool", + "numberOfBytes": "1" + }, + "t_uint256": { + "label": "uint256", + "numberOfBytes": "32" + }, + "t_uint8": { + "label": "uint8", + "numberOfBytes": "1" + } + } + } + }, + "ebc9c3f1c253e562c3d21649a4c7d904b40ed64689bc3d3bc57bbe09fcd1d120": { + "address": "0x35fDc5537c32588bfc97b393A8ed522Df737af5A", + "txHash": "0xc1d9400b9492c969e5a156fa8e419ccd8a1138160f6eb4079192455e3af357e6", + "layout": { + "solcVersion": "0.8.19", + "storage": [ + { + "label": "_initialized", + "offset": 0, + "slot": "0", + "type": "t_uint8", + "contract": "Initializable", + "src": "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol:62", + "retypedFrom": "bool" + }, + { + "label": "_initializing", + "offset": 1, + "slot": "0", + "type": "t_bool", + "contract": "Initializable", + "src": "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol:67" + }, + { + "label": "__gap", + "offset": 0, + "slot": "1", + "type": "t_array(t_uint256)50_storage", + "contract": "ContextUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/utils/ContextUpgradeable.sol:36" + }, + { + "label": "_owner", + "offset": 0, + "slot": "51", + "type": "t_address", + "contract": "OwnableUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol:22" + }, + { + "label": "__gap", + "offset": 0, + "slot": "52", + "type": "t_array(t_uint256)49_storage", + "contract": "OwnableUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol:94" + }, + { + "label": "__gap", + "offset": 0, + "slot": "101", + "type": "t_array(t_uint256)50_storage", + "contract": "ERC1967UpgradeUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/proxy/ERC1967/ERC1967UpgradeUpgradeable.sol:211" + }, + { + "label": "__gap", + "offset": 0, + "slot": "151", + "type": "t_array(t_uint256)50_storage", + "contract": "UUPSUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol:107" + } + ], + "types": { + "t_address": { + "label": "address", + "numberOfBytes": "20" + }, + "t_array(t_uint256)49_storage": { + "label": "uint256[49]", + "numberOfBytes": "1568" + }, + "t_array(t_uint256)50_storage": { + "label": "uint256[50]", + "numberOfBytes": "1600" + }, + "t_bool": { + "label": "bool", + "numberOfBytes": "1" + }, + "t_uint256": { + "label": "uint256", + "numberOfBytes": "32" + }, + "t_uint8": { + "label": "uint8", + "numberOfBytes": "1" + } + } + } } } } diff --git a/common/configuration.ts b/common/configuration.ts index 0bd5d8ff2..3692d8068 100644 --- a/common/configuration.ts +++ b/common/configuration.ts @@ -109,6 +109,7 @@ interface INetworkConfig { AAVE_INCENTIVES?: string AAVE_EMISSIONS_MGR?: string AAVE_RESERVE_TREASURY?: string + AAVE_DATA_PROVIDER?: string COMPTROLLER?: string FLUX_FINANCE_COMPTROLLER?: string GNOSIS_EASY_AUCTION?: string @@ -224,6 +225,7 @@ export const networkConfig: { [key: string]: INetworkConfig } = { AAVE_INCENTIVES: '0xd784927Ff2f95ba542BfC824c8a8a98F3495f6b5', AAVE_EMISSIONS_MGR: '0xEE56e2B3D491590B5b31738cC34d5232F378a8D5', AAVE_RESERVE_TREASURY: '0x464C71f6c2F760DdA6093dCB91C24c39e5d6e18c', + AAVE_DATA_PROVIDER: '0x057835Ad21a177dbdd3090bB1CAE03EaCF78Fc6d', FLUX_FINANCE_COMPTROLLER: '0x95Af143a021DF745bc78e845b54591C53a8B3A51', COMPTROLLER: '0x3d9819210A31b4961b30EF54bE2aeD79B9c9Cd3B', GNOSIS_EASY_AUCTION: '0x0b7fFc1f4AD541A4Ed16b40D8c37f0929158D101', @@ -329,6 +331,7 @@ export const networkConfig: { [key: string]: INetworkConfig } = { }, AAVE_LENDING_POOL: '0x7d2768dE32b0b80b7a3454c06BdAc94A69DDc7A9', AAVE_RESERVE_TREASURY: '0x464C71f6c2F760DdA6093dCB91C24c39e5d6e18c', + AAVE_DATA_PROVIDER: '0x057835Ad21a177dbdd3090bB1CAE03EaCF78Fc6d', FLUX_FINANCE_COMPTROLLER: '0x95Af143a021DF745bc78e845b54591C53a8B3A51', COMPTROLLER: '0x3d9819210A31b4961b30EF54bE2aeD79B9c9Cd3B', GNOSIS_EASY_AUCTION: '0x0b7fFc1f4AD541A4Ed16b40D8c37f0929158D101', @@ -428,6 +431,7 @@ export const networkConfig: { [key: string]: INetworkConfig } = { }, AAVE_LENDING_POOL: '0x7d2768dE32b0b80b7a3454c06BdAc94A69DDc7A9', AAVE_RESERVE_TREASURY: '0x464C71f6c2F760DdA6093dCB91C24c39e5d6e18c', + AAVE_DATA_PROVIDER: '0x057835Ad21a177dbdd3090bB1CAE03EaCF78Fc6d', FLUX_FINANCE_COMPTROLLER: '0x95Af143a021DF745bc78e845b54591C53a8B3A51', COMPTROLLER: '0x3d9819210A31b4961b30EF54bE2aeD79B9c9Cd3B', GNOSIS_EASY_AUCTION: '0x0b7fFc1f4AD541A4Ed16b40D8c37f0929158D101', @@ -642,6 +646,10 @@ export interface IRTokenConfig { params: IConfig } +export interface IMonitorParams { + AAVE_V2_DATA_PROVIDER_ADDR: string +} + export interface IBackupInfo { backupUnit: string diversityFactor: BigNumber diff --git a/contracts/facade/FacadeMonitor.sol b/contracts/facade/FacadeMonitor.sol new file mode 100644 index 000000000..e8221a119 --- /dev/null +++ b/contracts/facade/FacadeMonitor.sol @@ -0,0 +1,211 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.19; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import "../interfaces/IFacadeMonitor.sol"; +import "../interfaces/IRToken.sol"; +import "../libraries/Fixed.sol"; +import "../p1/RToken.sol"; +import "../plugins/assets/compoundv2/CTokenWrapper.sol"; +import "../plugins/assets/compoundv3/ICusdcV3Wrapper.sol"; +import "../plugins/assets/stargate/StargateRewardableWrapper.sol"; +import { StaticATokenV3LM } from "../plugins/assets/aave-v3/vendor/StaticATokenV3LM.sol"; +import "../plugins/assets/morpho-aave/MorphoAaveV2TokenisedDeposit.sol"; + +interface IAaveProtocolDataProvider { + function getReserveData(address asset) + external + view + returns ( + uint256 availableLiquidity, + uint256 totalStableDebt, + uint256 totalVariableDebt, + uint256 liquidityRate, + uint256 variableBorrowRate, + uint256 stableBorrowRate, + uint256 averageStableBorrowRate, + uint256 liquidityIndex, + uint256 variableBorrowIndex, + uint40 lastUpdateTimestamp + ); +} + +interface IStaticATokenLM is IERC20 { + // solhint-disable-next-line func-name-mixedcase + function UNDERLYING_ASSET_ADDRESS() external view returns (address); + + function dynamicBalanceOf(address account) external view returns (uint256); +} + +/** + * @title FacadeMonitor + * @notice A UX-friendly layer for monitoring RTokens + */ +contract FacadeMonitor is Initializable, OwnableUpgradeable, UUPSUpgradeable, IFacadeMonitor { + using FixLib for uint192; + + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + // solhint-disable-next-line var-name-mixedcase + address public immutable AAVE_V2_DATA_PROVIDER; + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor(MonitorParams memory params) { + AAVE_V2_DATA_PROVIDER = params.AAVE_V2_DATA_PROVIDER_ADDR; + _disableInitializers(); + } + + function init(address initialOwner) public initializer { + require(initialOwner != address(0), "invalid owner address"); + + __Ownable_init(); + __UUPSUpgradeable_init(); + _transferOwnership(initialOwner); + } + + // === Views === + + /// @return Whether batch auctions are disabled for a specific rToken + function batchAuctionsDisabled(IRToken rToken) external view returns (bool) { + return rToken.main().broker().batchTradeDisabled(); + } + + /// @return Whether any dutch auction is disabled for a specific rToken + function dutchAuctionsDisabled(IRToken rToken) external view returns (bool) { + bool disabled = false; + + IERC20[] memory erc20s = rToken.main().assetRegistry().erc20s(); + for (uint256 i = 0; i < erc20s.length; ++i) { + if (rToken.main().broker().dutchTradeDisabled(IERC20Metadata(address(erc20s[i])))) + disabled = true; + } + + return disabled; + } + + /// @return Which percentage of issuance throttle is still available for a specific rToken + function issuanceAvailable(IRToken rToken) external view returns (uint256) { + ThrottleLib.Params memory params = RTokenP1(address(rToken)).issuanceThrottleParams(); + + // Calculate hourly limit as: max(params.amtRate, supply.mul(params.pctRate)) + uint256 limit = (rToken.totalSupply() * params.pctRate) / FIX_ONE_256; // {qRTok} + if (params.amtRate > limit) limit = params.amtRate; + + uint256 issueAvailable = rToken.issuanceAvailable(); + if (issueAvailable >= limit) return FIX_ONE_256; + + return (issueAvailable * FIX_ONE_256) / limit; + } + + function redemptionAvailable(IRToken rToken) external view returns (uint256) { + ThrottleLib.Params memory params = RTokenP1(address(rToken)).redemptionThrottleParams(); + + uint256 supply = rToken.totalSupply(); + + if (supply == 0) return FIX_ONE_256; + + // Calculate hourly limit as: max(params.amtRate, supply.mul(params.pctRate)) + uint256 limit = (supply * params.pctRate) / FIX_ONE_256; // {qRTok} + if (params.amtRate > limit) limit = supply < params.amtRate ? supply : params.amtRate; + + uint256 redeemAvailable = rToken.redemptionAvailable(); + if (redeemAvailable >= limit) return FIX_ONE_256; + + return (redeemAvailable * FIX_ONE_256) / limit; + } + + function backingReedemable( + IRToken rToken, + CollPluginType collType, + IERC20 erc20 + ) external view returns (uint256) { + uint256 backingBalance; + uint256 availableLiquidity; + + if (collType == CollPluginType.AAVE_V2 || collType == CollPluginType.MORPHO_AAVE_V2) { + address underlying; + if (collType == CollPluginType.AAVE_V2) { + // AAVE V2 - Uses Static wrapper + IStaticATokenLM staticAToken = IStaticATokenLM(address(erc20)); + backingBalance = staticAToken.dynamicBalanceOf( + address(rToken.main().backingManager()) + ); + underlying = staticAToken.UNDERLYING_ASSET_ADDRESS(); + } else { + // MORPHO AAVE V2 + MorphoAaveV2TokenisedDeposit mrpTknDeposit = MorphoAaveV2TokenisedDeposit( + address(erc20) + ); + backingBalance = mrpTknDeposit.convertToAssets( + mrpTknDeposit.balanceOf(address(rToken.main().backingManager())) + ); + underlying = mrpTknDeposit.underlying(); + } + + (availableLiquidity, , , , , , , , , ) = IAaveProtocolDataProvider( + AAVE_V2_DATA_PROVIDER + ).getReserveData(underlying); + } else if (collType == CollPluginType.AAVE_V3) { + StaticATokenV3LM staticAToken = StaticATokenV3LM(address(erc20)); + IERC20 aToken = staticAToken.aToken(); + IERC20 underlying = IERC20(staticAToken.asset()); + + backingBalance = staticAToken.convertToAssets( + staticAToken.balanceOf(address(rToken.main().backingManager())) + ); + availableLiquidity = underlying.balanceOf(address(aToken)); + } else if (collType == CollPluginType.COMPOUND_V2 || collType == CollPluginType.FLUX) { + ICToken cToken; + uint256 cTokenBal; + if (collType == CollPluginType.COMPOUND_V2) { + // CompoundV2 uses a vault to wrap the CToken + CTokenWrapper cTokenVault = CTokenWrapper(address(erc20)); + cToken = ICToken(address(cTokenVault.underlying())); + cTokenBal = cTokenVault.balanceOf(address(rToken.main().backingManager())); + } else { + // FLUX - Uses FToken directly (fork of CToken) + cToken = ICToken(address(erc20)); + cTokenBal = cToken.balanceOf(address(rToken.main().backingManager())); + } + IERC20 underlying = IERC20(cToken.underlying()); + + uint256 exchangeRate = cToken.exchangeRateStored(); + + backingBalance = (cTokenBal * exchangeRate) / 1e18; + availableLiquidity = underlying.balanceOf(address(cToken)); + } else if (collType == CollPluginType.COMPOUND_V3) { + ICusdcV3Wrapper cTokenV3Wrapper = ICusdcV3Wrapper(address(erc20)); + CometInterface cTokenV3 = CometInterface(address(cTokenV3Wrapper.underlyingComet())); + IERC20 underlying = IERC20(cTokenV3.baseToken()); + + backingBalance = cTokenV3Wrapper.underlyingBalanceOf( + address(rToken.main().backingManager()) + ); + availableLiquidity = underlying.balanceOf(address(cTokenV3)); + } else if (collType == CollPluginType.STARGATE) { + StargateRewardableWrapper stgWrapper = StargateRewardableWrapper(address(erc20)); + IStargatePool stgPool = stgWrapper.pool(); + + uint256 wstgBal = stgWrapper.balanceOf(address(rToken.main().backingManager())); + + backingBalance = stgPool.amountLPtoLD(wstgBal); + availableLiquidity = stgPool.totalLiquidity(); + } + + if (availableLiquidity == 0) { + return 0; // Avoid division by zero + } + + if (availableLiquidity >= backingBalance) { + return FIX_ONE_256; + } + + // Calculate the percentage + return (availableLiquidity * FIX_ONE_256) / backingBalance; + } + + // solhint-disable-next-line no-empty-blocks + function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} +} diff --git a/contracts/interfaces/IFacadeMonitor.sol b/contracts/interfaces/IFacadeMonitor.sol new file mode 100644 index 000000000..6c4f6f8d2 --- /dev/null +++ b/contracts/interfaces/IFacadeMonitor.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.19; + +import "./IRToken.sol"; + +/** + * @title IFacadeMonitor + * @notice A monitoring layer for RTokens + */ + +/// PluginType +enum CollPluginType { + AAVE_V2, + AAVE_V3, + COMPOUND_V2, + COMPOUND_V3, + STARGATE, + FLUX, + MORPHO_AAVE_V2 +} + +/** + * @title MonitorParams + * @notice The set of protocol params needed for the required calculations + * Should be defined at deployment based on network + */ + +// solhint-disable var-name-mixedcase +struct MonitorParams { + // === AAVE_V2=== + address AAVE_V2_DATA_PROVIDER_ADDR; +} + +interface IFacadeMonitor { + // === Views === + function batchAuctionsDisabled(IRToken rToken) external view returns (bool); + + function dutchAuctionsDisabled(IRToken rToken) external view returns (bool); + + function issuanceAvailable(IRToken rToken) external view returns (uint256); + + function redemptionAvailable(IRToken rToken) external view returns (uint256); + + function backingReedemable( + IRToken rToken, + CollPluginType collType, + IERC20 erc20 + ) external view returns (uint256); +} diff --git a/contracts/plugins/assets/compoundv2/ICToken.sol b/contracts/plugins/assets/compoundv2/ICToken.sol index 609ea3711..c83f9a355 100644 --- a/contracts/plugins/assets/compoundv2/ICToken.sol +++ b/contracts/plugins/assets/compoundv2/ICToken.sol @@ -33,6 +33,15 @@ interface ICToken is IERC20Metadata { function redeem(uint256 redeemTokens) external returns (uint256); } +interface TestICToken is ICToken { + /** + * @notice Sender borrows assets from the protocol to their own address + * @param borrowAmount The amount of the underlying asset to borrow + * @return uint 0=success, otherwise a failure + */ + function borrow(uint256 borrowAmount) external returns (uint256); +} + interface IComptroller { /// Claim comp for an account, to an account function claimComp( @@ -44,4 +53,6 @@ interface IComptroller { /// @return The address for COMP token function getCompAddress() external view returns (address); + + function enterMarkets(address[] calldata) external returns (uint256[] memory); } diff --git a/contracts/plugins/mocks/ComptrollerMock.sol b/contracts/plugins/mocks/ComptrollerMock.sol index 49c46df9c..249bcdb08 100644 --- a/contracts/plugins/mocks/ComptrollerMock.sol +++ b/contracts/plugins/mocks/ComptrollerMock.sol @@ -37,4 +37,9 @@ contract ComptrollerMock is IComptroller { function getCompAddress() external view returns (address) { return address(compToken); } + + // mock + function enterMarkets(address[] calldata) external returns (uint256[] memory) { + return new uint256[](1); + } } diff --git a/contracts/plugins/mocks/upgrades/FacadeMonitorV2.sol b/contracts/plugins/mocks/upgrades/FacadeMonitorV2.sol new file mode 100644 index 000000000..ebbfc6b1c --- /dev/null +++ b/contracts/plugins/mocks/upgrades/FacadeMonitorV2.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.19; + +import "../../../facade/FacadeMonitor.sol"; + +/** + * @title FacadeMonitorV2 + * @notice Mock to test upgradeability for the FacadeMonitor contract + */ +contract FacadeMonitorV2 is FacadeMonitor { + /// @custom:oz-upgrades-unsafe-allow constructor + constructor(MonitorParams memory params) FacadeMonitor(params) {} + + uint256 public newValue; + + function setNewValue(uint256 newValue_) external onlyOwner { + newValue = newValue_; + } + + function version() public pure returns (string memory) { + return "2.0.0"; + } +} diff --git a/docs/deployed-addresses/1-FacadeMonitor.md b/docs/deployed-addresses/1-FacadeMonitor.md new file mode 100644 index 000000000..e8cf1a8c0 --- /dev/null +++ b/docs/deployed-addresses/1-FacadeMonitor.md @@ -0,0 +1,7 @@ +# FacadeMonitor (Mainnet) + +## Facade Monitor Proxy + +| Contract | Address | +| -------- | --------------------------------------------------------------------------------------------------------------------- | +| FacadeMonitor (Proxy) | [0xAeA6BD7b231C0eC7f35C2bdf47A76053D09dbD09](https://etherscan.io/address/0xAeA6BD7b231C0eC7f35C2bdf47A76053D09dbD09) | diff --git a/docs/deployed-addresses/8453-FacadeMonitor.md b/docs/deployed-addresses/8453-FacadeMonitor.md new file mode 100644 index 000000000..4cba0e181 --- /dev/null +++ b/docs/deployed-addresses/8453-FacadeMonitor.md @@ -0,0 +1,8 @@ +8453-FacadeMonitor.md +# FacadeMonitor (Base) + +## Facade Monitor Proxy + +| Contract | Address | +| --------------------- | --------------------------------------------------------------------------------------------------------------------- | +| FacadeMonitor (Proxy) | [0x5bfc6df700ef23741B2e01Bd45826E4c9735ae60](https://basescan.org/address/0x5bfc6df700ef23741B2e01Bd45826E4c9735ae60) | diff --git a/docs/monitoring.md b/docs/monitoring.md new file mode 100644 index 000000000..df1c5f8ba --- /dev/null +++ b/docs/monitoring.md @@ -0,0 +1,35 @@ +# Monitoring the Reserve Protocol and Rtokens + +This document provides an overview of the monitoring setup for the Reserve Protocol and RTokens on both the Ethereum and Base networks. The monitoring is conducted through the [Hypernative](https://app.hypernative.xyz/) platform, utilizing the `FacadeMonitor` contract to retrieve the status for specific RTokens. This monitoring setup ensures continuous vigilance over the Reserve Protocol and RTokens, with alerts promptly notifying relevant channels in case of any issues. + +## Checks/Alerts + +The following alerts are currently setup for RTokens deployed in Mainnet and Base: + +### Status (Basket Handler) - HIGH + +Checks if the status of the Basket Handler for a specific RToken is SOUND. If not, triggers an alert via Slack, Discord, Telegram, and Pager Duty. + +### Fully collateralized (Basket Handler) - HIGH + +Checks if the Basket Handler for a specific RToken is FULLY COLLATERALIZED. If not, triggers an alert via Slack, Discord, Telegram, and Pager Duty. + +### Batch Auctions Disabled - HIGH + +Checks if the batch auctions for a specific RToken are DISABLED. If true, triggers an alert via Slack, Discord, Telegram, and Pager Duty. + +### Dutch Auctions Disabled - HIGH + +Checks if the any of the dutch auctions for a specific RToken is DISABLED. If true, triggers an alert via Slack, Discord, Telegram, and Pager Duty. + +### Issuance Depleted - MEDIUM + +Triggers and alert via Slack if the Issuance Throttle for a specific RToken is consumed > 99% + +### Redemption Depleted - MEDIUM + +Triggers and alert via Slack if the Redemption Throttle for a specific RToken is consumed > 99% + +### Backing Fully Redeemable- MEDIUM + +Triggers and alert via Slack if the backing of a specific RToken is not redeemable 100% on the underlying Defi Protocol. Provides checks for AAVE V2, AAVE V3, Compound V2, Compound V3, Stargate, Flux, and Morpho AAVE V2. diff --git a/tasks/deployment/deploy-facade-monitor.ts b/tasks/deployment/deploy-facade-monitor.ts new file mode 100644 index 000000000..290a77f64 --- /dev/null +++ b/tasks/deployment/deploy-facade-monitor.ts @@ -0,0 +1,107 @@ +import { getChainId } from '../../common/blockchain-utils' +import { task, types } from 'hardhat/config' +import { FacadeMonitor } from '../../typechain' +import { developmentChains, networkConfig, IMonitorParams } from '../../common/configuration' +import { ZERO_ADDRESS } from '../../common/constants' +import { ContractFactory } from 'ethers' + +let facadeMonitor: FacadeMonitor + +task( + 'deploy-facade-monitor', + 'Deploys the FacadeMonitor implementation and proxy (if its not an upgrade)' +) + .addParam('upgrade', 'Set to true if this is for a later upgrade', false, types.boolean) + .addOptionalParam('owner', 'The address that will own the FacadeMonitor', '', types.string) + .addOptionalParam('noOutput', 'Suppress output', false, types.boolean) + .setAction(async (params, hre) => { + const [wallet] = await hre.ethers.getSigners() + + const chainId = await getChainId(hre) + + // ********** Read config ********** + if (!networkConfig[chainId]) { + throw new Error(`Missing network configuration for ${hre.network.name}`) + } + + if (!params.upgrade) { + if (!params.owner) { + throw new Error( + `An --owner must be specified for the initial deployment to ${hre.network.name}` + ) + } + } + + if (!params.noOutput) { + console.log( + `Deploying FacadeMonitor to ${hre.network.name} (${chainId}) with burner account ${wallet.address}` + ) + } + + // Setup Monitor Params + const monitorParams: IMonitorParams = { + AAVE_V2_DATA_PROVIDER_ADDR: networkConfig[chainId].AAVE_DATA_PROVIDER ?? ZERO_ADDRESS, + } + + // Deploy FacadeMonitor + const FacadeMonitorFactory: ContractFactory = await hre.ethers.getContractFactory( + 'FacadeMonitor' + ) + const facadeMonitorImplAddr = (await hre.upgrades.deployImplementation(FacadeMonitorFactory, { + kind: 'uups', + constructorArgs: [monitorParams], + })) as string + + if (!params.noOutput) { + console.log( + `Deployed FacadeMonitor (Implementation) to ${hre.network.name} (${chainId}): ${facadeMonitorImplAddr}` + ) + } + + if (!params.upgrade) { + facadeMonitor = await hre.upgrades.deployProxy( + FacadeMonitorFactory, + [params.owner], + { + kind: 'uups', + initializer: 'init', + constructorArgs: [monitorParams], + } + ) + + if (!params.noOutput) { + console.log( + `Deployed FacadeMonitor (Proxy) to ${hre.network.name} (${chainId}): ${facadeMonitor.address}` + ) + } + } + // Verify if its not a development chain + if (!developmentChains.includes(hre.network.name)) { + // Uncomment to verify + if (!params.noOutput) { + console.log('sleeping 30s') + } + + // Sleep to ensure API is in sync with chain + await new Promise((r) => setTimeout(r, 30000)) // 30s + + if (!params.noOutput) { + console.log('verifying') + } + + /** ******************** Verify FacadeMonitor ****************************************/ + console.time('Verifying FacadeMonitor Implementation') + await hre.run('verify:verify', { + address: facadeMonitorImplAddr, + constructorArguments: [monitorParams], + contract: 'contracts/facade/FacadeMonitor.sol:FacadeMonitor', + }) + console.timeEnd('Verifying FacadeMonitor Implementation') + + if (!params.noOutput) { + console.log('verified') + } + } + + return { facadeMonitor: facadeMonitor ? facadeMonitor.address : 'N/A', facadeMonitorImplAddr } + }) diff --git a/tasks/index.ts b/tasks/index.ts index 4f167da7a..b1a9df3b5 100644 --- a/tasks/index.ts +++ b/tasks/index.ts @@ -16,6 +16,7 @@ import './deployment/mock/deploy-mock-aave' import './deployment/mock/deploy-mock-wbtc' import './deployment/mock/deploy-mock-easyauction' import './deployment/create-deployer-registry' +import './deployment/deploy-facade-monitor' import './deployment/empty-wallet' import './deployment/cancel-tx' import './deployment/sign-msg' diff --git a/test/Broker.test.ts b/test/Broker.test.ts index 633d1fed4..8ef43c072 100644 --- a/test/Broker.test.ts +++ b/test/Broker.test.ts @@ -54,7 +54,7 @@ import { getLatestBlockTimestamp, getLatestBlockNumber, } from './utils/time' -import { ITradeRequest } from './utils/trades' +import { ITradeRequest, disableBatchTrade, disableDutchTrade } from './utils/trades' import { useEnv } from '#/utils/env' import { parseUnits } from 'ethers/lib/utils' @@ -132,30 +132,6 @@ describe(`BrokerP${IMPLEMENTATION} contract #fast`, () => { prices = { sellLow: fp('1'), sellHigh: fp('1'), buyLow: fp('1'), buyHigh: fp('1') } }) - const disableBatchTrade = async () => { - if (IMPLEMENTATION == Implementation.P1) { - const slot = await getStorageAt(broker.address, 205) - await setStorageAt( - broker.address, - 205, - slot.replace(slot.slice(2, 14), '1'.padStart(12, '0')) - ) - } else { - const slot = await getStorageAt(broker.address, 56) - await setStorageAt(broker.address, 56, slot.replace(slot.slice(2, 42), '1'.padStart(40, '0'))) - } - expect(await broker.batchTradeDisabled()).to.equal(true) - } - - const disableDutchTrade = async (erc20: string) => { - const mappingSlot = IMPLEMENTATION == Implementation.P1 ? bn('208') : bn('57') - const p = mappingSlot.toHexString().slice(2).padStart(64, '0') - const key = erc20.slice(2).padStart(64, '0') - const slot = ethers.utils.keccak256('0x' + key + p) - await setStorageAt(broker.address, slot, '0x' + '1'.padStart(64, '0')) - expect(await broker.dutchTradeDisabled(erc20)).to.equal(true) - } - describe('Deployment', () => { it('Should setup Broker correctly', async () => { expect(await broker.gnosis()).to.equal(gnosis.address) @@ -412,7 +388,7 @@ describe(`BrokerP${IMPLEMENTATION} contract #fast`, () => { expect(await broker.dutchTradeDisabled(token0.address)).to.equal(false) // Disable batch trade manually - await disableBatchTrade() + await disableBatchTrade(broker) expect(await broker.batchTradeDisabled()).to.equal(true) // Enable batch trade with owner @@ -425,7 +401,7 @@ describe(`BrokerP${IMPLEMENTATION} contract #fast`, () => { expect(await broker.dutchTradeDisabled(token0.address)).to.equal(false) // Disable dutch trade manually - await disableDutchTrade(token0.address) + await disableDutchTrade(broker, token0.address) expect(await broker.dutchTradeDisabled(token0.address)).to.equal(true) // Enable dutch trade with owner @@ -444,7 +420,7 @@ describe(`BrokerP${IMPLEMENTATION} contract #fast`, () => { describe('Trade Management', () => { it('Should not allow to open Batch trade if Disabled', async () => { // Disable Broker Batch Auctions - await disableBatchTrade() + await disableBatchTrade(broker) const tradeRequest: ITradeRequest = { sell: collateral0.address, @@ -479,7 +455,7 @@ describe(`BrokerP${IMPLEMENTATION} contract #fast`, () => { .callStatic.openTrade(TradeKind.DUTCH_AUCTION, tradeRequest, prices) // Disable Broker Dutch Auctions for token0 - await disableDutchTrade(token0.address) + await disableDutchTrade(broker, token0.address) // Dutch Auction openTrade should fail now await expect( @@ -498,7 +474,7 @@ describe(`BrokerP${IMPLEMENTATION} contract #fast`, () => { .callStatic.openTrade(TradeKind.DUTCH_AUCTION, tradeRequest, prices) // Disable Broker Dutch Auctions for token1 - await disableDutchTrade(token1.address) + await disableDutchTrade(broker, token1.address) // Dutch Auction openTrade should fail now await expect( diff --git a/test/Facade.test.ts b/test/Facade.test.ts index 9da2b8398..df8269dc8 100644 --- a/test/Facade.test.ts +++ b/test/Facade.test.ts @@ -3,11 +3,13 @@ import { anyValue } from '@nomicfoundation/hardhat-chai-matchers/withArgs' import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' import { expect } from 'chai' import { BigNumber, ContractFactory } from 'ethers' -import { ethers } from 'hardhat' +import { ethers, upgrades } from 'hardhat' import { expectEvents } from '../common/events' -import { IConfig } from '#/common/configuration' +import { IConfig, IMonitorParams } from '#/common/configuration' import { bn, fp } from '../common/numbers' import { setOraclePrice } from './utils/oracles' +import { disableBatchTrade, disableDutchTrade } from './utils/trades' + import { Asset, BackingManagerP1, @@ -18,6 +20,8 @@ import { CTokenWrapperMock, ERC20Mock, FacadeAct, + FacadeMonitor, + FacadeMonitorV2, FacadeRead, FacadeTest, MockV3Aggregator, @@ -49,7 +53,13 @@ import { PRICE_TIMEOUT, } from './fixtures' import { advanceBlocks, getLatestBlockTimestamp, setNextBlockTimestamp } from './utils/time' -import { CollateralStatus, TradeKind, MAX_UINT256, ZERO_ADDRESS } from '#/common/constants' +import { + CollateralStatus, + TradeKind, + MAX_UINT256, + ONE_PERIOD, + ZERO_ADDRESS, +} from '#/common/constants' import { expectTrade } from './utils/trades' import { mintCollaterals } from './utils/tokens' @@ -57,7 +67,7 @@ const describeP1 = IMPLEMENTATION == Implementation.P1 ? describe : describe.ski const itP1 = IMPLEMENTATION == Implementation.P1 ? it : it.skip -describe('FacadeRead + FacadeAct contracts', () => { +describe('FacadeRead + FacadeAct + FacadeMonitor contracts', () => { let owner: SignerWithAddress let addr1: SignerWithAddress let addr2: SignerWithAddress @@ -85,6 +95,7 @@ describe('FacadeRead + FacadeAct contracts', () => { let facade: FacadeRead let facadeTest: FacadeTest let facadeAct: FacadeAct + let facadeMonitor: FacadeMonitor // Main let rToken: TestIRToken @@ -127,6 +138,7 @@ describe('FacadeRead + FacadeAct contracts', () => { facade, facadeAct, facadeTest, + facadeMonitor, rToken, main, basketHandler, @@ -1047,6 +1059,339 @@ describe('FacadeRead + FacadeAct contracts', () => { } }) + describe('FacadeMonitor', () => { + const monitorParams: IMonitorParams = { + AAVE_V2_DATA_PROVIDER_ADDR: ZERO_ADDRESS, + } + + beforeEach(async () => { + // Mint Tokens + initialBal = bn('10000000000e18') + await token.connect(owner).mint(addr1.address, initialBal) + await usdc.connect(owner).mint(addr1.address, initialBal) + await aToken.connect(owner).mint(addr1.address, initialBal) + await cTokenVault.connect(owner).mint(addr1.address, initialBal) + + // Provide approvals + await token.connect(addr1).approve(rToken.address, initialBal) + await usdc.connect(addr1).approve(rToken.address, initialBal) + await aToken.connect(addr1).approve(rToken.address, initialBal) + await cTokenVault.connect(addr1).approve(rToken.address, initialBal) + }) + + it('should return batch auctions disabled correctly', async () => { + expect(await facadeMonitor.batchAuctionsDisabled(rToken.address)).to.equal(false) + + // Disable Broker Batch Auctions + await disableBatchTrade(broker) + + expect(await facadeMonitor.batchAuctionsDisabled(rToken.address)).to.equal(true) + }) + + it('should return dutch auctions disabled correctly', async () => { + expect(await facadeMonitor.dutchAuctionsDisabled(rToken.address)).to.equal(false) + + // Disable Broker Dutch Auctions for token0 + await disableDutchTrade(broker, token.address) + + expect(await facadeMonitor.dutchAuctionsDisabled(rToken.address)).to.equal(true) + }) + + it('should return issuance available', async () => { + expect(await rToken.issuanceAvailable()).to.equal(config.issuanceThrottle.amtRate) + expect(await facadeMonitor.issuanceAvailable(rToken.address)).to.equal(fp('1')) + expect(await facadeMonitor.redemptionAvailable(rToken.address)).to.equal(fp('1')) // no supply + + // Issue some RTokens (1%) + const issueAmount = bn('10000e18') + + // Issue rTokens (1%) + await rToken.connect(addr1).issue(issueAmount) + + // check throttles updated + expect(await facadeMonitor.issuanceAvailable(rToken.address)).to.equal(fp('0.99')) + expect(await facadeMonitor.redemptionAvailable(rToken.address)).to.equal(fp('1')) + + // Issue additional rTokens (another 1%) + await rToken.connect(addr1).issue(issueAmount) + + // Should be 2% down minus some recharging + expect(await facadeMonitor.issuanceAvailable(rToken.address)).to.be.closeTo( + fp('0.98'), + fp('0.001') + ) + expect(await facadeMonitor.redemptionAvailable(rToken.address)).to.equal(fp('1')) + + // Advance time significantly + await advanceTime(10000000) + + // Check new issuance available - fully recharged + expect(await rToken.issuanceAvailable()).to.equal(config.issuanceThrottle.amtRate) + expect(await facadeMonitor.issuanceAvailable(rToken.address)).to.equal(fp('1')) + expect(await facadeMonitor.redemptionAvailable(rToken.address)).to.equal(fp('1')) + + // Issuance #2 - Consume all throttle + const issueAmount2: BigNumber = config.issuanceThrottle.amtRate + await setNextBlockTimestamp(Number(await getLatestBlockTimestamp()) + Number(ONE_PERIOD)) + await rToken.connect(addr1).issue(issueAmount2) + + // Check new issuance available - all consumed + expect(await rToken.issuanceAvailable()).to.equal(bn(0)) + expect(await facadeMonitor.issuanceAvailable(rToken.address)).to.equal(bn(0)) + expect(await facadeMonitor.redemptionAvailable(rToken.address)).to.equal(fp('1')) + }) + + it('should return redemption available', async () => { + const issueAmount = bn('100000e18') + + // Decrease redemption allowed amount + const redeemThrottleParams = { amtRate: issueAmount.div(2), pctRate: fp('0.1') } // 50K + await rToken.connect(owner).setRedemptionThrottleParams(redeemThrottleParams) + + // Check with no supply + expect(await facadeMonitor.issuanceAvailable(rToken.address)).to.equal(fp('1')) + expect(await rToken.redemptionAvailable()).to.equal(bn(0)) + expect(await facadeMonitor.redemptionAvailable(rToken.address)).to.equal(fp('1')) + + // Issue some RTokens + await rToken.connect(addr1).issue(issueAmount) + + // check throttles - redemption still fully available + expect(await facadeMonitor.issuanceAvailable(rToken.address)).to.equal(fp('0.9')) + expect(await facadeMonitor.redemptionAvailable(rToken.address)).to.equal(fp('1')) + + // Redeem RTokens (50% of throttle) + await rToken.connect(addr1).redeem(issueAmount.div(4)) + + // check throttle - redemption allowed decreased to 50% + expect(await rToken.redemptionAvailable()).to.equal(issueAmount.div(4)) + expect(await facadeMonitor.redemptionAvailable(rToken.address)).to.equal(fp('0.5')) + + // Advance time significantly + await advanceTime(10000000) + + // Check redemption available - fully recharged + expect(await facadeMonitor.redemptionAvailable(rToken.address)).to.equal(fp('1')) + + // Redemption #2 - Consume all throttle + await rToken.connect(addr1).redeem(issueAmount.div(2)) + + // Check new redemption available - all consumed + expect(await rToken.redemptionAvailable()).to.equal(bn(0)) + expect(await facadeMonitor.redemptionAvailable(rToken.address)).to.equal(bn(0)) + }) + + it('Should handle issuance/redemption throttles correctly, using percent', async function () { + // Full issuance available. Nothing to redeem + expect(await rToken.issuanceAvailable()).to.equal(config.issuanceThrottle.amtRate) + expect(await rToken.redemptionAvailable()).to.equal(bn(0)) + expect(await facadeMonitor.issuanceAvailable(rToken.address)).to.equal(fp('1')) + expect(await facadeMonitor.redemptionAvailable(rToken.address)).to.equal(fp('1')) + + // Issue full throttle + const issueAmount1: BigNumber = config.issuanceThrottle.amtRate + await setNextBlockTimestamp(Number(await getLatestBlockTimestamp()) + Number(ONE_PERIOD)) + await rToken.connect(addr1).issue(issueAmount1) + + // Check redemption throttles updated + expect(await facadeMonitor.issuanceAvailable(rToken.address)).to.equal(bn(0)) + expect(await facadeMonitor.redemptionAvailable(rToken.address)).to.equal(fp('1')) + + // Advance time significantly + await advanceTime(1000000000) + + // Check new issuance available - fully recharged + expect(await rToken.issuanceAvailable()).to.equal(config.issuanceThrottle.amtRate) + expect(await rToken.redemptionAvailable()).to.equal(issueAmount1) + + expect(await facadeMonitor.issuanceAvailable(rToken.address)).to.equal(fp('1')) + expect(await facadeMonitor.redemptionAvailable(rToken.address)).to.equal(fp('1')) + + // Issuance #2 - Full throttle again - will be processed + const issueAmount2: BigNumber = config.issuanceThrottle.amtRate + await setNextBlockTimestamp(Number(await getLatestBlockTimestamp()) + Number(ONE_PERIOD)) + await rToken.connect(addr1).issue(issueAmount2) + + // Check new issuance available - all consumed + expect(await facadeMonitor.issuanceAvailable(rToken.address)).to.equal(bn(0)) + + // Check redemption throttle updated - fixed in max (does not exceed) + expect(await rToken.redemptionAvailable()).to.equal(config.redemptionThrottle.amtRate) + expect(await facadeMonitor.redemptionAvailable(rToken.address)).to.equal(fp('1')) + + // Set issuance throttle to percent only + const issuanceThrottleParams = { amtRate: fp('1'), pctRate: fp('0.1') } // 10% + await rToken.connect(owner).setIssuanceThrottleParams(issuanceThrottleParams) + + // Advance time significantly + await advanceTime(1000000000) + + // Check new issuance available - 10% of supply (2 M) = 200K + const supplyThrottle = bn('200000e18') + expect(await rToken.issuanceAvailable()).to.equal(supplyThrottle) + expect(await facadeMonitor.issuanceAvailable(rToken.address)).to.equal(fp('1')) + + // Check redemption throttle unchanged + expect(await rToken.redemptionAvailable()).to.equal(config.redemptionThrottle.amtRate) + expect(await facadeMonitor.redemptionAvailable(rToken.address)).to.equal(fp('1')) + + // Issuance #3 - Should be allowed, does not exceed supply restriction + const issueAmount3: BigNumber = bn('100000e18') + await setNextBlockTimestamp(Number(await getLatestBlockTimestamp()) + Number(ONE_PERIOD)) + await rToken.connect(addr1).issue(issueAmount3) + + // Check issuance throttle updated - Previous issuances recharged + expect(await rToken.issuanceAvailable()).to.equal(supplyThrottle.sub(issueAmount3)) + + // Hourly Limit: 210K (10% of total supply of 2.1 M) + // Available: 100 K / 201K (~ 0.47619) + expect(await facadeMonitor.issuanceAvailable(rToken.address)).to.be.closeTo( + fp('0.476'), + fp('0.001') + ) + + // Check redemption throttle unchanged + expect(await rToken.redemptionAvailable()).to.equal(config.redemptionThrottle.amtRate) + expect(await facadeMonitor.redemptionAvailable(rToken.address)).to.equal(fp('1')) + + // Check all issuances are confirmed + expect(await rToken.balanceOf(addr1.address)).to.equal( + issueAmount1.add(issueAmount2).add(issueAmount3) + ) + + // Advance time, issuance will recharge a bit + await advanceTime(100) + + // Now 50% of hourly limit available (~105.8K / 210 K) + expect(await rToken.issuanceAvailable()).to.be.closeTo(fp('105800'), fp('100')) + expect(await facadeMonitor.issuanceAvailable(rToken.address)).to.be.closeTo( + fp('0.5'), + fp('0.01') + ) + expect(await facadeMonitor.redemptionAvailable(rToken.address)).to.equal(fp('1')) + + const issueAmount4: BigNumber = fp('105800') + // Issuance #4 - almost all available + await setNextBlockTimestamp(Number(await getLatestBlockTimestamp()) + Number(ONE_PERIOD)) + await rToken.connect(addr1).issue(issueAmount4) + + expect(await facadeMonitor.issuanceAvailable(rToken.address)).to.be.closeTo( + fp('0.003'), + fp('0.001') + ) + expect(await facadeMonitor.redemptionAvailable(rToken.address)).to.equal(fp('1')) + + // Advance time significantly to fully recharge + await advanceTime(1000000000) + + expect(await facadeMonitor.issuanceAvailable(rToken.address)).to.equal(fp('1')) + expect(await facadeMonitor.redemptionAvailable(rToken.address)).to.equal(fp('1')) + + // Check redemptions + // Set redemption throttle to percent only + const redemptionThrottleParams = { amtRate: fp('1'), pctRate: fp('0.1') } // 10% + await rToken.connect(owner).setRedemptionThrottleParams(redemptionThrottleParams) + + const totalSupply = await rToken.totalSupply() + expect(await rToken.redemptionAvailable()).to.equal(totalSupply.div(10)) // 10% + expect(await facadeMonitor.redemptionAvailable(rToken.address)).to.equal(fp('1')) + + // Redeem half of the available throttle + await rToken.connect(addr1).redeem(totalSupply.div(10).div(2)) + + // About 52% now used of redemption throttle + expect(await facadeMonitor.redemptionAvailable(rToken.address)).to.be.closeTo( + fp('0.52'), + fp('0.01') + ) + + // Advance time significantly to fully recharge + await advanceTime(1000000000) + + expect(await facadeMonitor.issuanceAvailable(rToken.address)).to.equal(fp('1')) + expect(await facadeMonitor.redemptionAvailable(rToken.address)).to.equal(fp('1')) + + // Redeem all remaining + await rToken.connect(addr1).redeem(await rToken.redemptionAvailable()) + + // Check all consumed + expect(await facadeMonitor.issuanceAvailable(rToken.address)).to.equal(fp('1')) + expect(await rToken.redemptionAvailable()).to.equal(bn(0)) + expect(await facadeMonitor.redemptionAvailable(rToken.address)).to.equal(bn(0)) + }) + + it('Should not allow empty owner on initialization', async () => { + const FacadeMonitorFactory: ContractFactory = await ethers.getContractFactory('FacadeMonitor') + + const newFacadeMonitor = await upgrades.deployProxy(FacadeMonitorFactory, [], { + constructorArgs: [monitorParams], + kind: 'uups', + }) + + await expect(newFacadeMonitor.init(ZERO_ADDRESS)).to.be.revertedWith('invalid owner address') + }) + + it('Should allow owner to transfer ownership', async () => { + expect(await facadeMonitor.owner()).to.equal(owner.address) + + // Attempt to transfer ownership with another account + await expect( + facadeMonitor.connect(addr1).transferOwnership(addr1.address) + ).to.be.revertedWith('Ownable: caller is not the owner') + + // Owner remains the same + expect(await facadeMonitor.owner()).to.equal(owner.address) + + // Transfer ownership with owner + await expect(facadeMonitor.connect(owner).transferOwnership(addr1.address)) + .to.emit(facadeMonitor, 'OwnershipTransferred') + .withArgs(owner.address, addr1.address) + + // Owner changed + expect(await facadeMonitor.owner()).to.equal(addr1.address) + }) + + it('Should only allow owner to upgrade', async () => { + const FacadeMonitorV2Factory: ContractFactory = await ethers.getContractFactory( + 'FacadeMonitorV2' + ) + const facadeMonitorV2 = await FacadeMonitorV2Factory.deploy(monitorParams) + + await expect( + facadeMonitor.connect(addr1).upgradeTo(facadeMonitorV2.address) + ).to.be.revertedWith('Ownable: caller is not the owner') + await expect(facadeMonitor.connect(owner).upgradeTo(facadeMonitorV2.address)).to.not.be + .reverted + }) + + it('Should upgrade correctly', async () => { + // Upgrading + const FacadeMonitorV2Factory: ContractFactory = await ethers.getContractFactory( + 'FacadeMonitorV2' + ) + const facadeMonitorV2: FacadeMonitorV2 = await upgrades.upgradeProxy( + facadeMonitor.address, + FacadeMonitorV2Factory, + { + constructorArgs: [monitorParams], + } + ) + + // Check address is maintained + expect(facadeMonitorV2.address).to.equal(facadeMonitor.address) + + // Check state is preserved + expect(await facadeMonitorV2.owner()).to.equal(owner.address) + + // Check new version is implemented + expect(await facadeMonitorV2.version()).to.equal('2.0.0') + + expect(await facadeMonitorV2.newValue()).to.equal(0) + await facadeMonitorV2.connect(owner).setNewValue(bn(1000)) + expect(await facadeMonitorV2.newValue()).to.equal(bn(1000)) + }) + }) + // P1 only describeP1('FacadeAct', () => { let issueAmount: BigNumber diff --git a/test/fixtures.ts b/test/fixtures.ts index 6244d68ff..a787359e1 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -1,11 +1,23 @@ import { ContractFactory } from 'ethers' import { expect } from 'chai' -import hre, { ethers } from 'hardhat' +import hre, { ethers, upgrades } from 'hardhat' import { getChainId } from '../common/blockchain-utils' -import { IConfig, IImplementations, IRevenueShare, networkConfig } from '../common/configuration' +import { + IConfig, + IImplementations, + IMonitorParams, + IRevenueShare, + networkConfig, +} from '../common/configuration' import { expectInReceipt } from '../common/events' import { bn, fp } from '../common/numbers' -import { CollateralStatus, PAUSER, LONG_FREEZER, SHORT_FREEZER } from '../common/constants' +import { + CollateralStatus, + PAUSER, + LONG_FREEZER, + SHORT_FREEZER, + ZERO_ADDRESS, +} from '../common/constants' import { Asset, AssetRegistryP1, @@ -24,6 +36,7 @@ import { DutchTrade, FacadeRead, FacadeAct, + FacadeMonitor, FacadeTest, DistributorP1, FiatCollateral, @@ -412,6 +425,7 @@ export interface DefaultFixture extends RSRAndCompAaveAndCollateralAndModuleFixt facade: FacadeRead facadeAct: FacadeAct facadeTest: FacadeTest + facadeMonitor: FacadeMonitor broker: TestIBroker rsrTrader: TestIRevenueTrader rTokenTrader: TestIRevenueTrader @@ -468,6 +482,11 @@ const makeDefaultFixture = async (setBasket: boolean): Promise = }, } + // Setup Monitor Params (mock addrs for local deployment) + const monitorParams: IMonitorParams = { + AAVE_V2_DATA_PROVIDER_ADDR: ZERO_ADDRESS, + } + // Deploy TradingLib external library const TradingLibFactory: ContractFactory = await ethers.getContractFactory('TradingLibP0') const tradingLib: TradingLibP0 = await TradingLibFactory.deploy() @@ -484,6 +503,19 @@ const makeDefaultFixture = async (setBasket: boolean): Promise = const FacadeTestFactory: ContractFactory = await ethers.getContractFactory('FacadeTest') const facadeTest = await FacadeTestFactory.deploy() + // Deploy FacadeMonitor + const FacadeMonitorFactory: ContractFactory = await ethers.getContractFactory('FacadeMonitor') + + const facadeMonitor = await upgrades.deployProxy( + FacadeMonitorFactory, + [owner.address], + { + kind: 'uups', + initializer: 'init', + constructorArgs: [monitorParams], + } + ) + // Deploy RSR chainlink feed const MockV3AggregatorFactory: ContractFactory = await ethers.getContractFactory( 'MockV3Aggregator' @@ -751,6 +783,7 @@ const makeDefaultFixture = async (setBasket: boolean): Promise = facade, facadeAct, facadeTest, + facadeMonitor, rsrTrader, rTokenTrader, bySymbol, diff --git a/test/integration/fixtures.ts b/test/integration/fixtures.ts index 206cfb2af..c9778bb7d 100644 --- a/test/integration/fixtures.ts +++ b/test/integration/fixtures.ts @@ -1,8 +1,14 @@ import { BigNumber, ContractFactory } from 'ethers' import hre, { ethers } from 'hardhat' import { getChainId } from '../../common/blockchain-utils' -import { IConfig, IImplementations, IRevenueShare, networkConfig } from '../../common/configuration' -import { PAUSER, SHORT_FREEZER, LONG_FREEZER } from '../../common/constants' +import { + IConfig, + IImplementations, + IMonitorParams, + IRevenueShare, + networkConfig, +} from '../../common/configuration' +import { PAUSER, SHORT_FREEZER, LONG_FREEZER, ZERO_ADDRESS } from '../../common/constants' import { expectInReceipt } from '../../common/events' import { advanceTime } from '../utils/time' import { bn, fp } from '../../common/numbers' @@ -54,6 +60,7 @@ import { TestIRToken, TestIStRSR, RecollateralizationLibP1, + FacadeMonitor, } from '../../typechain' import { Collateral, @@ -247,6 +254,7 @@ export async function collateralFixture( 'stat' + symbol ) ) + const coll = await ATokenCollateralFactory.deploy( { priceTimeout: PRICE_TIMEOUT, @@ -584,7 +592,7 @@ type RSRAndCompAaveAndCollateralAndModuleFixture = RSRFixture & CollateralFixture & ModuleFixture -interface DefaultFixture extends RSRAndCompAaveAndCollateralAndModuleFixture { +export interface DefaultFixture extends RSRAndCompAaveAndCollateralAndModuleFixture { config: IConfig dist: IRevenueShare deployer: TestIDeployer @@ -603,6 +611,7 @@ interface DefaultFixture extends RSRAndCompAaveAndCollateralAndModuleFixture { facade: FacadeRead facadeAct: FacadeAct facadeTest: FacadeTest + facadeMonitor: FacadeMonitor broker: TestIBroker rsrTrader: TestIRevenueTrader rTokenTrader: TestIRevenueTrader @@ -663,6 +672,11 @@ const makeDefaultFixture = async (setBasket: boolean): Promise = }, } + // Setup Monitor Params based on network + const monitorParams: IMonitorParams = { + AAVE_V2_DATA_PROVIDER_ADDR: networkConfig[chainId].AAVE_DATA_PROVIDER ?? ZERO_ADDRESS, + } + // Deploy FacadeRead const FacadeReadFactory: ContractFactory = await ethers.getContractFactory('FacadeRead') const facade = await FacadeReadFactory.deploy() @@ -675,6 +689,10 @@ const makeDefaultFixture = async (setBasket: boolean): Promise = const FacadeTestFactory: ContractFactory = await ethers.getContractFactory('FacadeTest') const facadeTest = await FacadeTestFactory.deploy() + // Deploy FacadeMonitor - Use implementation to simplify deployments + const FacadeMonitorFactory: ContractFactory = await ethers.getContractFactory('FacadeMonitor') + const facadeMonitor = await FacadeMonitorFactory.deploy(monitorParams) + // Deploy TradingLib external library const TradingLibFactory: ContractFactory = await ethers.getContractFactory( 'RecollateralizationLibP1' @@ -930,6 +948,7 @@ const makeDefaultFixture = async (setBasket: boolean): Promise = facade, facadeAct, facadeTest, + facadeMonitor, rsrTrader, rTokenTrader, } diff --git a/test/integration/fork-block-numbers.ts b/test/integration/fork-block-numbers.ts index c575f48e3..f5b5dff06 100644 --- a/test/integration/fork-block-numbers.ts +++ b/test/integration/fork-block-numbers.ts @@ -5,6 +5,7 @@ const forkBlockNumber = { 'mainnet-deployment': 15690042, // Ethereum 'flux-finance': 16836855, // Ethereum 'mainnet-2.0': 17522362, // Ethereum + 'facade-monitor': 18742016, // Ethereum default: 18522901, // Ethereum } diff --git a/test/monitor/FacadeMonitor.test.ts b/test/monitor/FacadeMonitor.test.ts new file mode 100644 index 000000000..45b7bf1d2 --- /dev/null +++ b/test/monitor/FacadeMonitor.test.ts @@ -0,0 +1,1417 @@ +import { loadFixture } from '@nomicfoundation/hardhat-network-helpers' +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' +import { expect } from 'chai' +import { BigNumber, Contract } from 'ethers' +import hre, { ethers } from 'hardhat' +import { Collateral, IMPLEMENTATION } from '../fixtures' +import { defaultFixtureNoBasket, DefaultFixture } from '../integration/fixtures' +import { getChainId } from '../../common/blockchain-utils' +import { IConfig, baseL2Chains, networkConfig } from '../../common/configuration' +import { bn, fp, toBNDecimals } from '../../common/numbers' +import { advanceTime } from '../utils/time' +import { whileImpersonating } from '../utils/impersonation' +import { pushOracleForward } from '../utils/oracles' + +import forkBlockNumber from '../integration/fork-block-numbers' +import { + ATokenFiatCollateral, + AaveV3FiatCollateral, + CTokenV3Collateral, + CTokenFiatCollateral, + ERC20Mock, + FacadeTest, + FacadeMonitor, + FiatCollateral, + IAToken, + IComptroller, + IERC20, + ILendingPool, + IPool, + IWETH, + StaticATokenLM, + IAssetRegistry, + TestIBackingManager, + TestIBasketHandler, + TestICToken, + TestIRToken, + USDCMock, + CTokenWrapper, + StaticATokenV3LM, + CusdcV3Wrapper, + CometInterface, + StargateRewardableWrapper, + StargatePoolFiatCollateral, + IStargatePool, + MorphoAaveV2TokenisedDeposit, +} from '../../typechain' +import { useEnv } from '#/utils/env' +import { MAX_UINT256 } from '#/common/constants' + +enum CollPluginType { + AAVE_V2, + AAVE_V3, + COMPOUND_V2, + COMPOUND_V3, + STARGATE, + FLUX, + MORPHO_AAVE_V2, +} + +// Relevant addresses (Mainnet) +const holderDAI = '0x075e72a5eDf65F0A5f44699c7654C1a76941Ddc8' +const holderCDAI = '0x01d127D90513CCB6071F83eFE15611C4d9890668' +const holderADAI = '0x07edE94cF6316F4809f2B725f5d79AD303fB4Dc8' +const holderaUSDCV3 = '0x1eAb3B222A5B57474E0c237E7E1C4312C1066855' +const holderWETH = '0xF04a5cC80B1E94C69B48f5ee68a08CD2F09A7c3E' +const holdercUSDCV3 = '0x7f714b13249BeD8fdE2ef3FBDfB18Ed525544B03' +const holdersUSDC = '0xB0D502E938ed5f4df2E681fE6E419ff29631d62b' +const holderfUSDC = '0x86A07dDED024121b282362f4e7A249b00F5dAB37' +const holderUSDC = '0x28C6c06298d514Db089934071355E5743bf21d60' + +let owner: SignerWithAddress + +const describeFork = useEnv('FORK') ? describe : describe.skip + +describeFork(`FacadeMonitor - Integration - Mainnet Forking P${IMPLEMENTATION}`, function () { + let addr1: SignerWithAddress + let addr2: SignerWithAddress + + // Assets + let collateral: Collateral[] + + // Tokens and Assets + let dai: ERC20Mock + let aDai: IAToken + let stataDai: StaticATokenLM + let usdc: USDCMock + let aUsdcV3: IAToken + let sUsdc: IStargatePool + let fUsdc: TestICToken + let weth: IWETH + let cDai: TestICToken + let cDaiVault: CTokenWrapper + let cusdcV3: CometInterface + let daiCollateral: FiatCollateral + let aDaiCollateral: ATokenFiatCollateral + + // Contracts to retrieve after deploy + let rToken: TestIRToken + let facadeTest: FacadeTest + let facadeMonitor: FacadeMonitor + let assetRegistry: IAssetRegistry + let basketHandler: TestIBasketHandler + let backingManager: TestIBackingManager + let config: IConfig + + let initialBal: BigNumber + let basket: Collateral[] + let erc20s: IERC20[] + + let fullLiquidityAmt: BigNumber + let chainId: number + + // 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, + }, + }, + ], + }) + } + + describe('FacadeMonitor', () => { + before(async () => { + await setup(forkBlockNumber['facade-monitor']) + + chainId = await getChainId(hre) + if (!networkConfig[chainId]) { + throw new Error(`Missing network configuration for ${hre.network.name}`) + } + }) + + beforeEach(async () => { + ;[owner, addr1, addr2] = await ethers.getSigners() + ;({ + erc20s, + collateral, + basket, + assetRegistry, + basketHandler, + backingManager, + rToken, + facadeTest, + facadeMonitor, + config, + } = await loadFixture(defaultFixtureNoBasket)) + + // Get tokens + dai = erc20s[0] // DAI + cDaiVault = erc20s[6] // cDAI + cDai = await ethers.getContractAt('TestICToken', await cDaiVault.underlying()) // cDAI + stataDai = erc20s[10] // static aDAI + + // Get plain aTokens + aDai = ( + await ethers.getContractAt( + '@aave/protocol-v2/contracts/interfaces/IAToken.sol:IAToken', + networkConfig[chainId].tokens.aDAI || '' + ) + ) + + // Get collaterals + daiCollateral = collateral[0] // DAI + aDaiCollateral = collateral[10] // aDAI + + // Get assets and tokens for default basket + daiCollateral = basket[0] + aDaiCollateral = basket[1] + + dai = await ethers.getContractAt('ERC20Mock', await daiCollateral.erc20()) + stataDai = ( + await ethers.getContractAt('StaticATokenLM', await aDaiCollateral.erc20()) + ) + + // Get plain aToken + aDai = ( + await ethers.getContractAt( + '@aave/protocol-v2/contracts/interfaces/IAToken.sol:IAToken', + networkConfig[chainId].tokens.aDAI || '' + ) + ) + + usdc = ( + await ethers.getContractAt('USDCMock', networkConfig[chainId].tokens.USDC || '') + ) + aUsdcV3 = await ethers.getContractAt( + '@aave/protocol-v2/contracts/interfaces/IAToken.sol:IAToken', // use V2 interface, it includes ERC20 + networkConfig[chainId].tokens.aEthUSDC || '' + ) + + cusdcV3 = ( + await ethers.getContractAt('CometInterface', networkConfig[chainId].tokens.cUSDCv3 || '') + ) + + sUsdc = ( + await ethers.getContractAt('IStargatePool', networkConfig[chainId].tokens.sUSDC || '') + ) + + fUsdc = ( + await ethers.getContractAt('TestICToken', networkConfig[chainId].tokens.fUSDC || '') + ) + + initialBal = bn('2500000e18') + + // Fund user with static aDAI + await whileImpersonating(holderADAI, async (adaiSigner) => { + // Wrap ADAI into static ADAI + await aDai.connect(adaiSigner).transfer(addr1.address, initialBal) + await aDai.connect(addr1).approve(stataDai.address, initialBal) + await stataDai.connect(addr1).deposit(addr1.address, initialBal, 0, false) + }) + + // Fund user with aUSDCV3 + await whileImpersonating(holderaUSDCV3, async (ausdcV3Signer) => { + await aUsdcV3.connect(ausdcV3Signer).transfer(addr1.address, toBNDecimals(initialBal, 6)) + }) + + // Fund user with DAI + await whileImpersonating(holderDAI, async (daiSigner) => { + await dai.connect(daiSigner).transfer(addr1.address, initialBal.mul(8)) + }) + + // Fund user with cDAI + await whileImpersonating(holderCDAI, async (cdaiSigner) => { + await cDai.connect(cdaiSigner).transfer(addr1.address, toBNDecimals(initialBal, 8).mul(100)) + await cDai.connect(addr1).approve(cDaiVault.address, toBNDecimals(initialBal, 8).mul(100)) + await cDaiVault.connect(addr1).deposit(toBNDecimals(initialBal, 8).mul(100), addr1.address) + }) + + // Fund user with cUSDCV3 + await whileImpersonating(holdercUSDCV3, async (cusdcV3Signer) => { + await cusdcV3.connect(cusdcV3Signer).transfer(addr1.address, toBNDecimals(initialBal, 6)) + }) + + // Fund user with sUSDC + await whileImpersonating(holdersUSDC, async (susdcSigner) => { + await sUsdc.connect(susdcSigner).transfer(addr1.address, toBNDecimals(initialBal, 6)) + }) + + // Fund user with fUSDC + await whileImpersonating(holderfUSDC, async (fusdcSigner) => { + await fUsdc + .connect(fusdcSigner) + .transfer(addr1.address, toBNDecimals(initialBal, 8).mul(100)) + }) + + // Fund user with USDC + await whileImpersonating(holderUSDC, async (usdcSigner) => { + await usdc.connect(usdcSigner).transfer(addr1.address, toBNDecimals(initialBal, 6)) + }) + + // Fund user with WETH + weth = await ethers.getContractAt('IWETH', networkConfig[chainId].tokens.WETH || '') + await whileImpersonating(holderWETH, async (signer) => { + await weth.connect(signer).transfer(addr1.address, fp('500000')) + }) + }) + + describe('AAVE V2', () => { + const issueAmount: BigNumber = bn('1000000e18') + let lendingPool: ILendingPool + let aaveV2DataProvider: Contract + + beforeEach(async () => { + // Setup basket + await basketHandler.connect(owner).setPrimeBasket([stataDai.address], [fp('1')]) + await basketHandler.connect(owner).refreshBasket() + await advanceTime(Number(config.warmupPeriod) + 1) + + // Provide approvals + await stataDai.connect(addr1).approve(rToken.address, issueAmount) + + // Advance time significantly - Recharge throttle + await advanceTime(100000) + + // Issue rTokens + await rToken.connect(addr1).issue(issueAmount) + + lendingPool = ( + await ethers.getContractAt('ILendingPool', networkConfig[chainId].AAVE_LENDING_POOL || '') + ) + + const aaveV2DataProviderAbi = [ + 'function getReserveData(address asset) external view returns (uint256 availableLiquidity,uint256 totalStableDebt,uint256 totalVariableDebt,uint256 liquidityRate,uint256 variableBorrowRate,uint256 stableBorrowRate,uint256 averageStableBorrowRate,uint256 liquidityIndex,uint256 variableBorrowIndex,uint40 lastUpdateTimestamp)', + ] + aaveV2DataProvider = await ethers.getContractAt( + aaveV2DataProviderAbi, + networkConfig[chainId].AAVE_DATA_PROVIDER || '' + ) + + // Get current liquidity + ;[fullLiquidityAmt, , , , , , , , ,] = await aaveV2DataProvider + .connect(addr1) + .getReserveData(dai.address) + + // Provide liquidity in AAVE V2 to be able to borrow + const amountToDeposit = fp('500000') + await weth.connect(addr1).approve(lendingPool.address, amountToDeposit) + await lendingPool.connect(addr1).deposit(weth.address, amountToDeposit, addr1.address, 0) + }) + + it('Should return 100% when full liquidity available', async function () { + // Check asset value + expect(await facadeTest.callStatic.totalAssetValue(rToken.address)).to.be.closeTo( + issueAmount, + fp('150') + ) + + // AAVE V2 - All redeemable + expect( + await facadeMonitor.backingReedemable( + rToken.address, + CollPluginType.AAVE_V2, + stataDai.address + ) + ).to.equal(fp('1')) + + // Confirm all can be redeemed + expect(await dai.balanceOf(addr2.address)).to.equal(bn(0)) + const bmBalanceAmt = await stataDai.balanceOf(backingManager.address) + await whileImpersonating(backingManager.address, async (bmSigner) => { + await stataDai.connect(bmSigner).transfer(addr2.address, bmBalanceAmt) + }) + await stataDai.connect(addr2).withdraw(addr2.address, bmBalanceAmt, false) + await expect(lendingPool.connect(addr2).withdraw(dai.address, MAX_UINT256, addr2.address)) + .to.not.be.reverted + expect(await dai.balanceOf(addr2.address)).to.be.gt(bn(0)) + expect(await aDai.balanceOf(addr2.address)).to.equal(bn(0)) + }) + + it('Should return backing redeemable percent correctly', async function () { + // AAVE V2 - All redeemable + expect( + await facadeMonitor.backingReedemable( + rToken.address, + CollPluginType.AAVE_V2, + stataDai.address + ) + ).to.equal(fp('1')) + + // Leave only 80% of backing available to be redeemed + const borrowAmount = fullLiquidityAmt.sub(issueAmount.mul(80).div(100)) + await lendingPool.connect(addr1).borrow(dai.address, borrowAmount, 2, 0, addr1.address) + + expect( + await facadeMonitor.backingReedemable( + rToken.address, + CollPluginType.AAVE_V2, + stataDai.address + ) + ).to.be.closeTo(fp('0.80'), fp('0.01')) + + // Borrow half of the remaining liquidity + const remainingLiquidity = fullLiquidityAmt.sub(borrowAmount) + await lendingPool + .connect(addr1) + .borrow(dai.address, remainingLiquidity.div(2), 2, 0, addr1.address) + + // Now only 40% is available to be redeemed + expect( + await facadeMonitor.backingReedemable( + rToken.address, + CollPluginType.AAVE_V2, + stataDai.address + ) + ).to.be.closeTo(fp('0.40'), fp('0.01')) + + // Confirm we cannot redeem full balance + expect(await dai.balanceOf(addr2.address)).to.equal(bn(0)) + const bmBalanceAmt = await stataDai.balanceOf(backingManager.address) + await whileImpersonating(backingManager.address, async (bmSigner) => { + await stataDai.connect(bmSigner).transfer(addr2.address, bmBalanceAmt) + }) + await stataDai.connect(addr2).withdraw(addr2.address, bmBalanceAmt, false) + await expect(lendingPool.connect(addr2).withdraw(dai.address, MAX_UINT256, addr2.address)) + .to.be.reverted + expect(await dai.balanceOf(addr2.address)).to.equal(bn(0)) + + // But we can redeem if we reduce the amount to 30% + await expect( + lendingPool + .connect(addr2) + .withdraw( + dai.address, + (await aDai.balanceOf(addr2.address)).mul(30).div(100), + addr2.address + ) + ).to.not.be.reverted + expect(await dai.balanceOf(addr2.address)).to.be.gt(0) + }) + + it('Should handle no liquidity', async function () { + expect( + await facadeMonitor.backingReedemable( + rToken.address, + CollPluginType.AAVE_V2, + stataDai.address + ) + ).to.equal(fp('1')) + + // Borrow full liquidity + await lendingPool.connect(addr1).borrow(dai.address, fullLiquidityAmt, 2, 0, addr1.address) + + expect( + await facadeMonitor.backingReedemable( + rToken.address, + CollPluginType.AAVE_V2, + stataDai.address + ) + ).to.be.closeTo(fp('0'), fp('0.01')) + + // Confirm we cannot redeem anything, not even 1% + expect(await dai.balanceOf(addr2.address)).to.equal(bn(0)) + const bmBalanceAmt = await stataDai.balanceOf(backingManager.address) + await whileImpersonating(backingManager.address, async (bmSigner) => { + await stataDai.connect(bmSigner).transfer(addr2.address, bmBalanceAmt) + }) + await stataDai.connect(addr2).withdraw(addr2.address, bmBalanceAmt, false) + await expect( + lendingPool + .connect(addr2) + .withdraw(dai.address, (await aDai.balanceOf(addr2.address)).div(100), addr2.address) + ).to.be.reverted + expect(await dai.balanceOf(addr2.address)).to.equal(bn(0)) + }) + }) + + describe('AAVE V3', () => { + const issueAmount: BigNumber = bn('1000000e18') + let stataUsdcV3: StaticATokenV3LM + let pool: IPool + + beforeEach(async () => { + const StaticATokenFactory = await hre.ethers.getContractFactory('StaticATokenV3LM') + stataUsdcV3 = await StaticATokenFactory.deploy( + networkConfig[chainId].AAVE_V3_POOL!, + networkConfig[chainId].AAVE_V3_INCENTIVES_CONTROLLER! + ) + + await stataUsdcV3.deployed() + await ( + await stataUsdcV3.initialize( + networkConfig[chainId].tokens.aEthUSDC!, + 'Static Aave Ethereum USDC', + 'saEthUSDC' + ) + ).wait() + + /******** Deploy Aave V3 USDC collateral plugin **************************/ + const usdcOracleTimeout = '86400' // 24 hr + const usdcOracleError = baseL2Chains.includes(hre.network.name) ? fp('0.003') : fp('0.0025') // 0.3% (Base) or 0.25% + + const MockV3AggregatorFactory = await ethers.getContractFactory('MockV3Aggregator') + const chainlinkFeed = await MockV3AggregatorFactory.deploy(8, bn('1e8')) + + const CollateralFactory = await ethers.getContractFactory('AaveV3FiatCollateral') + const collateral = await CollateralFactory.connect(owner).deploy( + { + priceTimeout: bn('604800'), + chainlinkFeed: chainlinkFeed.address, + oracleError: usdcOracleError, + erc20: stataUsdcV3.address, + maxTradeVolume: fp('1e6'), + oracleTimeout: usdcOracleTimeout, + targetName: ethers.utils.formatBytes32String('USD'), + defaultThreshold: fp('0.01').add(usdcOracleError), + delayUntilDefault: bn('86400'), + }, + fp('1e-6') + ) + + // Register and update collateral + await collateral.deployed() + await (await collateral.refresh()).wait() + await pushOracleForward(chainlinkFeed.address) + await assetRegistry.connect(owner).register(collateral.address) + + // Wrap aUsdcV3 + await aUsdcV3.connect(addr1).approve(stataUsdcV3.address, toBNDecimals(initialBal, 6)) + await stataUsdcV3 + .connect(addr1) + ['deposit(uint256,address,uint16,bool)']( + toBNDecimals(initialBal, 6), + addr1.address, + 0, + false + ) + + // Get current liquidity + fullLiquidityAmt = await usdc.balanceOf(aUsdcV3.address) + + // Setup basket + await pushOracleForward(chainlinkFeed.address) + await basketHandler.connect(owner).setPrimeBasket([stataUsdcV3.address], [fp('1')]) + await basketHandler.connect(owner).refreshBasket() + await advanceTime(Number(config.warmupPeriod) + 1) + + // Provide approvals + await stataUsdcV3.connect(addr1).approve(rToken.address, issueAmount) + + // Advance time significantly - Recharge throttle + await advanceTime(100000) + await pushOracleForward(chainlinkFeed.address) + + // Issue rTokens + await rToken.connect(addr1).issue(issueAmount) + + pool = await ethers.getContractAt('IPool', networkConfig[chainId].AAVE_V3_POOL || '') + + // Provide liquidity to be able to borrow + const amountToDeposit = fp('500000') + await weth.connect(addr1).approve(pool.address, amountToDeposit) + await pool.connect(addr1).supply(weth.address, amountToDeposit, addr1.address, 0) + }) + + it('Should return 100% when full liquidity available', async function () { + // Check asset value + expect(await facadeTest.callStatic.totalAssetValue(rToken.address)).to.be.closeTo( + issueAmount, + fp('150') + ) + + // AAVE V3 - All redeemable + expect( + await facadeMonitor.backingReedemable( + rToken.address, + CollPluginType.AAVE_V3, + stataUsdcV3.address + ) + ).to.equal(fp('1')) + + // Confirm all can be redeemed + expect(await usdc.balanceOf(addr2.address)).to.equal(bn(0)) + const bmBalanceAmt = await stataUsdcV3.balanceOf(backingManager.address) + await whileImpersonating(backingManager.address, async (bmSigner) => { + await stataUsdcV3.connect(bmSigner).transfer(addr2.address, bmBalanceAmt) + }) + await stataUsdcV3 + .connect(addr2) + ['redeem(uint256,address,address,bool)']( + bmBalanceAmt, + addr2.address, + addr2.address, + false + ) + await expect(pool.connect(addr2).withdraw(usdc.address, MAX_UINT256, addr2.address)).to.not + .be.reverted + expect(await usdc.balanceOf(addr2.address)).to.be.gt(bn(0)) + expect(await aUsdcV3.balanceOf(addr2.address)).to.equal(bn(0)) + }) + + it('Should return backing redeemable percent correctly', async function () { + // AAVE V3 - All redeemable + expect( + await facadeMonitor.backingReedemable( + rToken.address, + CollPluginType.AAVE_V3, + stataUsdcV3.address + ) + ).to.equal(fp('1')) + + // Leave only 80% of backing to be able to be redeemed + const borrowAmount = fullLiquidityAmt.sub(toBNDecimals(issueAmount, 6).mul(80).div(100)) + await pool.connect(addr1).borrow(usdc.address, borrowAmount, 2, 0, addr1.address) + + expect( + await facadeMonitor.backingReedemable( + rToken.address, + CollPluginType.AAVE_V3, + stataUsdcV3.address + ) + ).to.be.closeTo(fp('0.80'), fp('0.01')) + + // Borrow half of the remaining liquidity + const remainingLiquidity = fullLiquidityAmt.sub(borrowAmount) + await pool + .connect(addr1) + .borrow(usdc.address, remainingLiquidity.div(2), 2, 0, addr1.address) + + // Only 40% available to be redeemed + expect( + await facadeMonitor.backingReedemable( + rToken.address, + CollPluginType.AAVE_V3, + stataUsdcV3.address + ) + ).to.be.closeTo(fp('0.40'), fp('0.01')) + + // Confirm we cannot redeem full balance + expect(await usdc.balanceOf(addr2.address)).to.equal(bn(0)) + const bmBalanceAmt = await stataUsdcV3.balanceOf(backingManager.address) + await whileImpersonating(backingManager.address, async (bmSigner) => { + await stataUsdcV3.connect(bmSigner).transfer(addr2.address, bmBalanceAmt) + }) + await stataUsdcV3 + .connect(addr2) + ['redeem(uint256,address,address,bool)']( + bmBalanceAmt, + addr2.address, + addr2.address, + false + ) + await expect(pool.connect(addr2).withdraw(usdc.address, MAX_UINT256, addr2.address)).to.be + .reverted + expect(await dai.balanceOf(addr2.address)).to.equal(bn(0)) + + // We can redeem if we reduce to 30% + await expect( + pool + .connect(addr2) + .withdraw( + usdc.address, + (await aUsdcV3.balanceOf(addr2.address)).mul(30).div(100), + addr2.address + ) + ).to.not.be.reverted + expect(await usdc.balanceOf(addr2.address)).to.be.gt(0) + }) + + it('Should handle no liquidity', async function () { + expect( + await facadeMonitor.backingReedemable( + rToken.address, + CollPluginType.AAVE_V3, + stataUsdcV3.address + ) + ).to.equal(fp('1')) + + // Borrow full liquidity + await pool.connect(addr1).borrow(usdc.address, fullLiquidityAmt, 2, 0, addr1.address) + + expect( + await facadeMonitor.backingReedemable( + rToken.address, + CollPluginType.AAVE_V3, + stataUsdcV3.address + ) + ).to.be.closeTo(fp('0'), fp('0.01')) + + // Confirm we cannot redeem anything, not even 1% + expect(await usdc.balanceOf(addr2.address)).to.equal(bn(0)) + const bmBalanceAmt = await stataUsdcV3.balanceOf(backingManager.address) + await whileImpersonating(backingManager.address, async (bmSigner) => { + await stataUsdcV3.connect(bmSigner).transfer(addr2.address, bmBalanceAmt) + }) + await stataUsdcV3 + .connect(addr2) + ['redeem(uint256,address,address,bool)']( + bmBalanceAmt, + addr2.address, + addr2.address, + false + ) + await expect( + pool + .connect(addr2) + .withdraw( + usdc.address, + (await aUsdcV3.balanceOf(addr2.address)).div(100), + addr2.address + ) + ).to.be.reverted + expect(await usdc.balanceOf(addr2.address)).to.equal(bn(0)) + }) + }) + + describe('Compound V2', () => { + const issueAmount: BigNumber = bn('1000000e18') + let comptroller: IComptroller + + beforeEach(async () => { + // Setup basket + await basketHandler.connect(owner).setPrimeBasket([cDaiVault.address], [fp('1')]) + await basketHandler.connect(owner).refreshBasket() + await advanceTime(Number(config.warmupPeriod) + 1) + + // Provide approvals + await cDaiVault + .connect(addr1) + .approve(rToken.address, toBNDecimals(issueAmount, 8).mul(100)) + + // Advance time significantly - Recharge throttle + await advanceTime(100000) + + // Issue rTokens + await rToken.connect(addr1).issue(issueAmount) + + // Get current liquidity + fullLiquidityAmt = await dai.balanceOf(cDai.address) + + // Compound Comptroller + comptroller = await ethers.getContractAt( + 'ComptrollerMock', + networkConfig[chainId].COMPTROLLER || '' + ) + + // Deposit ETH to be able to borrow + const cEtherAbi = [ + 'function mint(uint256 mintAmount) external payable returns (uint256)', + 'function balanceOf(address owner) external view returns (uint256 balance)', + ] + const cEth = await ethers.getContractAt(cEtherAbi, networkConfig[chainId].tokens.cETH || '') + await comptroller.connect(addr1).enterMarkets([cEth.address]) + const amountToDeposit = fp('500000') + await weth.connect(addr1).withdraw(amountToDeposit) + await cEth.connect(addr1).mint(amountToDeposit, { value: amountToDeposit }) + }) + + it('Should return 100% when full liquidity available', async function () { + // Check asset value + expect(await facadeTest.callStatic.totalAssetValue(rToken.address)).to.be.closeTo( + issueAmount, + fp('150') + ) + + // COMPOUND V2 - All redeemable + expect( + await facadeMonitor.backingReedemable( + rToken.address, + CollPluginType.COMPOUND_V2, + cDaiVault.address + ) + ).to.equal(fp('1')) + + // Confirm all can be redeemed + expect(await dai.balanceOf(addr2.address)).to.equal(bn(0)) + const bmBalanceAmt = await cDaiVault.balanceOf(backingManager.address) + await whileImpersonating(backingManager.address, async (bmSigner) => { + await cDaiVault.connect(bmSigner).transfer(addr2.address, bmBalanceAmt) + }) + await cDaiVault.connect(addr2).withdraw(bmBalanceAmt, addr2.address) + expect(await cDai.balanceOf(addr2.address)).to.equal(bmBalanceAmt) + + await expect(cDai.connect(addr2).redeem(bmBalanceAmt)).to.not.be.reverted + expect(await dai.balanceOf(addr2.address)).to.be.gt(bn(0)) + expect(await cDai.balanceOf(addr2.address)).to.equal(bn(0)) + }) + + it('Should return backing redeemable percent correctly', async function () { + // COMPOUND V2 - All redeemable + expect( + await facadeMonitor.backingReedemable( + rToken.address, + CollPluginType.COMPOUND_V2, + cDaiVault.address + ) + ).to.equal(fp('1')) + + // Leave only 80% of backing to be able to be redeemed + const borrowAmount = fullLiquidityAmt.sub(issueAmount.mul(80).div(100)) + await cDai.connect(addr1).borrow(borrowAmount) + + expect( + await facadeMonitor.backingReedemable( + rToken.address, + CollPluginType.COMPOUND_V2, + cDaiVault.address + ) + ).to.be.closeTo(fp('0.80'), fp('0.01')) + + // Borrow half of the remaining liquidity + const remainingLiquidity = fullLiquidityAmt.sub(borrowAmount) + await cDai.connect(addr1).borrow(bn(remainingLiquidity.div(2))) + + // Now only 40% of backing can be redeemed + expect( + await facadeMonitor.backingReedemable( + rToken.address, + CollPluginType.COMPOUND_V2, + cDaiVault.address + ) + ).to.be.closeTo(fp('0.40'), fp('0.01')) + + // Confirm we cannot redeem full balance + expect(await dai.balanceOf(addr2.address)).to.equal(bn(0)) + const bmBalanceAmt = await cDaiVault.balanceOf(backingManager.address) + await whileImpersonating(backingManager.address, async (bmSigner) => { + await cDaiVault.connect(bmSigner).transfer(addr2.address, bmBalanceAmt) + }) + await cDaiVault.connect(addr2).withdraw(bmBalanceAmt, addr2.address) + await expect(cDai.connect(addr2).redeem(bmBalanceAmt)).to.be.reverted + expect(await dai.balanceOf(addr2.address)).to.equal(bn(0)) + + // We can redeem iff we reduce to 30% + await expect(cDai.connect(addr2).redeem(bmBalanceAmt.mul(30).div(100))).to.not.be.reverted + expect(await dai.balanceOf(addr2.address)).to.be.gt(0) + }) + + it('Should handle no liquidity', async function () { + expect( + await facadeMonitor.backingReedemable( + rToken.address, + CollPluginType.COMPOUND_V2, + cDaiVault.address + ) + ).to.equal(fp('1')) + + // Borrow full liquidity + await cDai.connect(addr1).borrow(fullLiquidityAmt) + + expect( + await facadeMonitor.backingReedemable( + rToken.address, + CollPluginType.COMPOUND_V2, + cDaiVault.address + ) + ).to.be.closeTo(fp('0'), fp('0.01')) + + // Confirm we cannot redeem anything, not even 1% + expect(await dai.balanceOf(addr2.address)).to.equal(bn(0)) + const bmBalanceAmt = await cDaiVault.balanceOf(backingManager.address) + await whileImpersonating(backingManager.address, async (bmSigner) => { + await cDaiVault.connect(bmSigner).transfer(addr2.address, bmBalanceAmt) + }) + await cDaiVault.connect(addr2).withdraw(bmBalanceAmt, addr2.address) + expect(await cDai.balanceOf(addr2.address)).to.equal(bmBalanceAmt) + + await expect(cDai.connect(addr2).redeem((await cDai.balanceOf(addr2.address)).div(100))).to + .be.reverted + expect(await dai.balanceOf(addr2.address)).to.equal(bn(0)) + }) + }) + + describe('Compound V3', () => { + const issueAmount: BigNumber = bn('1000000e18') + let wcusdcV3: CusdcV3Wrapper + + beforeEach(async () => { + const CUsdcV3WrapperFactory = await hre.ethers.getContractFactory('CusdcV3Wrapper') + + wcusdcV3 = ( + await CUsdcV3WrapperFactory.deploy( + cusdcV3.address, + networkConfig[chainId].COMET_REWARDS || '', + networkConfig[chainId].tokens.COMP || '' + ) + ) + await wcusdcV3.deployed() + + /******** Deploy Compound V3 USDC collateral plugin **************************/ + const CollateralFactory = await ethers.getContractFactory('CTokenV3Collateral') + + const usdcOracleTimeout = '86400' // 24 hr + const usdcOracleError = baseL2Chains.includes(hre.network.name) ? fp('0.003') : fp('0.0025') // 0.3% (Base) or 0.25% + + const MockV3AggregatorFactory = await ethers.getContractFactory('MockV3Aggregator') + const chainlinkFeed = await MockV3AggregatorFactory.deploy(8, bn('1e8')) + + const collateral = await CollateralFactory.connect(owner).deploy( + { + priceTimeout: bn('604800'), + chainlinkFeed: chainlinkFeed.address, + oracleError: usdcOracleError.toString(), + erc20: wcusdcV3.address, + maxTradeVolume: fp('1e6').toString(), // $1m, + oracleTimeout: usdcOracleTimeout, // 24h hr, + targetName: hre.ethers.utils.formatBytes32String('USD'), + defaultThreshold: fp('0.01').add(usdcOracleError).toString(), + delayUntilDefault: bn('86400').toString(), // 24h + }, + fp('1e-6'), + bn('10000e6').toString() // $10k + ) + + // Register and update collateral + await collateral.deployed() + await (await collateral.refresh()).wait() + await pushOracleForward(chainlinkFeed.address) + await assetRegistry.connect(owner).register(collateral.address) + + // Wrap cUSDCV3 + await cusdcV3.connect(addr1).allow(wcusdcV3.address, true) + await wcusdcV3.connect(addr1).deposit(toBNDecimals(initialBal, 6)) + + // Get current liquidity + fullLiquidityAmt = await usdc.balanceOf(cusdcV3.address) + + // Setup basket + await pushOracleForward(chainlinkFeed.address) + await basketHandler.connect(owner).setPrimeBasket([wcusdcV3.address], [fp('1')]) + await basketHandler.connect(owner).refreshBasket() + await advanceTime(Number(config.warmupPeriod) + 1) + + // Provide approvals + await wcusdcV3.connect(addr1).approve(rToken.address, MAX_UINT256) + + // Advance time significantly - Recharge throttle + await advanceTime(100000) + await pushOracleForward(chainlinkFeed.address) + + // Issue rTokens + await rToken.connect(addr1).issue(issueAmount) + + // Provide liquidity to be able to borrow + const amountToDeposit = fp('500000') + await weth.connect(addr1).approve(cusdcV3.address, amountToDeposit) + await cusdcV3.connect(addr1).supply(weth.address, amountToDeposit.div(2)) + }) + + it('Should return 100% when full liquidity available', async function () { + // Check asset value + expect(await facadeTest.callStatic.totalAssetValue(rToken.address)).to.be.closeTo( + issueAmount, + fp('150') + ) + + // Compound V3 - All redeemable + expect( + await facadeMonitor.backingReedemable( + rToken.address, + CollPluginType.COMPOUND_V3, + wcusdcV3.address + ) + ).to.equal(fp('1')) + + // Confirm all can be redeemed + expect(await usdc.balanceOf(addr2.address)).to.equal(bn(0)) + const bmBalanceAmt = await wcusdcV3.balanceOf(backingManager.address) + await whileImpersonating(backingManager.address, async (bmSigner) => { + await wcusdcV3.connect(bmSigner).transfer(addr2.address, bmBalanceAmt) + }) + await wcusdcV3.connect(addr2).withdraw(MAX_UINT256) + + await expect(cusdcV3.connect(addr2).withdraw(usdc.address, MAX_UINT256)).to.not.be.reverted + expect(await usdc.balanceOf(addr2.address)).to.be.gt(bn(0)) + expect(await cusdcV3.balanceOf(addr2.address)).to.equal(bn(0)) + }) + + it('Should return backing redeemable percent correctly', async function () { + // AAVE V3 - All redeemable + expect( + await facadeMonitor.backingReedemable( + rToken.address, + CollPluginType.COMPOUND_V3, + wcusdcV3.address + ) + ).to.equal(fp('1')) + + // Leave only 80% of backing to be able to be redeemed + const borrowAmount = fullLiquidityAmt.sub(toBNDecimals(issueAmount, 6).mul(80).div(100)) + await cusdcV3.connect(addr1).withdraw(usdc.address, borrowAmount) + + expect( + await facadeMonitor.backingReedemable( + rToken.address, + CollPluginType.COMPOUND_V3, + wcusdcV3.address + ) + ).to.be.closeTo(fp('0.80'), fp('0.01')) + + // Borrow half of the remaining liquidity + const remainingLiquidity = fullLiquidityAmt.sub(borrowAmount) + await cusdcV3.connect(addr1).withdraw(usdc.address, remainingLiquidity.div(2)) + + // Only 40% available to be redeemed + expect( + await facadeMonitor.backingReedemable( + rToken.address, + CollPluginType.COMPOUND_V3, + wcusdcV3.address + ) + ).to.be.closeTo(fp('0.40'), fp('0.01')) + + // Confirm we cannot redeem full balance + expect(await usdc.balanceOf(addr2.address)).to.equal(bn(0)) + const bmBalanceAmt = await wcusdcV3.balanceOf(backingManager.address) + await whileImpersonating(backingManager.address, async (bmSigner) => { + await wcusdcV3.connect(bmSigner).transfer(addr2.address, bmBalanceAmt) + }) + await wcusdcV3.connect(addr2).withdraw(MAX_UINT256) + + await expect(cusdcV3.connect(addr2).withdraw(usdc.address, MAX_UINT256)).to.be.reverted + expect(await dai.balanceOf(addr2.address)).to.equal(bn(0)) + + // We can redeem if we reduce to 30% + await expect( + cusdcV3 + .connect(addr2) + .withdraw(usdc.address, (await cusdcV3.balanceOf(addr2.address)).mul(30).div(100)) + ).to.not.be.reverted + expect(await usdc.balanceOf(addr2.address)).to.be.gt(0) + }) + + it('Should handle no liquidity', async function () { + expect( + await facadeMonitor.backingReedemable( + rToken.address, + CollPluginType.COMPOUND_V3, + wcusdcV3.address + ) + ).to.equal(fp('1')) + + // Borrow full liquidity + await cusdcV3.connect(addr1).withdraw(usdc.address, fullLiquidityAmt) + + expect( + await facadeMonitor.backingReedemable( + rToken.address, + CollPluginType.COMPOUND_V3, + wcusdcV3.address + ) + ).to.be.closeTo(fp('0'), fp('0.01')) + + // Confirm we cannot redeem anything, not even 1% + expect(await usdc.balanceOf(addr2.address)).to.equal(bn(0)) + const bmBalanceAmt = await wcusdcV3.balanceOf(backingManager.address) + await whileImpersonating(backingManager.address, async (bmSigner) => { + await wcusdcV3.connect(bmSigner).transfer(addr2.address, bmBalanceAmt) + }) + await wcusdcV3.connect(addr2).withdraw(MAX_UINT256) + + await expect( + cusdcV3 + .connect(addr2) + .withdraw(usdc.address, (await cusdcV3.balanceOf(addr2.address)).div(100)) + ).to.be.reverted + expect(await usdc.balanceOf(addr2.address)).to.equal(bn(0)) + }) + }) + + describe('Stargate', () => { + const issueAmount: BigNumber = bn('1000000e18') + let wstgUsdc: StargateRewardableWrapper + + beforeEach(async () => { + const SthWrapperFactory = await hre.ethers.getContractFactory('StargateRewardableWrapper') + + wstgUsdc = await SthWrapperFactory.deploy( + 'Wrapped Stargate USDC', + 'wsgUSDC', + networkConfig[chainId].tokens.STG!, + networkConfig[chainId].STARGATE_STAKING_CONTRACT!, + networkConfig[chainId].tokens.sUSDC! + ) + await wstgUsdc.deployed() + + /******** Deploy Stargate USDC collateral plugin **************************/ + const usdcOracleTimeout = '86400' // 24 hr + const usdcOracleError = baseL2Chains.includes(hre.network.name) ? fp('0.003') : fp('0.0025') // 0.3% (Base) or 0.25% + + const MockV3AggregatorFactory = await ethers.getContractFactory('MockV3Aggregator') + const chainlinkFeed = await MockV3AggregatorFactory.deploy(8, bn('1e8')) + + const CollateralFactory = await hre.ethers.getContractFactory('StargatePoolFiatCollateral') + const collateral = await CollateralFactory.connect( + owner + ).deploy( + { + priceTimeout: bn('604800'), + chainlinkFeed: chainlinkFeed.address, + oracleError: usdcOracleError, + erc20: wstgUsdc.address, + maxTradeVolume: fp('1e6'), + oracleTimeout: usdcOracleTimeout, + targetName: ethers.utils.formatBytes32String('USD'), + defaultThreshold: fp('0.01').add(usdcOracleError), + delayUntilDefault: bn('86400'), + }, + fp('1e-6') + ) + + // Register and update collateral + await collateral.deployed() + await (await collateral.refresh()).wait() + await pushOracleForward(chainlinkFeed.address) + await assetRegistry.connect(owner).register(collateral.address) + + // Wrap sUsdc + await sUsdc.connect(addr1).approve(wstgUsdc.address, toBNDecimals(initialBal, 6)) + await wstgUsdc.connect(addr1).deposit(toBNDecimals(initialBal, 6), addr1.address) + + // Get current liquidity + fullLiquidityAmt = await sUsdc.totalLiquidity() + + // Setup basket + await pushOracleForward(chainlinkFeed.address) + await basketHandler.connect(owner).setPrimeBasket([wstgUsdc.address], [fp('1')]) + await basketHandler.connect(owner).refreshBasket() + await advanceTime(Number(config.warmupPeriod) + 1) + + // Provide approvals + await wstgUsdc.connect(addr1).approve(rToken.address, issueAmount) + + // Advance time significantly - Recharge throttle + await advanceTime(100000) + await pushOracleForward(chainlinkFeed.address) + + // Issue rTokens + await rToken.connect(addr1).issue(issueAmount) + }) + + it('Should return 100%, full liquidity available at all times', async function () { + // Check asset value + expect(await facadeTest.callStatic.totalAssetValue(rToken.address)).to.be.closeTo( + issueAmount, + fp('150') + ) + + // AAVE V3 - All redeemable + expect( + await facadeMonitor.backingReedemable( + rToken.address, + CollPluginType.STARGATE, + wstgUsdc.address + ) + ).to.equal(fp('1')) + }) + }) + + describe('Flux', () => { + const issueAmount: BigNumber = bn('1000000e18') + + beforeEach(async () => { + /******** Deploy Flux USDC collateral plugin **************************/ + const CollateralFactory = await ethers.getContractFactory('CTokenFiatCollateral') + + const usdcOracleTimeout = '86400' // 24 hr + const usdcOracleError = baseL2Chains.includes(hre.network.name) ? fp('0.003') : fp('0.0025') // 0.3% (Base) or 0.25% + + const MockV3AggregatorFactory = await ethers.getContractFactory('MockV3Aggregator') + const chainlinkFeed = await MockV3AggregatorFactory.deploy(8, bn('1e8')) + + const collateral = await CollateralFactory.connect(owner).deploy( + { + priceTimeout: bn('604800'), + chainlinkFeed: chainlinkFeed.address, + oracleError: usdcOracleError.toString(), + erc20: fUsdc.address, + maxTradeVolume: fp('1e6').toString(), // $1m, + oracleTimeout: usdcOracleTimeout, // 24h hr, + targetName: hre.ethers.utils.formatBytes32String('USD'), + defaultThreshold: fp('0.01').add(usdcOracleError).toString(), + delayUntilDefault: bn('86400').toString(), // 24h + }, + fp('1e-6') + ) + + // Register and update collateral + await collateral.deployed() + await (await collateral.refresh()).wait() + await pushOracleForward(chainlinkFeed.address) + await assetRegistry.connect(owner).register(collateral.address) + + // Get current liquidity + fullLiquidityAmt = await usdc.balanceOf(fUsdc.address) + + // Setup basket + await pushOracleForward(chainlinkFeed.address) + await basketHandler.connect(owner).setPrimeBasket([fUsdc.address], [fp('1')]) + await basketHandler.connect(owner).refreshBasket() + await advanceTime(Number(config.warmupPeriod) + 1) + + // Provide approvals + await fUsdc.connect(addr1).approve(rToken.address, toBNDecimals(issueAmount, 8).mul(100)) + + // Advance time significantly - Recharge throttle + await advanceTime(100000) + await pushOracleForward(chainlinkFeed.address) + + // Issue rTokens + await rToken.connect(addr1).issue(issueAmount) + }) + + it('Should return 100% when full liquidity available', async function () { + // Check asset value + expect(await facadeTest.callStatic.totalAssetValue(rToken.address)).to.be.closeTo( + issueAmount, + fp('150') + ) + + // FLUX - All redeemable + expect( + await facadeMonitor.backingReedemable(rToken.address, CollPluginType.FLUX, fUsdc.address) + ).to.equal(fp('1')) + + // Confirm all can be redeemed + expect(await usdc.balanceOf(addr2.address)).to.equal(bn(0)) + const bmBalanceAmt = await fUsdc.balanceOf(backingManager.address) + await whileImpersonating(backingManager.address, async (bmSigner) => { + await fUsdc.connect(bmSigner).transfer(addr2.address, bmBalanceAmt) + }) + await expect(fUsdc.connect(addr2).redeem(bmBalanceAmt)).to.not.be.reverted + expect(await usdc.balanceOf(addr2.address)).to.be.gt(bn(0)) + expect(await fUsdc.balanceOf(addr2.address)).to.equal(bn(0)) + }) + }) + + describe('MORPHO - AAVE V2', () => { + const issueAmount: BigNumber = bn('1000000e18') + let lendingPool: ILendingPool + let maUSDC: MorphoAaveV2TokenisedDeposit + let aaveV2DataProvider: Contract + + beforeEach(async () => { + /******** Deploy Morpho AAVE V2 USDC collateral plugin **************************/ + const MorphoTokenisedDepositFactory = await ethers.getContractFactory( + 'MorphoAaveV2TokenisedDeposit' + ) + maUSDC = await MorphoTokenisedDepositFactory.deploy({ + morphoController: networkConfig[chainId].MORPHO_AAVE_CONTROLLER!, + morphoLens: networkConfig[chainId].MORPHO_AAVE_LENS!, + rewardsDistributor: networkConfig[chainId].MORPHO_REWARDS_DISTRIBUTOR!, + underlyingERC20: networkConfig[chainId].tokens.USDC!, + poolToken: networkConfig[chainId].tokens.aUSDC!, + rewardToken: networkConfig[chainId].tokens.MORPHO!, + }) + + const CollateralFactory = await hre.ethers.getContractFactory('MorphoFiatCollateral') + + const usdcOracleTimeout = '86400' // 24 hr + const usdcOracleError = baseL2Chains.includes(hre.network.name) ? fp('0.003') : fp('0.0025') // 0.3% (Base) or 0.25% + const baseStableConfig = { + priceTimeout: bn('604800').toString(), + oracleError: usdcOracleError.toString(), + maxTradeVolume: fp('1e6').toString(), // $1m, + oracleTimeout: usdcOracleTimeout, // 24h + targetName: ethers.utils.formatBytes32String('USD'), + defaultThreshold: usdcOracleError.add(fp('0.01')), // 1.25% + delayUntilDefault: bn('86400').toString(), // 24h + } + const MockV3AggregatorFactory = await ethers.getContractFactory('MockV3Aggregator') + const chainlinkFeed = await MockV3AggregatorFactory.deploy(8, bn('1e8')) + + const collateral = await CollateralFactory.connect(owner).deploy( + { + ...baseStableConfig, + chainlinkFeed: chainlinkFeed.address, + erc20: maUSDC.address, + }, + fp('1e-6') + ) + + // Register and update collateral + await collateral.deployed() + await (await collateral.refresh()).wait() + await pushOracleForward(chainlinkFeed.address) + await assetRegistry.connect(owner).register(collateral.address) + + const aaveV2DataProviderAbi = [ + 'function getReserveData(address asset) external view returns (uint256 availableLiquidity,uint256 totalStableDebt,uint256 totalVariableDebt,uint256 liquidityRate,uint256 variableBorrowRate,uint256 stableBorrowRate,uint256 averageStableBorrowRate,uint256 liquidityIndex,uint256 variableBorrowIndex,uint40 lastUpdateTimestamp)', + ] + aaveV2DataProvider = await ethers.getContractAt( + aaveV2DataProviderAbi, + networkConfig[chainId].AAVE_DATA_PROVIDER || '' + ) + + await facadeMonitor.backingReedemable( + rToken.address, + CollPluginType.MORPHO_AAVE_V2, + maUSDC.address + ) + + // Wrap maUSDC + await usdc.connect(addr1).approve(maUSDC.address, 0) + await usdc.connect(addr1).approve(maUSDC.address, MAX_UINT256) + await maUSDC.connect(addr1).mint(toBNDecimals(initialBal, 15), addr1.address) + + // Setup basket + await pushOracleForward(chainlinkFeed.address) + await basketHandler.connect(owner).setPrimeBasket([maUSDC.address], [fp('1')]) + await basketHandler.connect(owner).refreshBasket() + await advanceTime(Number(config.warmupPeriod) + 1) + + // Provide approvals + await maUSDC.connect(addr1).approve(rToken.address, toBNDecimals(issueAmount, 15)) + + // Advance time significantly - Recharge throttle + await advanceTime(100000) + await pushOracleForward(chainlinkFeed.address) + + // Issue rTokens + await rToken.connect(addr1).issue(issueAmount) + + lendingPool = ( + await ethers.getContractAt('ILendingPool', networkConfig[chainId].AAVE_LENDING_POOL || '') + ) + + // Provide liquidity in AAVE V2 to be able to borrow + const amountToDeposit = fp('500000') + await weth.connect(addr1).approve(lendingPool.address, amountToDeposit) + await lendingPool.connect(addr1).deposit(weth.address, amountToDeposit, addr1.address, 0) + }) + + it('Should return 100% when full liquidity available', async function () { + // Check asset value + expect(await facadeTest.callStatic.totalAssetValue(rToken.address)).to.be.closeTo( + issueAmount, + fp('150') + ) + + // MORPHO AAVE V2 - All redeemable + expect( + await facadeMonitor.backingReedemable( + rToken.address, + CollPluginType.MORPHO_AAVE_V2, + maUSDC.address + ) + ).to.equal(fp('1')) + + // Confirm all can be redeemed + expect(await usdc.balanceOf(addr2.address)).to.equal(bn(0)) + const bmBalanceAmt = await maUSDC.balanceOf(backingManager.address) + await whileImpersonating(backingManager.address, async (bmSigner) => { + await maUSDC.connect(bmSigner).transfer(addr2.address, bmBalanceAmt) + }) + const maxWithdraw = await maUSDC.maxWithdraw(addr2.address) + await expect(maUSDC.connect(addr2).withdraw(maxWithdraw, addr2.address, addr2.address)).to + .not.be.reverted + expect(await usdc.balanceOf(addr2.address)).to.be.gt(bn(0)) + }) + + it('Should return backing redeemable percent correctly', async function () { + // MORPHO AAVE V2 - All redeemable + expect( + await facadeMonitor.backingReedemable( + rToken.address, + CollPluginType.MORPHO_AAVE_V2, + maUSDC.address + ) + ).to.equal(fp('1')) + + // Get current liquidity from Aave V2 (Morpho relies on this) + ;[fullLiquidityAmt, , , , , , , , ,] = await aaveV2DataProvider + .connect(addr1) + .getReserveData(usdc.address) + + // Leave only 80% of backing available to be redeemed + const borrowAmount = fullLiquidityAmt.sub(toBNDecimals(issueAmount, 6).mul(80).div(100)) + await lendingPool.connect(addr1).borrow(usdc.address, borrowAmount, 2, 0, addr1.address) + + expect( + await facadeMonitor.backingReedemable( + rToken.address, + CollPluginType.MORPHO_AAVE_V2, + maUSDC.address + ) + ).to.be.closeTo(fp('0.80'), fp('0.01')) + + // Borrow half of the remaining liquidity + const remainingLiquidity = fullLiquidityAmt.sub(borrowAmount) + await lendingPool + .connect(addr1) + .borrow(usdc.address, remainingLiquidity.div(2), 2, 0, addr1.address) + + // Now only 40% is available to be redeemed + expect( + await facadeMonitor.backingReedemable( + rToken.address, + CollPluginType.MORPHO_AAVE_V2, + maUSDC.address + ) + ).to.be.closeTo(fp('0.40'), fp('0.01')) + + // Confirm we cannot redeem full balance + expect(await usdc.balanceOf(addr2.address)).to.equal(bn(0)) + const bmBalanceAmt = await maUSDC.balanceOf(backingManager.address) + await whileImpersonating(backingManager.address, async (bmSigner) => { + await maUSDC.connect(bmSigner).transfer(addr2.address, bmBalanceAmt) + }) + const maxWithdraw = await maUSDC.maxWithdraw(addr2.address) + await expect(maUSDC.connect(addr2).withdraw(maxWithdraw, addr2.address, addr2.address)).to + .be.reverted + expect(await usdc.balanceOf(addr2.address)).to.equal(bn(0)) + + // But we can redeem if we reduce the amount to 30% + await expect( + maUSDC.connect(addr2).withdraw(maxWithdraw.mul(30).div(100), addr2.address, addr2.address) + ).to.not.be.reverted + expect(await usdc.balanceOf(addr2.address)).to.be.gt(0) + }) + + it('Should handle no liquidity', async function () { + expect( + await facadeMonitor.backingReedemable( + rToken.address, + CollPluginType.MORPHO_AAVE_V2, + maUSDC.address + ) + ).to.equal(fp('1')) + + // Get current liquidity from Aave V2 (Morpho relies on this) + ;[fullLiquidityAmt, , , , , , , , ,] = await aaveV2DataProvider + .connect(addr1) + .getReserveData(usdc.address) + + // Borrow full liquidity + await lendingPool.connect(addr1).borrow(usdc.address, fullLiquidityAmt, 2, 0, addr1.address) + + expect( + await facadeMonitor.backingReedemable( + rToken.address, + CollPluginType.MORPHO_AAVE_V2, + maUSDC.address + ) + ).to.be.closeTo(fp('0'), fp('0.01')) + + // Confirm we cannot redeem anything, not even 1% + expect(await usdc.balanceOf(addr2.address)).to.equal(bn(0)) + const bmBalanceAmt = await maUSDC.balanceOf(backingManager.address) + await whileImpersonating(backingManager.address, async (bmSigner) => { + await maUSDC.connect(bmSigner).transfer(addr2.address, bmBalanceAmt) + }) + const maxWithdraw = await maUSDC.maxWithdraw(addr2.address) + await expect( + maUSDC.connect(addr2).withdraw(maxWithdraw.div(100), addr2.address, addr2.address) + ).to.be.reverted + expect(await usdc.balanceOf(addr2.address)).to.equal(bn(0)) + }) + }) + }) +}) diff --git a/test/utils/trades.ts b/test/utils/trades.ts index b952deabf..99e0f4c22 100644 --- a/test/utils/trades.ts +++ b/test/utils/trades.ts @@ -1,9 +1,11 @@ +import { getStorageAt, setStorageAt } from '@nomicfoundation/hardhat-network-helpers' import { Decimal } from 'decimal.js' import { BigNumber } from 'ethers' import { ethers } from 'hardhat' import { expect } from 'chai' -import { TestITrading, GnosisTrade } from '../../typechain' +import { TestITrading, GnosisTrade, TestIBroker } from '../../typechain' import { bn, fp, divCeil, divRound } from '../../common/numbers' +import { IMPLEMENTATION, Implementation } from '../fixtures' export const expectTrade = async (trader: TestITrading, auctionInfo: Partial) => { if (!auctionInfo.sell) throw new Error('Must provide sell token to find trade') @@ -118,3 +120,23 @@ export const dutchBuyAmount = async ( } else price = worstPrice return divCeil(outAmount.mul(price), fp('1')) } + +export const disableBatchTrade = async (broker: TestIBroker) => { + if (IMPLEMENTATION == Implementation.P1) { + const slot = await getStorageAt(broker.address, 205) + await setStorageAt(broker.address, 205, slot.replace(slot.slice(2, 14), '1'.padStart(12, '0'))) + } else { + const slot = await getStorageAt(broker.address, 56) + await setStorageAt(broker.address, 56, slot.replace(slot.slice(2, 42), '1'.padStart(40, '0'))) + } + expect(await broker.batchTradeDisabled()).to.equal(true) +} + +export const disableDutchTrade = async (broker: TestIBroker, erc20: string) => { + const mappingSlot = IMPLEMENTATION == Implementation.P1 ? bn('208') : bn('57') + const p = mappingSlot.toHexString().slice(2).padStart(64, '0') + const key = erc20.slice(2).padStart(64, '0') + const slot = ethers.utils.keccak256('0x' + key + p) + await setStorageAt(broker.address, slot, '0x' + '1'.padStart(64, '0')) + expect(await broker.dutchTradeDisabled(erc20)).to.equal(true) +}