From 735cf124b0bc93023271cf4765cdaf7ce5bd4a5e Mon Sep 17 00:00:00 2001 From: miguelmtz <36620902+miguelmtzinf@users.noreply.github.com> Date: Mon, 12 Jun 2023 10:35:27 +0200 Subject: [PATCH] feat: Add GhoSteward (#346) * fix: Add getter to GhoInterestRateStrategy * fix: Fix natspec docs of ZeroInterestRateStrategy * fix: Make constructor of GhoFlashMinter use internal setters * fix: Optimized GhoFLashMinter.maxFlashloan * fix: Remove unneccesary require in GhoToken.mint * fix: Optimize GhoDebtToken discount hook * test: Update tests * fix: modify Certora patch harness due to GhoToken change * fix: modify Certora GhoToken spec for non-zero mint * fix: Fix tests * fix: Fix natspec docs * fix: fix: Tweaks on GhoToken roles * fix: Fix tests * feat: Add GhoSteward code * fix: Rename GhoManager to GhoSteward * feat: Revamp GhoSteward contract * fix: Move the steward contract to misc * fix: Rename ghoManager to ghoSteward in tests * docs: Add missing natspec docs for constructor * docs: Clarify borrow rate param * test: Fix emit event check in test case * fix: Remove unneeded console log * fix: Fix hardhat tests * fix: Fix modifier of constant variable * fix: Fix time bound validation for steward expiration * feat: Make Steward ownable * fix: Fix modifier of mock test contract * fix: Fix tests * feat: Add cache registry of Gho IRs to facilitate reuse * ci: Pin version of Certora CVL prover --------- Co-authored-by: cedephrase <130931230+cedephrase@users.noreply.github.com> --- .github/workflows/certora.yml | 2 +- deploy/10_deploy_ghomanager.ts | 16 +- helpers/contract-getters.ts | 6 +- .../facilitators/aave/misc/GhoManager.sol | 43 -- src/contracts/misc/GhoSteward.sol | 193 ++++++++ src/contracts/misc/interfaces/IGhoSteward.sol | 99 +++++ src/test/TestGhoAToken.t.sol | 2 +- src/test/TestGhoBase.t.sol | 15 +- src/test/TestGhoDiscountRateStrategy.t.sol | 2 - src/test/TestGhoManager.t.sol | 48 -- src/test/TestGhoSteward.t.sol | 415 ++++++++++++++++++ src/test/helpers/Constants.sol | 6 + src/test/helpers/Events.sol | 3 + src/test/mocks/MockedPool.sol | 2 +- src/test/mocks/MockedProvider.sol | 20 +- tasks/main/gho-testnet-setup.ts | 4 +- tasks/testnet-setup/07_add-gho-manager.ts | 22 - tasks/testnet-setup/07_add-gho-steward.ts | 36 ++ test/gho-manager.test.ts | 95 ---- test/gho-steward.test.ts | 218 +++++++++ test/helpers/make-suite.ts | 11 +- 21 files changed, 1025 insertions(+), 233 deletions(-) delete mode 100644 src/contracts/facilitators/aave/misc/GhoManager.sol create mode 100644 src/contracts/misc/GhoSteward.sol create mode 100644 src/contracts/misc/interfaces/IGhoSteward.sol delete mode 100644 src/test/TestGhoManager.t.sol create mode 100644 src/test/TestGhoSteward.t.sol delete mode 100644 tasks/testnet-setup/07_add-gho-manager.ts create mode 100644 tasks/testnet-setup/07_add-gho-steward.ts delete mode 100644 test/gho-manager.test.ts create mode 100644 test/gho-steward.test.ts diff --git a/.github/workflows/certora.yml b/.github/workflows/certora.yml index ab580f07..1eb6928b 100644 --- a/.github/workflows/certora.yml +++ b/.github/workflows/certora.yml @@ -33,7 +33,7 @@ jobs: with: { java-version: '11', java-package: jre } - name: Install certora cli - run: pip install certora-cli + run: pip install certora-cli==3.6.8.post3 - name: Install solc run: | diff --git a/deploy/10_deploy_ghomanager.ts b/deploy/10_deploy_ghomanager.ts index 819b575d..b176f030 100644 --- a/deploy/10_deploy_ghomanager.ts +++ b/deploy/10_deploy_ghomanager.ts @@ -1,5 +1,8 @@ import { HardhatRuntimeEnvironment } from 'hardhat/types'; import { DeployFunction } from 'hardhat-deploy/types'; +import { getPoolAddressesProvider } from '@aave/deploy-v3'; +import { getGhoToken } from '../helpers/contract-getters'; + const func: DeployFunction = async function ({ getNamedAccounts, deployments, @@ -7,17 +10,20 @@ const func: DeployFunction = async function ({ const { deploy } = deployments; const { deployer } = await getNamedAccounts(); - const ghoManager = await deploy('GhoManager', { + const addressesProvider = await getPoolAddressesProvider(); + const ghoToken = await getGhoToken(); + + const ghoSteward = await deploy('GhoSteward', { from: deployer, - args: [], + args: [addressesProvider.address, ghoToken.address, deployer, deployer], log: true, }); - console.log(`GHO Manager: ${ghoManager.address}`); + console.log(`GHO Steward: ${ghoSteward.address}`); return true; }; -func.id = 'GhoManager'; -func.tags = ['GhoManager', 'full_gho_deploy']; +func.id = 'GhoSteward'; +func.tags = ['GhoSteward', 'full_gho_deploy']; export default func; diff --git a/helpers/contract-getters.ts b/helpers/contract-getters.ts index ff930a99..648d14ff 100644 --- a/helpers/contract-getters.ts +++ b/helpers/contract-getters.ts @@ -23,7 +23,7 @@ import { VariableDebtToken, StakedAaveV3, GhoFlashMinter, - GhoManager, + GhoSteward, GhoStableDebtToken, } from '../types'; @@ -78,8 +78,8 @@ export const getGhoStableDebtToken = async ( address || (await hre.deployments.get('GhoStableDebtToken')).address ); -export const getGhoManager = async (address?: tEthereumAddress): Promise => - getContract('GhoManager', address || (await hre.deployments.get('GhoManager')).address); +export const getGhoSteward = async (address?: tEthereumAddress): Promise => + getContract('GhoSteward', address || (await hre.deployments.get('GhoSteward')).address); export const getBaseImmutableAdminUpgradeabilityProxy = async ( address: tEthereumAddress diff --git a/src/contracts/facilitators/aave/misc/GhoManager.sol b/src/contracts/facilitators/aave/misc/GhoManager.sol deleted file mode 100644 index 05369d4a..00000000 --- a/src/contracts/facilitators/aave/misc/GhoManager.sol +++ /dev/null @@ -1,43 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.10; - -import {Ownable} from '@openzeppelin/contracts/access/Ownable.sol'; -import {IPoolConfigurator} from '@aave/core-v3/contracts/interfaces/IPoolConfigurator.sol'; -import {IGhoVariableDebtToken} from 'src/contracts/facilitators/aave/tokens/interfaces/IGhoVariableDebtToken.sol'; - -/** - * @title GhoManager - * @author Aave - * @notice Helper contract for managing key risk parameters of the GHO reserve within the Aave Facilitator - * @dev This contract is intended to be granted as PoolAdmin - */ -contract GhoManager is Ownable { - /** - * @notice Updates the Discount Rate Strategy - * @param ghoVariableDebtToken The address of GhoVariableDebtToken contract - * @param newDiscountRateStrategy The address of DiscountRateStrategy contract - */ - function updateDiscountRateStrategy( - address ghoVariableDebtToken, - address newDiscountRateStrategy - ) external onlyOwner { - IGhoVariableDebtToken(ghoVariableDebtToken).updateDiscountRateStrategy(newDiscountRateStrategy); - } - - /** - * @notice Updates the ReserveInterestRateStrategy - * @param poolConfigurator The address of PoolConfigurator contract - * @param asset The address of the GHO deployed contract - * @param newRateStrategyAddress The address of new RateStrategyAddress contract - */ - function setReserveInterestRateStrategyAddress( - address poolConfigurator, - address asset, - address newRateStrategyAddress - ) external onlyOwner { - IPoolConfigurator(poolConfigurator).setReserveInterestRateStrategyAddress( - asset, - newRateStrategyAddress - ); - } -} diff --git a/src/contracts/misc/GhoSteward.sol b/src/contracts/misc/GhoSteward.sol new file mode 100644 index 00000000..539741de --- /dev/null +++ b/src/contracts/misc/GhoSteward.sol @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.10; + +import {Ownable} from '@openzeppelin/contracts/access/Ownable.sol'; +import {IPoolAddressesProvider} from '@aave/core-v3/contracts/interfaces/IPoolAddressesProvider.sol'; +import {IPoolConfigurator} from '@aave/core-v3/contracts/interfaces/IPoolConfigurator.sol'; +import {IPool} from '@aave/core-v3/contracts/interfaces/IPool.sol'; +import {DataTypes} from '@aave/core-v3/contracts/protocol/libraries/types/DataTypes.sol'; +import {PercentageMath} from '@aave/core-v3/contracts/protocol/libraries/math/PercentageMath.sol'; +import {GhoInterestRateStrategy} from '../facilitators/aave/interestStrategy/GhoInterestRateStrategy.sol'; +import {IGhoToken} from '../gho/interfaces/IGhoToken.sol'; +import {IGhoSteward} from './interfaces/IGhoSteward.sol'; + +/** + * @title GhoSteward + * @author Aave + * @notice Helper contract for managing risk parameters of the GHO reserve within the Aave Facilitator + * @dev This contract must be granted `PoolAdmin` in the Aave V3 Ethereum Pool and `BucketManager` in GHO Token + * @dev Only the Risk Council is able to action contract's functions. + * @dev Only the Aave DAO is able to extend the steward's lifespan. + */ +contract GhoSteward is Ownable, IGhoSteward { + using PercentageMath for uint256; + + /// @inheritdoc IGhoSteward + uint256 public constant MINIMUM_DELAY = 5 days; + + /// @inheritdoc IGhoSteward + uint256 public constant BORROW_RATE_CHANGE_MAX = 0.0050e4; + + /// @inheritdoc IGhoSteward + uint40 public constant STEWARD_LIFESPAN = 60 days; + + /// @inheritdoc IGhoSteward + address public immutable POOL_ADDRESSES_PROVIDER; + + /// @inheritdoc IGhoSteward + address public immutable GHO_TOKEN; + + /// @inheritdoc IGhoSteward + address public immutable RISK_COUNCIL; + + Debounce internal _timelocks; + uint40 internal _stewardExpiration; + mapping(uint256 => address) internal strategiesByRate; + address[] internal strategies; + + /** + * @dev Only Risk Council can call functions marked by this modifier. + */ + modifier onlyRiskCouncil() { + require(RISK_COUNCIL == msg.sender, 'INVALID_CALLER'); + _; + } + + /** + * @dev Constructor + * @param addressesProvider The address of the PoolAddressesProvider of Aave V3 Ethereum Pool + * @param ghoToken The address of the GhoToken + * @param riskCouncil The address of the RiskCouncil + * @param shortExecutor The address of the Aave Short Executor + */ + constructor( + address addressesProvider, + address ghoToken, + address riskCouncil, + address shortExecutor + ) { + require(addressesProvider != address(0), 'INVALID_ADDRESSES_PROVIDER'); + require(ghoToken != address(0), 'INVALID_GHO_TOKEN'); + require(riskCouncil != address(0), 'INVALID_RISK_COUNCIL'); + require(shortExecutor != address(0), 'INVALID_SHORT_EXECUTOR'); + POOL_ADDRESSES_PROVIDER = addressesProvider; + GHO_TOKEN = ghoToken; + RISK_COUNCIL = riskCouncil; + _stewardExpiration = uint40(block.timestamp + STEWARD_LIFESPAN); + + _transferOwnership(shortExecutor); + } + + /// @inheritdoc IGhoSteward + function updateBorrowRate(uint256 newBorrowRate) external onlyRiskCouncil { + require(block.timestamp < _stewardExpiration, 'STEWARD_EXPIRED'); + require( + block.timestamp - _timelocks.borrowRateLastUpdated > MINIMUM_DELAY, + 'DEBOUNCE_NOT_RESPECTED' + ); + + DataTypes.ReserveData memory ghoReserveData = IPool( + IPoolAddressesProvider(POOL_ADDRESSES_PROVIDER).getPool() + ).getReserveData(GHO_TOKEN); + require( + ghoReserveData.interestRateStrategyAddress != address(0), + 'GHO_INTEREST_RATE_STRATEGY_NOT_FOUND' + ); + + uint256 oldBorrowRate = GhoInterestRateStrategy(ghoReserveData.interestRateStrategyAddress) + .getBaseVariableBorrowRate(); + require(_borrowRateChangeAllowed(oldBorrowRate, newBorrowRate), 'INVALID_BORROW_RATE_UPDATE'); + + _timelocks.borrowRateLastUpdated = uint40(block.timestamp); + + address cachedStrategyAddress = strategiesByRate[newBorrowRate]; + // Deploy a new one if does not exist + if (cachedStrategyAddress == address(0)) { + GhoInterestRateStrategy newRateStrategy = new GhoInterestRateStrategy( + POOL_ADDRESSES_PROVIDER, + newBorrowRate + ); + cachedStrategyAddress = address(newRateStrategy); + + strategiesByRate[newBorrowRate] = address(newRateStrategy); + strategies.push(address(newRateStrategy)); + } + + IPoolConfigurator(IPoolAddressesProvider(POOL_ADDRESSES_PROVIDER).getPoolConfigurator()) + .setReserveInterestRateStrategyAddress(GHO_TOKEN, cachedStrategyAddress); + } + + /// @inheritdoc IGhoSteward + function updateBucketCapacity(uint128 newBucketCapacity) external onlyRiskCouncil { + require(block.timestamp < _stewardExpiration, 'STEWARD_EXPIRED'); + require( + block.timestamp - _timelocks.bucketCapacityLastUpdated > MINIMUM_DELAY, + 'DEBOUNCE_NOT_RESPECTED' + ); + + DataTypes.ReserveData memory ghoReserveData = IPool( + IPoolAddressesProvider(POOL_ADDRESSES_PROVIDER).getPool() + ).getReserveData(GHO_TOKEN); + require(ghoReserveData.aTokenAddress != address(0), 'GHO_ATOKEN_NOT_FOUND'); + + (uint256 oldBucketCapacity, ) = IGhoToken(GHO_TOKEN).getFacilitatorBucket( + ghoReserveData.aTokenAddress + ); + require( + _bucketCapacityIncreaseAllowed(oldBucketCapacity, newBucketCapacity), + 'INVALID_BUCKET_CAPACITY_UPDATE' + ); + + _timelocks.bucketCapacityLastUpdated = uint40(block.timestamp); + + IGhoToken(GHO_TOKEN).setFacilitatorBucketCapacity( + ghoReserveData.aTokenAddress, + newBucketCapacity + ); + } + + /// @inheritdoc IGhoSteward + function extendStewardExpiration() external onlyOwner { + uint40 oldStewardExpiration = _stewardExpiration; + _stewardExpiration += uint40(STEWARD_LIFESPAN); + emit StewardExpirationUpdated(oldStewardExpiration, _stewardExpiration); + } + + /// @inheritdoc IGhoSteward + function getTimelock() external view returns (Debounce memory) { + return _timelocks; + } + + /// @inheritdoc IGhoSteward + function getStewardExpiration() external view returns (uint40) { + return _stewardExpiration; + } + + /// @inheritdoc IGhoSteward + function getAllStrategies() external view returns (address[] memory) { + return strategies; + } + + /** + * @notice Ensures the borrow rate change is within the allowed range. + * @param from current borrow rate (in ray) + * @param to new borrow rate (in ray) + * @return bool true, if difference is within the max 0.5% change window + */ + function _borrowRateChangeAllowed(uint256 from, uint256 to) internal pure returns (bool) { + return + from < to + ? to - from <= from.percentMul(BORROW_RATE_CHANGE_MAX) + : from - to <= from.percentMul(BORROW_RATE_CHANGE_MAX); + } + + /** + * @notice Ensures the bucket capacity increase is within the allowed range. + * @param from current bucket capacity + * @param to new bucket capacity + * @return bool true, if difference is within the max 100% increase window + */ + function _bucketCapacityIncreaseAllowed(uint256 from, uint256 to) internal pure returns (bool) { + return to >= from && to - from <= from; + } +} diff --git a/src/contracts/misc/interfaces/IGhoSteward.sol b/src/contracts/misc/interfaces/IGhoSteward.sol new file mode 100644 index 00000000..18bce746 --- /dev/null +++ b/src/contracts/misc/interfaces/IGhoSteward.sol @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.10; + +/** + * @title IGhoSteward + * @author Aave + * @notice Defines the basic interface of the GhoSteward + */ +interface IGhoSteward { + struct Debounce { + uint40 borrowRateLastUpdated; + uint40 bucketCapacityLastUpdated; + } + + /** + * @dev Emitted when the steward expiration is updated + * @param oldStewardExpiration The old expiration unix time of the steward (in seconds) + * @param oldStewardExpiration The new expiration unix time of the steward (in seconds) + */ + event StewardExpirationUpdated(uint40 oldStewardExpiration, uint40 newStewardExpiration); + + /** + * @notice Returns the minimum delay that must be respected between updating a specific parameter twice + * @return The minimum delay between parameter updates (in seconds) + */ + function MINIMUM_DELAY() external view returns (uint256); + + /** + * @notice Returns the maximum percentage change for borrow rate updates. The new borrow rate can only differ up to this percentage. + * @return The maximum percentage change for borrow rate updates (e.g. 0.0050e4 is 50, which results in 0.5%) + */ + function BORROW_RATE_CHANGE_MAX() external view returns (uint256); + + /** + * @notice Returns the lifespan of the steward + * @return The lifespan of the steward (in seconds) + */ + function STEWARD_LIFESPAN() external view returns (uint40); + + /** + * @notice Returns the address of the Pool Addresses Provider of the Aave V3 Ethereum Facilitator + * @return The address of the PoolAddressesProvider of Aave V3 Ethereum Facilitator + */ + function POOL_ADDRESSES_PROVIDER() external view returns (address); + + /** + * @notice Returns the address of the Gho Token + * @return The address of the GhoToken + */ + function GHO_TOKEN() external view returns (address); + + /** + * @notice Returns the address of the Risk Council + * @return The address of the RiskCouncil + */ + function RISK_COUNCIL() external view returns (address); + + /** + * @notice Updates the borrow rate of GHO, only if: + * - respects the debounce duration (5 day pause between updates must be respected) + * - the update changes up to 0.50% upwards or downwards + * @dev Only callable by Risk Council + * @param newBorrowRate The new variable borrow rate (expressed in ray) (e.g. 0.0150e27 results in 1.50%) + */ + function updateBorrowRate(uint256 newBorrowRate) external; + + /** + * @notice Updates the Bucket Capacity of the Aave V3 Ethereum Pool Facilitator, only if: + * - respects the debounce duration (5 day pause between updates must be respected) + * - the update changes up to 100% upwards + * @dev Only callable by Risk Council + * @param newBucketCapacity The new bucket capacity of the facilitator + */ + function updateBucketCapacity(uint128 newBucketCapacity) external; + + /** + * @notice Extends the steward expiration date by `STEWARD_LIFESPAN` + * @dev Only callable by Aave Short Executor + */ + function extendStewardExpiration() external; + + /** + * @notice Returns the timelock values for all parameters updates + * @return The Debounce struct with parameters' timelock + */ + function getTimelock() external view returns (Debounce memory); + + /** + * @notice Returns the expiration time of the steward + * @return The expiration unix time of the steward (in seconds) + */ + function getStewardExpiration() external view returns (uint40); + + /** + * @notice Returns the list of Interest Rate Strategies for GHO + * @return An array of GhoInterestRateStrategy addresses + */ + function getAllStrategies() external view returns (address[] memory); +} diff --git a/src/test/TestGhoAToken.t.sol b/src/test/TestGhoAToken.t.sol index cb5361a9..8326c34a 100644 --- a/src/test/TestGhoAToken.t.sol +++ b/src/test/TestGhoAToken.t.sol @@ -183,7 +183,7 @@ contract TestGhoAToken is TestGhoBase { assertEq( GHO_ATOKEN.RESERVE_TREASURY_ADDRESS(), TREASURY, - 'AToken treasury address should match the initalized address' + 'AToken treasury address should match the initialized address' ); } diff --git a/src/test/TestGhoBase.t.sol b/src/test/TestGhoBase.t.sol index 4a9bb743..e9c9c27b 100644 --- a/src/test/TestGhoBase.t.sol +++ b/src/test/TestGhoBase.t.sol @@ -45,7 +45,8 @@ import {GhoAToken} from '../contracts/facilitators/aave/tokens/GhoAToken.sol'; import {GhoDiscountRateStrategy} from '../contracts/facilitators/aave/interestStrategy/GhoDiscountRateStrategy.sol'; import {GhoFlashMinter} from '../contracts/facilitators/flashMinter/GhoFlashMinter.sol'; import {GhoInterestRateStrategy} from '../contracts/facilitators/aave/interestStrategy/GhoInterestRateStrategy.sol'; -import {GhoManager} from '../contracts/facilitators/aave/misc/GhoManager.sol'; +import {GhoSteward} from '../contracts/misc/GhoSteward.sol'; +import {IGhoSteward} from '../contracts/misc/interfaces/IGhoSteward.sol'; import {GhoOracle} from '../contracts/facilitators/aave/oracle/GhoOracle.sol'; import {GhoStableDebtToken} from '../contracts/facilitators/aave/tokens/GhoStableDebtToken.sol'; import {GhoToken} from '../contracts/gho/GhoToken.sol'; @@ -85,7 +86,7 @@ contract TestGhoBase is Test, Constants, Events { GhoDiscountRateStrategy GHO_DISCOUNT_STRATEGY; MockFlashBorrower FLASH_BORROWER; GhoOracle GHO_ORACLE; - GhoManager GHO_MANAGER; + GhoSteward GHO_STEWARD; constructor() { setupGho(); @@ -102,8 +103,9 @@ contract TestGhoBase is Test, Constants, Events { PROVIDER = new MockedProvider(address(ACL_MANAGER)); POOL = new MockedPool(IPoolAddressesProvider(address(PROVIDER))); CONFIGURATOR = new MockedConfigurator(IPool(POOL)); + PROVIDER.setPool(address(POOL)); + PROVIDER.setConfigurator(address(CONFIGURATOR)); GHO_ORACLE = new GhoOracle(); - GHO_MANAGER = new GhoManager(); GHO_TOKEN = new GhoToken(address(this)); GHO_TOKEN.grantRole(FACILITATOR_MANAGER_ROLE, address(this)); GHO_TOKEN.grantRole(BUCKET_MANAGER_ROLE, address(this)); @@ -195,6 +197,13 @@ contract TestGhoBase is Test, Constants, Events { ); IGhoToken(ghoToken).addFacilitator(FAUCET, 'Faucet Facilitator', DEFAULT_CAPACITY); + GHO_STEWARD = new GhoSteward( + address(PROVIDER), + address(GHO_TOKEN), + RISK_COUNCIL, + SHORT_EXECUTOR + ); + GHO_TOKEN.grantRole(BUCKET_MANAGER_ROLE, address(GHO_STEWARD)); } function ghoFaucet(address to, uint256 amount) public { diff --git a/src/test/TestGhoDiscountRateStrategy.t.sol b/src/test/TestGhoDiscountRateStrategy.t.sol index bf847fec..722aaec6 100644 --- a/src/test/TestGhoDiscountRateStrategy.t.sol +++ b/src/test/TestGhoDiscountRateStrategy.t.sol @@ -49,7 +49,6 @@ contract TestGhoDiscountRateStrategy is TestGhoBase { uint256 minimumDiscountTokenBalance = (GHO_DISCOUNT_STRATEGY.MIN_DISCOUNT_TOKEN_BALANCE() * ratio) / GHO_DISCOUNT_STRATEGY.GHO_DISCOUNTED_PER_DISCOUNT_TOKEN(); - console2.log(minimumDiscountTokenBalance); uint256 result = GHO_DISCOUNT_STRATEGY.calculateDiscountRate( GHO_DISCOUNT_STRATEGY.MIN_DEBT_TOKEN_BALANCE(), @@ -76,7 +75,6 @@ contract TestGhoDiscountRateStrategy is TestGhoBase { uint256 minimumDiscountTokenBalance = (GHO_DISCOUNT_STRATEGY.MIN_DISCOUNT_TOKEN_BALANCE() * ratio) / GHO_DISCOUNT_STRATEGY.GHO_DISCOUNTED_PER_DISCOUNT_TOKEN(); - console2.log(minimumDiscountTokenBalance); uint256 result = GHO_DISCOUNT_STRATEGY.calculateDiscountRate( GHO_DISCOUNT_STRATEGY.MIN_DEBT_TOKEN_BALANCE(), diff --git a/src/test/TestGhoManager.t.sol b/src/test/TestGhoManager.t.sol deleted file mode 100644 index 5e0927a6..00000000 --- a/src/test/TestGhoManager.t.sol +++ /dev/null @@ -1,48 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.0; - -import './TestGhoBase.t.sol'; - -contract TestGhoManager is TestGhoBase { - function testUpdateDiscountRateStrategy() public { - vm.expectEmit(true, true, false, true, address(GHO_DEBT_TOKEN)); - emit DiscountRateStrategyUpdated( - address(GHO_DISCOUNT_STRATEGY), - address(GHO_DISCOUNT_STRATEGY) - ); - GHO_MANAGER.updateDiscountRateStrategy(address(GHO_DEBT_TOKEN), address(GHO_DISCOUNT_STRATEGY)); - } - - function testRevertUnauthorizedUpdateDiscountRateStrategy() public { - vm.prank(ALICE); - vm.expectRevert('Ownable: caller is not the owner'); - GHO_MANAGER.updateDiscountRateStrategy(address(GHO_DEBT_TOKEN), address(GHO_DISCOUNT_STRATEGY)); - } - - function testSetReserveInterestRateStrategy() public { - address oldInterestStrategy = POOL.getReserveInterestRateStrategyAddress(address(GHO_TOKEN)); - GhoInterestRateStrategy newInterestStrategy = new GhoInterestRateStrategy(address(0), 2e25); - vm.expectEmit(true, true, true, true, address(CONFIGURATOR)); - emit ReserveInterestRateStrategyChanged( - address(GHO_TOKEN), - oldInterestStrategy, - address(newInterestStrategy) - ); - GHO_MANAGER.setReserveInterestRateStrategyAddress( - address(CONFIGURATOR), - address(GHO_TOKEN), - address(newInterestStrategy) - ); - } - - function testRevertUnauthorizedSetReserveInterestRateStrategy() public { - GhoInterestRateStrategy newInterestStrategy = new GhoInterestRateStrategy(address(0), 2e25); - vm.prank(ALICE); - vm.expectRevert('Ownable: caller is not the owner'); - GHO_MANAGER.setReserveInterestRateStrategyAddress( - address(CONFIGURATOR), - address(GHO_TOKEN), - address(newInterestStrategy) - ); - } -} diff --git a/src/test/TestGhoSteward.t.sol b/src/test/TestGhoSteward.t.sol new file mode 100644 index 00000000..7fcab1bb --- /dev/null +++ b/src/test/TestGhoSteward.t.sol @@ -0,0 +1,415 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import './TestGhoBase.t.sol'; + +contract TestGhoSteward is TestGhoBase { + using PercentageMath for uint256; + + function testConstructor() public { + assertEq(GHO_STEWARD.MINIMUM_DELAY(), MINIMUM_DELAY); + assertEq(GHO_STEWARD.BORROW_RATE_CHANGE_MAX(), BORROW_RATE_CHANGE_MAX); + assertEq(GHO_STEWARD.STEWARD_LIFESPAN(), STEWARD_LIFESPAN); + + assertEq(GHO_STEWARD.POOL_ADDRESSES_PROVIDER(), address(PROVIDER)); + assertEq(GHO_STEWARD.GHO_TOKEN(), address(GHO_TOKEN)); + assertEq(GHO_STEWARD.RISK_COUNCIL(), RISK_COUNCIL); + assertEq(GHO_STEWARD.owner(), SHORT_EXECUTOR); + + IGhoSteward.Debounce memory timelocks = GHO_STEWARD.getTimelock(); + assertEq(timelocks.borrowRateLastUpdated, 0); + assertEq(timelocks.bucketCapacityLastUpdated, 0); + + assertEq(GHO_STEWARD.getStewardExpiration(), block.timestamp + GHO_STEWARD.STEWARD_LIFESPAN()); + } + + function testRevertConstructorInvalidAddressesProvider() public { + vm.expectRevert('INVALID_ADDRESSES_PROVIDER'); + new GhoSteward(address(0), address(0x002), address(0x003), address(0x004)); + } + + function testRevertConstructorInvalidGhoToken() public { + vm.expectRevert('INVALID_GHO_TOKEN'); + new GhoSteward(address(0x001), address(0), address(0x003), address(0x004)); + } + + function testRevertConstructorInvalidRiskCouncil() public { + vm.expectRevert('INVALID_RISK_COUNCIL'); + new GhoSteward(address(0x001), address(0x002), address(0), address(0x004)); + } + + function testRevertConstructorInvalidShortExecutor() public { + vm.expectRevert('INVALID_SHORT_EXECUTOR'); + new GhoSteward(address(0x001), address(0x002), address(0x003), address(0)); + } + + function testExtendStewardExpiration() public { + uint40 oldExpirationTime = GHO_STEWARD.getStewardExpiration(); + uint40 newExpirationTime = oldExpirationTime + GHO_STEWARD.STEWARD_LIFESPAN(); + vm.prank(GHO_STEWARD.owner()); + vm.expectEmit(true, true, true, true, address(GHO_STEWARD)); + emit StewardExpirationUpdated(oldExpirationTime, newExpirationTime); + GHO_STEWARD.extendStewardExpiration(); + assertEq(GHO_STEWARD.getStewardExpiration(), newExpirationTime); + } + + function testRevertExtendStewardExpiration() public { + vm.prank(ALICE); + vm.expectRevert('Ownable: caller is not the owner'); + GHO_STEWARD.extendStewardExpiration(); + } + + function testUpdateBorrowRate() public { + address oldInterestStrategy = POOL.getReserveInterestRateStrategyAddress(address(GHO_TOKEN)); + uint256 oldBorrowRate = GhoInterestRateStrategy(oldInterestStrategy) + .getBaseVariableBorrowRate(); + vm.expectEmit(true, true, true, false, address(CONFIGURATOR)); + emit ReserveInterestRateStrategyChanged( + address(GHO_TOKEN), + oldInterestStrategy, + address(0) // deployed by GhoSteward + ); + uint256 newBorrowRate = oldBorrowRate + 1; + vm.warp(GHO_STEWARD.MINIMUM_DELAY() + 1); + + IGhoSteward.Debounce memory timelocksBefore = GHO_STEWARD.getTimelock(); + + assertEq(GHO_STEWARD.getAllStrategies().length, 0); + + vm.prank(RISK_COUNCIL); + GHO_STEWARD.updateBorrowRate(newBorrowRate); + + address[] memory strategies = GHO_STEWARD.getAllStrategies(); + assertEq(strategies.length, 1); + + address newInterestStrategy = POOL.getReserveInterestRateStrategyAddress(address(GHO_TOKEN)); + assertEq(strategies[0], newInterestStrategy); + assertEq( + GhoInterestRateStrategy(newInterestStrategy).getBaseVariableBorrowRate(), + newBorrowRate + ); + IGhoSteward.Debounce memory timelocks = GHO_STEWARD.getTimelock(); + assertEq(timelocks.borrowRateLastUpdated, block.timestamp); + assertEq(timelocks.bucketCapacityLastUpdated, timelocksBefore.bucketCapacityLastUpdated); + } + + function testUpdateBorrowRateReuseStrategy() public { + address oldInterestStrategy = POOL.getReserveInterestRateStrategyAddress(address(GHO_TOKEN)); + uint256 oldBorrowRate = GhoInterestRateStrategy(oldInterestStrategy) + .getBaseVariableBorrowRate(); + + vm.warp(GHO_STEWARD.MINIMUM_DELAY() + 1); + + assertEq(GHO_STEWARD.getAllStrategies().length, 0); + + vm.prank(RISK_COUNCIL); + GHO_STEWARD.updateBorrowRate(oldBorrowRate); + + assertEq(GHO_STEWARD.getAllStrategies().length, 1); + + address[] memory strategies = GHO_STEWARD.getAllStrategies(); + assertEq(strategies.length, 1); + + // New borrow rate + uint256 newBorrowRate = oldBorrowRate + 1; + vm.warp(block.timestamp + GHO_STEWARD.MINIMUM_DELAY() + 1); + + vm.prank(RISK_COUNCIL); + GHO_STEWARD.updateBorrowRate(newBorrowRate); + + strategies = GHO_STEWARD.getAllStrategies(); + assertEq(strategies.length, 2); + + address newInterestStrategy = POOL.getReserveInterestRateStrategyAddress(address(GHO_TOKEN)); + assertEq(strategies[1], newInterestStrategy); + assertEq( + GhoInterestRateStrategy(newInterestStrategy).getBaseVariableBorrowRate(), + newBorrowRate + ); + + // Come back to old rate + vm.warp(block.timestamp + GHO_STEWARD.MINIMUM_DELAY() + 1); + + vm.prank(RISK_COUNCIL); + GHO_STEWARD.updateBorrowRate(oldBorrowRate); + + assertEq(GHO_STEWARD.getAllStrategies().length, 2); + assertEq( + GhoInterestRateStrategy(POOL.getReserveInterestRateStrategyAddress(address(GHO_TOKEN))) + .getBaseVariableBorrowRate(), + oldBorrowRate + ); + } + + function testUpdateBorrowRateIdempotent() public { + address oldInterestStrategy = POOL.getReserveInterestRateStrategyAddress(address(GHO_TOKEN)); + uint256 oldBorrowRate = GhoInterestRateStrategy(oldInterestStrategy) + .getBaseVariableBorrowRate(); + vm.expectEmit(true, true, true, false, address(CONFIGURATOR)); + emit ReserveInterestRateStrategyChanged( + address(GHO_TOKEN), + oldInterestStrategy, + address(0) // deployed by GhoSteward + ); + vm.warp(GHO_STEWARD.MINIMUM_DELAY() + 1); + + vm.prank(RISK_COUNCIL); + GHO_STEWARD.updateBorrowRate(oldBorrowRate); + + address newInterestStrategy = POOL.getReserveInterestRateStrategyAddress(address(GHO_TOKEN)); + assertEq( + GhoInterestRateStrategy(newInterestStrategy).getBaseVariableBorrowRate(), + oldBorrowRate + ); + } + + function testUpdateBorrowRateMaximumIncrease() public { + address oldInterestStrategy = POOL.getReserveInterestRateStrategyAddress(address(GHO_TOKEN)); + uint256 oldBorrowRate = GhoInterestRateStrategy(oldInterestStrategy) + .getBaseVariableBorrowRate(); + vm.expectEmit(true, true, true, false, address(CONFIGURATOR)); + emit ReserveInterestRateStrategyChanged( + address(GHO_TOKEN), + oldInterestStrategy, + address(0) // deployed by GhoSteward + ); + + uint256 newBorrowRate = oldBorrowRate + + oldBorrowRate.percentMul(GHO_STEWARD.BORROW_RATE_CHANGE_MAX()); + + vm.warp(GHO_STEWARD.MINIMUM_DELAY() + 1); + vm.prank(RISK_COUNCIL); + GHO_STEWARD.updateBorrowRate(newBorrowRate); + + address newInterestStrategy = POOL.getReserveInterestRateStrategyAddress(address(GHO_TOKEN)); + assertEq( + GhoInterestRateStrategy(newInterestStrategy).getBaseVariableBorrowRate(), + newBorrowRate + ); + } + + function testUpdateBorrowRateMaximumDecrease() public { + address oldInterestStrategy = POOL.getReserveInterestRateStrategyAddress(address(GHO_TOKEN)); + uint256 oldBorrowRate = GhoInterestRateStrategy(oldInterestStrategy) + .getBaseVariableBorrowRate(); + vm.expectEmit(true, true, true, false, address(CONFIGURATOR)); + emit ReserveInterestRateStrategyChanged( + address(GHO_TOKEN), + oldInterestStrategy, + address(0) // deployed by GhoSteward + ); + + uint256 newBorrowRate = oldBorrowRate + + oldBorrowRate.percentMul(GHO_STEWARD.BORROW_RATE_CHANGE_MAX()); + vm.warp(block.timestamp + GHO_STEWARD.MINIMUM_DELAY() + 1); + + vm.prank(RISK_COUNCIL); + GHO_STEWARD.updateBorrowRate(newBorrowRate); + + address newInterestStrategy = POOL.getReserveInterestRateStrategyAddress(address(GHO_TOKEN)); + assertEq( + GhoInterestRateStrategy(newInterestStrategy).getBaseVariableBorrowRate(), + newBorrowRate + ); + } + + function testRevertUpdateBorrowRateUnauthorized() public { + vm.expectRevert('INVALID_CALLER'); + GHO_STEWARD.updateBorrowRate(123); + } + + function testRevertUpdateBorrowRateExpiredSteward() public { + vm.warp(block.timestamp + GHO_STEWARD.getStewardExpiration()); + vm.prank(RISK_COUNCIL); + vm.expectRevert('STEWARD_EXPIRED'); + GHO_STEWARD.updateBorrowRate(123); + } + + function testRevertUpdateBorrowRateDebounceNotRespectedAtLaunch() public { + vm.warp(GHO_STEWARD.MINIMUM_DELAY()); + + vm.prank(RISK_COUNCIL); + vm.expectRevert('DEBOUNCE_NOT_RESPECTED'); + GHO_STEWARD.updateBorrowRate(123); + } + + function testRevertUpdateBorrowRateDebounceNotRespected() public { + // first borrow rate update + address oldInterestStrategy = POOL.getReserveInterestRateStrategyAddress(address(GHO_TOKEN)); + uint256 oldBorrowRate = GhoInterestRateStrategy(oldInterestStrategy) + .getBaseVariableBorrowRate(); + vm.warp(GHO_STEWARD.MINIMUM_DELAY() + 1); + vm.prank(RISK_COUNCIL); + GHO_STEWARD.updateBorrowRate(oldBorrowRate); + + vm.prank(RISK_COUNCIL); + vm.expectRevert('DEBOUNCE_NOT_RESPECTED'); + GHO_STEWARD.updateBorrowRate(123); + } + + function testRevertUpdateBorrowRateInterestRateNotFound() public { + vm.warp(GHO_STEWARD.MINIMUM_DELAY() + 1); + DataTypes.ReserveData memory mockData = POOL.getReserveData(address(GHO_TOKEN)); + mockData.interestRateStrategyAddress = address(0); + vm.mockCall( + address(POOL), + abi.encodeWithSelector(IPool.getReserveData.selector, address(GHO_TOKEN)), + abi.encode(mockData) + ); + + vm.prank(RISK_COUNCIL); + vm.expectRevert('GHO_INTEREST_RATE_STRATEGY_NOT_FOUND'); + GHO_STEWARD.updateBorrowRate(123); + } + + function testRevertUpdateBorrowRateAboveMaximumIncrease() public { + address oldInterestStrategy = POOL.getReserveInterestRateStrategyAddress(address(GHO_TOKEN)); + uint256 oldBorrowRate = GhoInterestRateStrategy(oldInterestStrategy) + .getBaseVariableBorrowRate(); + uint256 newBorrowRate = oldBorrowRate + + oldBorrowRate.percentMul(GHO_STEWARD.BORROW_RATE_CHANGE_MAX()) + + 1; + vm.warp(GHO_STEWARD.MINIMUM_DELAY() + 1); + + vm.prank(RISK_COUNCIL); + vm.expectRevert('INVALID_BORROW_RATE_UPDATE'); + GHO_STEWARD.updateBorrowRate(newBorrowRate); + } + + function testRevertUpdateBorrowRateBelowMaximumDecrease() public { + address oldInterestStrategy = POOL.getReserveInterestRateStrategyAddress(address(GHO_TOKEN)); + uint256 oldBorrowRate = GhoInterestRateStrategy(oldInterestStrategy) + .getBaseVariableBorrowRate(); + uint256 newBorrowRate = oldBorrowRate - + oldBorrowRate.percentMul(GHO_STEWARD.BORROW_RATE_CHANGE_MAX()) - + 1; + vm.warp(GHO_STEWARD.MINIMUM_DELAY() + 1); + + vm.prank(RISK_COUNCIL); + vm.expectRevert('INVALID_BORROW_RATE_UPDATE'); + GHO_STEWARD.updateBorrowRate(newBorrowRate); + } + + function testUpdateBucketCapacity() public { + (uint256 oldCapacity, ) = GHO_TOKEN.getFacilitatorBucket(address(GHO_ATOKEN)); + uint128 newCapacity = uint128(oldCapacity) + 1; + vm.warp(GHO_STEWARD.MINIMUM_DELAY() + 1); + + IGhoSteward.Debounce memory timelocksBefore = GHO_STEWARD.getTimelock(); + + vm.expectEmit(true, true, true, false, address(GHO_TOKEN)); + emit FacilitatorBucketCapacityUpdated(address(GHO_ATOKEN), oldCapacity, newCapacity); + vm.prank(RISK_COUNCIL); + GHO_STEWARD.updateBucketCapacity(newCapacity); + + (uint256 capacity, ) = GHO_TOKEN.getFacilitatorBucket(address(GHO_ATOKEN)); + assertEq(capacity, newCapacity); + IGhoSteward.Debounce memory timelocks = GHO_STEWARD.getTimelock(); + assertEq(timelocks.borrowRateLastUpdated, timelocksBefore.borrowRateLastUpdated); + assertEq(timelocks.bucketCapacityLastUpdated, block.timestamp); + } + + function testUpdateBucketCapacityIdempotent() public { + (uint256 oldCapacity, ) = GHO_TOKEN.getFacilitatorBucket(address(GHO_ATOKEN)); + vm.warp(GHO_STEWARD.MINIMUM_DELAY() + 1); + + IGhoSteward.Debounce memory timelocksBefore = GHO_STEWARD.getTimelock(); + + vm.expectEmit(true, true, true, false, address(GHO_TOKEN)); + emit FacilitatorBucketCapacityUpdated(address(GHO_ATOKEN), oldCapacity, oldCapacity); + vm.prank(RISK_COUNCIL); + GHO_STEWARD.updateBucketCapacity(uint128(oldCapacity)); + + (uint256 capacity, ) = GHO_TOKEN.getFacilitatorBucket(address(GHO_ATOKEN)); + assertEq(capacity, oldCapacity); + IGhoSteward.Debounce memory timelocks = GHO_STEWARD.getTimelock(); + assertEq(timelocks.borrowRateLastUpdated, timelocksBefore.borrowRateLastUpdated); + assertEq(timelocks.bucketCapacityLastUpdated, block.timestamp); + } + + function testUpdateBucketCapacityMaximumIncrease() public { + (uint256 oldCapacity, ) = GHO_TOKEN.getFacilitatorBucket(address(GHO_ATOKEN)); + uint128 newCapacity = uint128(oldCapacity * 2); + vm.warp(GHO_STEWARD.MINIMUM_DELAY() + 1); + + IGhoSteward.Debounce memory timelocksBefore = GHO_STEWARD.getTimelock(); + + vm.expectEmit(true, true, true, false, address(GHO_TOKEN)); + emit FacilitatorBucketCapacityUpdated(address(GHO_ATOKEN), oldCapacity, newCapacity); + vm.prank(RISK_COUNCIL); + GHO_STEWARD.updateBucketCapacity(newCapacity); + + (uint256 capacity, ) = GHO_TOKEN.getFacilitatorBucket(address(GHO_ATOKEN)); + assertEq(capacity, newCapacity); + IGhoSteward.Debounce memory timelocks = GHO_STEWARD.getTimelock(); + assertEq(timelocks.borrowRateLastUpdated, timelocksBefore.borrowRateLastUpdated); + assertEq(timelocks.bucketCapacityLastUpdated, block.timestamp); + } + + function testRevertUpdateBucketCapacityUnauthorized() public { + vm.expectRevert('INVALID_CALLER'); + GHO_STEWARD.updateBucketCapacity(123); + } + + function testRevertUpdateBucketCapacityExpiredSteward() public { + vm.warp(block.timestamp + GHO_STEWARD.getStewardExpiration()); + vm.prank(RISK_COUNCIL); + vm.expectRevert('STEWARD_EXPIRED'); + GHO_STEWARD.updateBucketCapacity(123); + } + + function testRevertUpdateBucketCapacityDebounceNotRespectedAtLaunch() public { + vm.warp(GHO_STEWARD.MINIMUM_DELAY()); + + vm.prank(RISK_COUNCIL); + vm.expectRevert('DEBOUNCE_NOT_RESPECTED'); + GHO_STEWARD.updateBucketCapacity(123); + } + + function testRevertUpdateBucketCapacityDebounceNotRespected() public { + // first bucket capacity update + (uint256 oldCapacity, ) = GHO_TOKEN.getFacilitatorBucket(address(GHO_ATOKEN)); + vm.warp(GHO_STEWARD.MINIMUM_DELAY() + 1); + vm.prank(RISK_COUNCIL); + GHO_STEWARD.updateBucketCapacity(uint128(oldCapacity)); + + vm.prank(RISK_COUNCIL); + vm.expectRevert('DEBOUNCE_NOT_RESPECTED'); + GHO_STEWARD.updateBucketCapacity(123); + } + + function testRevertUpdateBucketCapacityGhoATokenNotFound() public { + vm.warp(GHO_STEWARD.MINIMUM_DELAY() + 1); + DataTypes.ReserveData memory mockData = POOL.getReserveData(address(GHO_TOKEN)); + mockData.aTokenAddress = address(0); + vm.mockCall( + address(POOL), + abi.encodeWithSelector(IPool.getReserveData.selector, address(GHO_TOKEN)), + abi.encode(mockData) + ); + + vm.prank(RISK_COUNCIL); + vm.expectRevert('GHO_ATOKEN_NOT_FOUND'); + GHO_STEWARD.updateBucketCapacity(123); + } + + function testRevertUpdateBucketCapacityAboveMaximumIncrease() public { + (uint256 oldCapacity, ) = GHO_TOKEN.getFacilitatorBucket(address(GHO_ATOKEN)); + uint128 newCapacity = uint128(oldCapacity * 2 + 1); + vm.warp(GHO_STEWARD.MINIMUM_DELAY() + 1); + + vm.prank(RISK_COUNCIL); + vm.expectRevert('INVALID_BUCKET_CAPACITY_UPDATE'); + GHO_STEWARD.updateBucketCapacity(newCapacity); + } + + function testRevertUpdateBucketCapacityBelowMaximumDecrease() public { + (uint256 oldCapacity, ) = GHO_TOKEN.getFacilitatorBucket(address(GHO_ATOKEN)); + uint128 newCapacity = uint128(oldCapacity - 1); + vm.warp(GHO_STEWARD.MINIMUM_DELAY() + 1); + + vm.prank(RISK_COUNCIL); + vm.expectRevert('INVALID_BUCKET_CAPACITY_UPDATE'); + GHO_STEWARD.updateBucketCapacity(newCapacity); + } +} diff --git a/src/test/helpers/Constants.sol b/src/test/helpers/Constants.sol index 567aaef1..1f635fb7 100644 --- a/src/test/helpers/Constants.sol +++ b/src/test/helpers/Constants.sol @@ -17,6 +17,11 @@ contract Constants { int256 constant DEFAULT_GHO_PRICE = 1e8; uint8 constant DEFAULT_ORACLE_DECIMALS = 8; + // GhoSteward + uint256 constant MINIMUM_DELAY = 5 days; + uint256 constant BORROW_RATE_CHANGE_MAX = 0.0050e4; + uint40 constant STEWARD_LIFESPAN = 60 days; + // sample users used across unit tests address constant ALICE = address(0x1111); address constant BOB = address(0x1112); @@ -24,4 +29,5 @@ contract Constants { address constant FAUCET = address(0x10001); address constant TREASURY = address(0x10002); + address constant RISK_COUNCIL = address(0x10003); } diff --git a/src/test/helpers/Events.sol b/src/test/helpers/Events.sol index b78fd88f..0e51fa56 100644 --- a/src/test/helpers/Events.sol +++ b/src/test/helpers/Events.sol @@ -75,4 +75,7 @@ interface Events { event RoleGranted(bytes32 indexed role, address indexed account, address indexed sender); event RoleRevoked(bytes32 indexed role, address indexed account, address indexed sender); + + // GhoSteward + event StewardExpirationUpdated(uint40 oldStewardExpiration, uint40 newStewardExpiration); } diff --git a/src/test/mocks/MockedPool.sol b/src/test/mocks/MockedPool.sol index 4cbac6b5..d8b45e44 100644 --- a/src/test/mocks/MockedPool.sol +++ b/src/test/mocks/MockedPool.sol @@ -111,7 +111,7 @@ contract MockedPool is Pool { _reserves[asset].interestRateStrategyAddress = rateStrategyAddress; } - function getReserveInterestRateStrategyAddress(address asset) external returns (address) { + function getReserveInterestRateStrategyAddress(address asset) public view returns (address) { return _reserves[asset].interestRateStrategyAddress; } } diff --git a/src/test/mocks/MockedProvider.sol b/src/test/mocks/MockedProvider.sol index 6b910f54..4aff6ec3 100644 --- a/src/test/mocks/MockedProvider.sol +++ b/src/test/mocks/MockedProvider.sol @@ -3,6 +3,8 @@ pragma solidity ^0.8.0; contract MockedProvider { address immutable ACL_MANAGER; + address POOL; + address POOL_CONFIGURATOR; constructor(address aclManager) { ACL_MANAGER = aclManager; @@ -13,7 +15,23 @@ contract MockedProvider { // Excludes contract from coverage. } - function getACLManager() public returns (address) { + function setPool(address pool) public { + POOL = pool; + } + + function setConfigurator(address configurator) public { + POOL_CONFIGURATOR = configurator; + } + + function getACLManager() public view returns (address) { return ACL_MANAGER; } + + function getPool() public view returns (address) { + return POOL; + } + + function getPoolConfigurator() public view returns (address) { + return POOL_CONFIGURATOR; + } } diff --git a/tasks/main/gho-testnet-setup.ts b/tasks/main/gho-testnet-setup.ts index 58ede632..15f48086 100644 --- a/tasks/main/gho-testnet-setup.ts +++ b/tasks/main/gho-testnet-setup.ts @@ -44,11 +44,11 @@ task('gho-testnet-setup', 'Deploy and Configure Gho').setAction(async (params, h await hre.run('upgrade-stkAave'); /***************************************** - * ADD GhoManager * + * ADD GhoSteward * ******************************************/ blankSpace(); - await hre.run('add-gho-manager'); + await hre.run('add-gho-steward'); console.log(`\nGho Setup Complete!\n`); diff --git a/tasks/testnet-setup/07_add-gho-manager.ts b/tasks/testnet-setup/07_add-gho-manager.ts deleted file mode 100644 index d73fdb33..00000000 --- a/tasks/testnet-setup/07_add-gho-manager.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { task } from 'hardhat/config'; -import { getACLManager } from '@aave/deploy-v3'; -import { GhoManager } from '../../../types/src/contracts/facilitators/aave/misc/GhoManager'; - -task('add-gho-manager', 'Adds ghoManager poolAdmin role').setAction(async (_, hre) => { - const { ethers } = hre; - - const ghoManager = (await ethers.getContract('GhoManager')) as GhoManager; - const aclArtifact = await getACLManager(); - const addPoolAdminTx = await aclArtifact.addPoolAdmin(ghoManager.address); - - const addPoolAdminTxReceipt = await addPoolAdminTx.wait(); - const newPoolAdminEvents = addPoolAdminTxReceipt.events?.find((e) => { - return e.event === 'RoleGranted'; - }); - if (newPoolAdminEvents?.args) { - console.log(`Gho manager added as a poolAdmin: ${JSON.stringify(newPoolAdminEvents.args[0])}`); - } else { - throw new Error(`Error at adding entity. Check tx: ${addPoolAdminTx.hash}`); - } - return; -}); diff --git a/tasks/testnet-setup/07_add-gho-steward.ts b/tasks/testnet-setup/07_add-gho-steward.ts new file mode 100644 index 00000000..3fd7854e --- /dev/null +++ b/tasks/testnet-setup/07_add-gho-steward.ts @@ -0,0 +1,36 @@ +import { task } from 'hardhat/config'; +import { getACLManager } from '@aave/deploy-v3'; +import { GhoSteward } from '../../../types/src/contracts/facilitators/aave/misc/GhoSteward'; +import { getGhoToken } from '../../helpers/contract-getters'; +import { ethers } from 'ethers'; + +const BUCKET_MANAGER_ROLE = ethers.utils.id('BUCKET_MANAGER_ROLE'); + +task('add-gho-steward', 'Adds ghoSteward poolAdmin role').setAction(async (_, hre) => { + const { ethers } = hre; + + const ghoSteward = (await ethers.getContract('GhoSteward')) as GhoSteward; + const aclArtifact = await getACLManager(); + const addPoolAdminTx = await aclArtifact.addPoolAdmin(ghoSteward.address); + + const addPoolAdminTxReceipt = await addPoolAdminTx.wait(); + const newPoolAdminEvents = addPoolAdminTxReceipt.events?.find((e) => { + return e.event === 'RoleGranted'; + }); + if (newPoolAdminEvents?.args) { + console.log(`Gho steward added as a poolAdmin: ${JSON.stringify(newPoolAdminEvents.args[0])}`); + } else { + throw new Error(`Error at adding entity. Check tx: ${addPoolAdminTx.hash}`); + } + + const ghoToken = await getGhoToken(); + await (await ghoToken.grantRole(BUCKET_MANAGER_ROLE, ghoSteward.address)).wait(); + const added = await ghoToken.hasRole(BUCKET_MANAGER_ROLE, ghoSteward.address); + if (added) { + console.log('Gho steward added as bucketManager'); + } else { + throw new Error(`Error at adding entity ad BUCKET_MANAGER`); + } + + return; +}); diff --git a/test/gho-manager.test.ts b/test/gho-manager.test.ts deleted file mode 100644 index 831e33c8..00000000 --- a/test/gho-manager.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import hre from 'hardhat'; -import { expect } from 'chai'; -import { makeSuite, TestEnv } from './helpers/make-suite'; -import { impersonateAccountHardhat } from '../helpers/misc-utils'; -import { ONE_ADDRESS } from '../helpers/constants'; -import { ProtocolErrors } from '@aave/core-v3'; -import { getPoolConfiguratorProxy } from '@aave/deploy-v3'; - -export const TWO_ADDRESS = '0x0000000000000000000000000000000000000002'; - -makeSuite('Gho Manager End-To-End', (testEnv: TestEnv) => { - let ethers; - - let poolSigner; - let randomSigner; - let poolConfigurator; - - before(async () => { - ethers = hre.ethers; - - const { pool } = testEnv; - - poolSigner = await impersonateAccountHardhat(pool.address); - randomSigner = await impersonateAccountHardhat(ONE_ADDRESS); - poolConfigurator = await getPoolConfiguratorProxy(); - }); - - it('Update discount rate strategy from gho manager', async function () { - const { variableDebtToken, deployer, ghoManager } = testEnv; - - await expect( - ghoManager - .connect(deployer.signer) - .updateDiscountRateStrategy(variableDebtToken.address, TWO_ADDRESS) - ).to.emit(variableDebtToken, 'DiscountRateStrategyUpdated'); - }); - - it('Get Discount Strategy - after setting', async function () { - const { variableDebtToken } = testEnv; - - expect(await variableDebtToken.getDiscountRateStrategy()).to.be.equal(TWO_ADDRESS); - }); - - it('Update discount rate strategy from gho manager without owner role (revert expected)', async function () { - const { variableDebtToken, ghoManager } = testEnv; - - await expect( - ghoManager - .connect(randomSigner) - .updateDiscountRateStrategy(variableDebtToken.address, ONE_ADDRESS) - ).to.be.revertedWith(ProtocolErrors.OWNABLE_ONLY_OWNER); - }); - - it('Updates gho interest rate strategy', async function () { - const { ghoManager, gho, poolAdmin } = testEnv; - const randomAddress = ONE_ADDRESS; - await expect( - ghoManager - .connect(poolAdmin.signer) - .setReserveInterestRateStrategyAddress(poolConfigurator.address, gho.address, randomAddress) - ).to.emit(poolConfigurator, 'ReserveInterestRateStrategyChanged'); - }); - - it('Check gho interest rate strategy is set correctly', async function () { - const { gho, aaveDataProvider } = testEnv; - const randomAddress = ONE_ADDRESS; - await expect(await aaveDataProvider.getInterestRateStrategyAddress(gho.address)).to.be.equal( - randomAddress - ); - }); - - it('Check permissions of owner modified functions (revert expected)', async () => { - const { variableDebtToken, users, ghoManager, gho } = testEnv; - const nonPoolAdmin = users[2]; - - const randomAddress = ONE_ADDRESS; - const calls = [ - { fn: 'updateDiscountRateStrategy', args: [variableDebtToken.address, randomAddress] }, - { - fn: 'setReserveInterestRateStrategyAddress', - args: [poolConfigurator.address, gho.address, randomAddress], - }, - ]; - for (const call of calls) { - await expect( - ghoManager.connect(nonPoolAdmin.signer)[call.fn](...call.args) - ).to.be.revertedWith(ProtocolErrors.OWNABLE_ONLY_OWNER); - } - }); - - it('Check GhoManager is PoolAdmin', async function () { - const { ghoManager, aclManager } = testEnv; - await expect(await aclManager.isPoolAdmin(ghoManager.address)).to.be.equal(true); - }); -}); diff --git a/test/gho-steward.test.ts b/test/gho-steward.test.ts new file mode 100644 index 00000000..44e85fe5 --- /dev/null +++ b/test/gho-steward.test.ts @@ -0,0 +1,218 @@ +import hre from 'hardhat'; +import { expect } from 'chai'; +import { makeSuite, TestEnv } from './helpers/make-suite'; +import { + advanceTimeAndBlock, + impersonateAccountHardhat, + mine, + setBlocktime, + timeLatest, +} from '../helpers/misc-utils'; +import { ONE_ADDRESS } from '../helpers/constants'; +import { ProtocolErrors } from '@aave/core-v3'; +import { evmRevert, evmSnapshot, getPoolConfiguratorProxy } from '@aave/deploy-v3'; +import { BigNumber } from 'ethers'; +import { GhoInterestRateStrategy__factory } from '../types'; + +export const TWO_ADDRESS = '0x0000000000000000000000000000000000000002'; + +makeSuite('Gho Steward End-To-End', (testEnv: TestEnv) => { + let ethers; + + let poolSigner; + let randomSigner; + let poolConfigurator; + + const BUCKET_MANAGER_ROLE = hre.ethers.utils.id('BUCKET_MANAGER_ROLE'); + + before(async () => { + ethers = hre.ethers; + + const { pool } = testEnv; + + poolSigner = await impersonateAccountHardhat(pool.address); + randomSigner = await impersonateAccountHardhat(ONE_ADDRESS); + poolConfigurator = await getPoolConfiguratorProxy(); + }); + + it('Check GhoSteward is PoolAdmin and BucketManager', async function () { + const { gho, ghoSteward, aclManager } = testEnv; + expect(await aclManager.isPoolAdmin(ghoSteward.address)).to.be.equal(true); + expect(await gho.hasRole(BUCKET_MANAGER_ROLE, ghoSteward.address)).to.be.equal(true); + }); + + it('Extends steward expiration', async function () { + const { ghoSteward } = testEnv; + + const expirationTimeBefore = await ghoSteward.getStewardExpiration(); + const newExpirationTime = BigNumber.from(expirationTimeBefore).add( + await ghoSteward.STEWARD_LIFESPAN() + ); + + const ownerAddress = await ghoSteward.owner(); + const owner = await impersonateAccountHardhat(ownerAddress); + await expect(ghoSteward.connect(owner).extendStewardExpiration()) + .to.emit(ghoSteward, 'StewardExpirationUpdated') + .withArgs(expirationTimeBefore, newExpirationTime); + + expect(await ghoSteward.getStewardExpiration()).to.be.eq(newExpirationTime.toNumber()); + }); + + it('Tries to extend steward expiration with no authorization (revert expected)', async function () { + const { ghoSteward, users } = testEnv; + const nonPoolAdmin = users[2]; + + await expect( + ghoSteward.connect(nonPoolAdmin.signer).extendStewardExpiration() + ).to.be.revertedWith('Ownable: caller is not the owner'); + }); + + it('Updates gho variable borrow rate', async function () { + const { ghoSteward, poolAdmin, aaveDataProvider, gho, deployer } = testEnv; + const oldInterestRateStrategyAddress = await aaveDataProvider.getInterestRateStrategyAddress( + gho.address + ); + const oldRate = await GhoInterestRateStrategy__factory.connect( + oldInterestRateStrategyAddress, + deployer.signer + ).getBaseVariableBorrowRate(); + await advanceTimeAndBlock((await ghoSteward.MINIMUM_DELAY()).toNumber()); + await expect(ghoSteward.connect(poolAdmin.signer).updateBorrowRate(oldRate)).to.emit( + poolConfigurator, + 'ReserveInterestRateStrategyChanged' + ); + + expect(await aaveDataProvider.getInterestRateStrategyAddress(gho.address)).not.to.be.equal( + oldInterestRateStrategyAddress + ); + }); + + it('GhoSteward tries to update gho variable borrow rate without PoolAdmin role (revert expected)', async function () { + const { ghoSteward, poolAdmin, aclAdmin, aclManager, aaveDataProvider, deployer, gho } = + testEnv; + + const snapId = await evmSnapshot(); + + const oldInterestRateStrategyAddress = await aaveDataProvider.getInterestRateStrategyAddress( + gho.address + ); + const oldRate = await GhoInterestRateStrategy__factory.connect( + oldInterestRateStrategyAddress, + deployer.signer + ).getBaseVariableBorrowRate(); + await advanceTimeAndBlock((await ghoSteward.MINIMUM_DELAY()).toNumber()); + + expect(await aclManager.connect(aclAdmin.signer).removePoolAdmin(ghoSteward.address)); + expect(await aclManager.isPoolAdmin(ghoSteward.address)).to.be.false; + + await expect(ghoSteward.connect(poolAdmin.signer).updateBorrowRate(oldRate)).to.be.revertedWith( + ProtocolErrors.CALLER_NOT_RISK_OR_POOL_ADMIN + ); + + await evmRevert(snapId); + }); + + it('Updates facilitator bucket', async function () { + const { ghoSteward, poolAdmin, gho, aToken } = testEnv; + + const [oldCapacity] = await gho.getFacilitatorBucket(aToken.address); + await advanceTimeAndBlock((await ghoSteward.MINIMUM_DELAY()).toNumber()); + await expect(ghoSteward.connect(poolAdmin.signer).updateBucketCapacity(oldCapacity.add(1))) + .to.emit(gho, 'FacilitatorBucketCapacityUpdated') + .withArgs(aToken.address, oldCapacity, oldCapacity.add(1)); + }); + + it('GhoSteward tries to update bucket capacity without BucketManager role (revert expected)', async function () { + const { ghoSteward, poolAdmin, gho, deployer, aToken } = testEnv; + + const snapId = await evmSnapshot(); + + const [oldCapacity] = await gho.getFacilitatorBucket(aToken.address); + await advanceTimeAndBlock((await ghoSteward.MINIMUM_DELAY()).toNumber()); + expect(await gho.connect(deployer.signer).revokeRole(BUCKET_MANAGER_ROLE, ghoSteward.address)); + expect(await gho.hasRole(BUCKET_MANAGER_ROLE, ghoSteward.address)).to.be.false; + + await expect( + ghoSteward.connect(poolAdmin.signer).updateBucketCapacity(oldCapacity) + ).to.be.revertedWith( + `AccessControl: account ${ghoSteward.address.toLowerCase()} is missing role ${BUCKET_MANAGER_ROLE}` + ); + + await evmRevert(snapId); + }); + + it('Check permissions of owner modified functions (revert expected)', async () => { + const { users, ghoSteward } = testEnv; + const nonPoolAdmin = users[2]; + + const calls = [ + { fn: 'updateBorrowRate', args: [0] }, + { fn: 'updateBucketCapacity', args: [ONE_ADDRESS] }, + ]; + for (const call of calls) { + await expect( + ghoSteward.connect(nonPoolAdmin.signer)[call.fn](...call.args) + ).to.be.revertedWith('INVALID_CALLER'); + } + }); + + it('RiskCouncil updates both parameters, steward expires, expiration time extends, more updates', async function () { + const { ghoSteward, poolAdmin, gho, aToken, aaveDataProvider, deployer } = testEnv; + + const oldInterestRateStrategyAddress = await aaveDataProvider.getInterestRateStrategyAddress( + gho.address + ); + const oldRate = await GhoInterestRateStrategy__factory.connect( + oldInterestRateStrategyAddress, + deployer.signer + ).getBaseVariableBorrowRate(); + const [oldCapacity] = await gho.getFacilitatorBucket(aToken.address); + await advanceTimeAndBlock((await ghoSteward.MINIMUM_DELAY()).toNumber()); + + // Update Bucket Capacity + await expect(ghoSteward.connect(poolAdmin.signer).updateBucketCapacity(oldCapacity.add(1))); + expect((await ghoSteward.getTimelock()).bucketCapacityLastUpdated).to.be.eq(await timeLatest()); + // Update Borrow Rate + await expect(ghoSteward.connect(poolAdmin.signer).updateBorrowRate(oldRate)); + expect((await ghoSteward.getTimelock()).borrowRateLastUpdated).to.be.eq(await timeLatest()); + + // Advance until expiration + await setBlocktime(await ghoSteward.getStewardExpiration()); + await mine(); + + // Tries to update bucket capacity or borrow rate + await expect( + ghoSteward.connect(poolAdmin.signer).updateBucketCapacity(oldCapacity) + ).to.be.revertedWith('STEWARD_EXPIRED'); + await expect(ghoSteward.connect(poolAdmin.signer).updateBorrowRate(oldRate)).to.be.revertedWith( + 'STEWARD_EXPIRED' + ); + + // Extend + const ownerAddress = await ghoSteward.owner(); + const owner = await impersonateAccountHardhat(ownerAddress); + await expect(ghoSteward.connect(owner).extendStewardExpiration()).to.emit( + ghoSteward, + 'StewardExpirationUpdated' + ); + + // New updates are possible + await expect(ghoSteward.connect(poolAdmin.signer).updateBucketCapacity(oldCapacity.add(1))); + expect((await ghoSteward.getTimelock()).bucketCapacityLastUpdated).to.be.eq(await timeLatest()); + // Update Borrow Rate + await expect(ghoSteward.connect(poolAdmin.signer).updateBorrowRate(oldRate)); + expect((await ghoSteward.getTimelock()).borrowRateLastUpdated).to.be.eq(await timeLatest()); + }); + + it('Deactivate Steward', async function () { + const { gho, ghoSteward, aclManager, deployer } = testEnv; + expect(await aclManager.isPoolAdmin(ghoSteward.address)).to.be.equal(true); + expect(await gho.hasRole(BUCKET_MANAGER_ROLE, ghoSteward.address)).to.be.equal(true); + + expect(await aclManager.connect(deployer.signer).removePoolAdmin(ghoSteward.address)); + expect(await gho.connect(deployer.signer).revokeRole(BUCKET_MANAGER_ROLE, ghoSteward.address)); + + expect(await aclManager.isPoolAdmin(ghoSteward.address)).to.be.equal(false); + expect(await gho.hasRole(BUCKET_MANAGER_ROLE, ghoSteward.address)).to.be.equal(false); + }); +}); diff --git a/test/helpers/make-suite.ts b/test/helpers/make-suite.ts index 7ba330f3..f9649104 100644 --- a/test/helpers/make-suite.ts +++ b/test/helpers/make-suite.ts @@ -19,7 +19,7 @@ import { StakedAaveV3, MintableERC20, GhoFlashMinter, - GhoManager, + GhoSteward, } from '../../types'; import { getGhoDiscountRateStrategy, @@ -31,7 +31,7 @@ import { getStakedAave, getMintableErc20, getGhoFlashMinter, - getGhoManager, + getGhoSteward, getGhoStableDebtToken, } from '../../helpers/contract-getters'; import { @@ -80,13 +80,12 @@ export interface TestEnv { aaveDataProvider: AaveProtocolDataProvider; aaveOracle: AaveOracle; treasuryAddress: tEthereumAddress; - shortExecutorAddress: tEthereumAddress; weth: MintableERC20; usdc: MintableERC20; aaveToken: IERC20; flashMinter: GhoFlashMinter; faucetOwner: Faucet; - ghoManager: GhoManager; + ghoSteward: GhoSteward; } let HardhatSnapshotId: string = '0x1'; @@ -124,7 +123,7 @@ const testEnv: TestEnv = { aaveToken: {} as IERC20, flashMinter: {} as GhoFlashMinter, faucetOwner: {} as Faucet, - ghoManager: {} as GhoManager, + ghoSteward: {} as GhoSteward, } as TestEnv; export async function initializeMakeSuite() { @@ -167,7 +166,7 @@ export async function initializeMakeSuite() { tokenProxyAddresses.variableDebtTokenAddress ); - testEnv.ghoManager = await getGhoManager(); + testEnv.ghoSteward = await getGhoSteward(); testEnv.aTokenImplementation = await getGhoAToken(); testEnv.stableDebtTokenImplementation = await getGhoStableDebtToken();