diff --git a/.github/workflows/OCG.yml b/.github/workflows/OCG.yml new file mode 100644 index 000000000..090021ebe --- /dev/null +++ b/.github/workflows/OCG.yml @@ -0,0 +1,37 @@ +name: OCG Proposals +on: + push: + branches: + - master + pull_request: + +jobs: + run-ci: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - uses: pnpm/action-setup@v2 + with: + version: 9 + + - name: Install Node dependencies + run: pnpm install + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + + - name: Install Foundry dependencies + run: pnpm run build + + - name: Run proposal simulation tests + run: pnpm run test:proposal + env: + FORK_TEST_RPC_URL: ${{ secrets.FORK_TEST_RPC_URL }} diff --git a/ROLES.md b/ROLES.md index c9211e073..6bb80d850 100644 --- a/ROLES.md +++ b/ROLES.md @@ -10,6 +10,7 @@ This document describes the roles that are used in the Olympus protocol. | bridge_admin | CrossChainBridge | Allows configuring the CrossChainBridge | | callback_admin | BondCallback | Administers the policy | | callback_whitelist | BondCallback | Whitelists/blacklists tellers for callback | +| contract_registry_admin | ContractRegistryAdmin | Allows registering/deregistering contracts | | cooler_overseer | Clearinghouse | Allows activating the Clearinghouse | | custodian | TreasuryCustodian | Deposit/withdraw reserves and grant/revoke approvals | | distributor_admin | Distributor | Set reward rate, bounty, and other parameters | @@ -24,6 +25,7 @@ This document describes the roles that are used in the Olympus protocol. | heart | ReserveMigrator | Allows migrating reserves from one reserve token to another | | heart | YieldRepurchaseFacility | Creates a new YRF market | | heart_admin | Heart | Allows configuring heart parameters and activation/deactivation | +| loan_consolidator_admin | LoanConsolidator | Allows configuring the LoanConsolidator | | loop_daddy | YieldRepurchaseFacility | Activate/deactivate the functionality | | operator_admin | Operator | Activate/deactivate the functionality | | operator_policy | Operator | Set spreads, threshold factor, and cushion factor | diff --git a/audit/2024-10_loan-consolidator/README.md b/audit/2024-10_loan-consolidator/README.md new file mode 100644 index 000000000..d38db4a60 --- /dev/null +++ b/audit/2024-10_loan-consolidator/README.md @@ -0,0 +1,149 @@ +# Olympus Loan Consolidator Audit + +## Purpose + +The purpose of this audit is to review the Cooler Loans LoanConsolidator contract and associated contracts. + +These contracts will be installed in the Olympus V3 "Bophades" system, based on the [Default Framework](https://palm-cause-2bd.notion.site/Default-A-Design-Pattern-for-Better-Protocol-Development-7f8ace6d263c4303b108dc5f8c3055b1). + +## Scope + +### In-Scope Contracts + +The contracts in scope for this audit are: + +- [src/](../../src) + - [interfaces/](../../src/interfaces) + - [maker-dao/](../../src/interfaces/maker-dao) + - [IERC3156FlashBorrower.sol](../../src/interfaces/maker-dao/IERC3156FlashBorrower.sol) + - [IERC3156FlashLender.sol](../../src/interfaces/maker-dao/IERC3156FlashLender.sol) + - [modules/](../../src/modules) + - [CHREG/](../../src/modules/CHREG) + - [CHREG.v1.sol](../../src/modules/CHREG/CHREG.v1.sol) + - [OlympusClearinghouseRegistry.sol](../../src/modules/CHREG/OlympusClearinghouseRegistry.sol) + - [RGSTY/](../../src/modules/RGSTY) + - [RGSTY.v1.sol](../../src/modules/RGSTY/RGSTY.v1.sol) + - [OlympusContractRegistry.sol](../../src/modules/RGSTY/OlympusContractRegistry.sol) + - [policies/](../../src/policies) + - [ContractRegistryAdmin.sol](../../src/policies/ContractRegistryAdmin.sol) + - [LoanConsolidator.sol](../../src/policies/LoanConsolidator.sol) + +The following pull requests can be referred to for the in-scope contracts: + +- [Clearinghouse Registry](https://github.com/OlympusDAO/bophades/pull/191) +- [LoanConsolidator v1](https://github.com/OlympusDAO/bophades/pull/397) +- [LoanConsolidator v2](https://github.com/OlympusDAO/bophades/pull/412) + +See the [solidity-metrics.html](./solidity-metrics.html) report for a summary of the code metrics for these contracts. + +### Previous Audits + +You can review previous audits here: + +- Spearbit (07/2022) + - [Report](https://storage.googleapis.com/olympusdao-landing-page-reports/audits/2022-08%20Code4rena.pdf) +- Code4rena Olympus V3 Audit (08/2022) + - [Repo](https://github.com/code-423n4/2022-08-olympus) + - [Findings](https://github.com/code-423n4/2022-08-olympus-findings) +- Kebabsec Olympus V3 Remediation and Follow-up Audits (10/2022 - 11/2022) + - [Remediation Audit Phase 1 Report](https://hackmd.io/tJdujc0gSICv06p_9GgeFQ) + - [Remediation Audit Phase 2 Report](https://hackmd.io/@12og4u7y8i/rk5PeIiEs) + - [Follow-on Audit Report](https://hackmd.io/@12og4u7y8i/Sk56otcBs) +- Cross-Chain Bridge by OtterSec (04/2023)🙏🏼 + - [Report](https://storage.googleapis.com/olympusdao-landing-page-reports/audits/Olympus-CrossChain-Audit.pdf) +- PRICEv2 by HickupHH3 (06/2023) + - [Report](https://storage.googleapis.com/olympusdao-landing-page-reports/audits/2023_7_OlympusDAO-final.pdf) + - [Pre-Audit Commit](https://github.com/OlympusDAO/bophades/tree/17fe660525b2f0d706ca318b53111fbf103949ba) + - [Post-Remediations Commit](https://github.com/OlympusDAO/bophades/tree/9c10dc188210632b6ce46c7a836484e8e063151f) +- Cooler Loans by Sherlock (09/2023) + - [Report](https://docs.olympusdao.finance/assets/files/Cooler_Update_Audit_Report-f3f983a8ee8632637790bcc136275aa0.pdf) +- RBS 1.3 & 1.4 by HickupHH3 (11/2023) + - [Report](https://storage.googleapis.com/olympusdao-landing-page-reports/audits/OlympusDAO%20Nov%202023.pdf) + - [Pre-Audit Commit](https://github.com/OlympusDAO/bophades/tree/7a0902cf3ced19d41aafa83e96cf235fb3f15921) + - [Post-Remediations Commit](https://github.com/OlympusDAO/bophades/tree/e61d954cc620254effb014f2d2733e59d828b5b1) + +## Architecture + +### Overview + +The diagram illustrates the architecture of the components: + +```mermaid +flowchart TD + cooler_overseer((cooler_overseer)) -- activate --> Clearinghouse + Clearinghouse -- activates clearinghouse --> CHREG + cooler_overseer((cooler_overseer)) -- deactivate --> Clearinghouse + Clearinghouse -- deactivates clearinghouse --> CHREG + + subgraph Policies + ContractRegistryAdmin + LoanConsolidator + Clearinghouse + end + + subgraph Modules + CHREG + RGSTY + end + + contract_registry_admin((contract_registry_admin)) -- registers contract --> ContractRegistryAdmin + ContractRegistryAdmin -- registers contract --> RGSTY + contract_registry_admin((contract_registry_admin)) -- updates contract --> ContractRegistryAdmin + ContractRegistryAdmin -- updates contract --> RGSTY + contract_registry_admin((contract_registry_admin)) -- deregisters contract --> ContractRegistryAdmin + ContractRegistryAdmin -- deregisters contract --> RGSTY + + Caller((Caller)) -- determine required approvals --> LoanConsolidator + Caller -- consolidate loans --> LoanConsolidator + LoanConsolidator -- validates Clearinghouse is Olympus-owned --> CHREG + LoanConsolidator -- obtains contract addresses --> RGSTY + LoanConsolidator -- takes flashloan --> IERC3156FlashLender + LoanConsolidator -- consolidates loans --> Clearinghouse +``` + +### CHREG (Module) + +Features: + +- Activate and deactivate Clearinghouses +- Access a list of active Clearinghouses +- Access a list of all Clearinghouses (regardless of state) + +The Clearinghouse Registry is a module that requires permissioned access in order to activate/deactivate a Clearinghouse. + +In the current implementation, when a Clearinghouse policy is activated (via `activate()`) by a permissioned user (`cooler_overseer` role), the policy marks the Clearinghouse as active in the Clearinghouse Registry. + +### RGSTY (Module) + +Features: + +- Tracks contract addresses against an alpha-numeric name +- Access a list of registered names +- Access the current address for a given name +- Update the address for a given name +- Deregister a name +- Contract addresses can be registered as mutable or immutable + +The Contract Registry is a module that requires permissioned access in order to add/update/remove an address for a given name. + +### Contract Registry Admin (Policy) + +Features: + +- Register a name for a contract address +- Update the address for a given name +- Deregister a name + +The Contract Registry Admin is a policy that enables modification of the RGSTY module. It is gated to the `contract_registry_admin` role. + +### Loan Consolidator (Policy) + +Features: + +- Enables the owner of Cooler Loans to consolidate multiple loans (within the same Cooler) into a single loan, using an ERC3156 flash loan provider. +- Enables the owner of Cooler Loans to migrate loans between Clearinghouses and debt tokens (DAI and USDS). +- Has a helper function to advise the owner on the approvals of gOHM/DAI/sDAI required in order to complete the consolidation. +- The policy can optionally take a protocol fee (sent to the TRSRY) that is set to 0 by default. +- Utilises the CHREG module to obtain the list of Clearinghouse policies. +- Utilises the RGSTY module to obtain the addresses of contracts. +- The loan consolidation functionality can be activated/deactivated by the `loan_consolidator_admin` role. diff --git a/audit/2024-10_loan-consolidator/solidity-metrics.html b/audit/2024-10_loan-consolidator/solidity-metrics.html new file mode 100644 index 000000000..452d21c33 --- /dev/null +++ b/audit/2024-10_loan-consolidator/solidity-metrics.html @@ -0,0 +1,233145 @@ + + + + + + + + + Solidity Metrics + + + + + + + + + + + + + +
+ Rendering Report...

Note: This window will update automatically. In case it is not, close the + window and try again (vscode bug) :/ +
+
+ + diff --git a/deployments/.mainnet-1733829635.json b/deployments/.mainnet-1733829635.json new file mode 100644 index 000000000..4b5fbb42a --- /dev/null +++ b/deployments/.mainnet-1733829635.json @@ -0,0 +1,4 @@ +{ + "OlympusContractRegistry": "0x89631595649Cc6dEBa249A8012a5b2d88C8ddE48", + "ContractRegistryAdmin": "0xBA05d48Fb94dC76820EB7ea1B360fd6DfDEabdc5" +} diff --git a/deployments/.mainnet-1733829839.json b/deployments/.mainnet-1733829839.json new file mode 100644 index 000000000..d04e66f3a --- /dev/null +++ b/deployments/.mainnet-1733829839.json @@ -0,0 +1,3 @@ +{ + "LoanConsolidator": "0x784cA0C006b8651BAB183829A99fA46BeCe50dBc" +} diff --git a/src/external/cooler/CoolerUtils.sol b/src/external/cooler/CoolerUtils.sol deleted file mode 100644 index c9eae0e13..000000000 --- a/src/external/cooler/CoolerUtils.sol +++ /dev/null @@ -1,397 +0,0 @@ -// SPDX-License-Identifier: GLP-3.0 -pragma solidity ^0.8.15; - -import {IERC20} from "forge-std/interfaces/IERC20.sol"; -import {IERC4626} from "forge-std/interfaces/IERC4626.sol"; -import {Owned} from "solmate/auth/Owned.sol"; - -import {IERC3156FlashBorrower} from "src/interfaces/maker-dao/IERC3156FlashBorrower.sol"; -import {IERC3156FlashLender} from "src/interfaces/maker-dao/IERC3156FlashLender.sol"; -import {Clearinghouse} from "src/policies/Clearinghouse.sol"; -import {Cooler} from "src/external/cooler/Cooler.sol"; - -// -// ██████╗ ██████╗ ██████╗ ██╗ ███████╗██████╗ ██╗ ██╗████████╗██╗██╗ ███████╗ -// ██╔════╝██╔═══██╗██╔═══██╗██║ ██╔════╝██╔══██╗ ██║ ██║╚══██╔══╝██║██║ ██╔════╝ -// ██║ ██║ ██║██║ ██║██║ █████╗ ██████╔╝ ██║ ██║ ██║ ██║██║ ███████╗ -// ██║ ██║ ██║██║ ██║██║ ██╔══╝ ██╔══██╗ ██║ ██║ ██║ ██║██║ ╚════██║ -// ╚██████╗╚██████╔╝╚██████╔╝███████╗███████╗██║ ██║ ╚██████╔╝ ██║ ██║███████╗███████║ -// ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝╚══════╝ -// - -contract CoolerUtils is IERC3156FlashBorrower, Owned { - // --- ERRORS ------------------------------------------------------------------ - - /// @notice Thrown when the caller is not the contract itself. - error OnlyThis(); - - /// @notice Thrown when the caller is not the flash lender. - error OnlyLender(); - - /// @notice Thrown when the caller is not the Cooler owner. - error OnlyCoolerOwner(); - - /// @notice Thrown when the fee percentage is out of range. - /// @dev Valid values are 0 <= feePercentage <= 100e2 - error Params_FeePercentageOutOfRange(); - - /// @notice Thrown when the address is invalid. - error Params_InvalidAddress(); - - /// @notice Thrown when the caller attempts to provide more funds than are required. - error Params_UseFundsOutOfBounds(); - - /// @notice Thrown when the caller attempts to consolidate too few cooler loans. The minimum is two. - error InsufficientCoolerCount(); - - // --- DATA STRUCTURES --------------------------------------------------------- - - struct Batch { - address cooler; - uint256[] ids; - } - - struct FlashLoanData { - address clearinghouse; - address cooler; - uint256[] ids; - uint256 principal; - uint256 protocolFee; - } - - // --- IMMUTABLES AND STATE VARIABLES ------------------------------------------ - - /// @notice FlashLender contract used to take flashloans - IERC3156FlashLender public immutable lender; - IERC20 public immutable gohm; - IERC4626 public immutable sdai; - IERC20 public immutable dai; - - uint256 public constant ONE_HUNDRED_PERCENT = 100e2; - - // protocol fees - uint256 public feePercentage; - - /// @notice Address permitted to collect protocol fees - address public collector; - - // --- INITIALIZATION ---------------------------------------------------------- - - constructor( - address gohm_, - address sdai_, - address dai_, - address owner_, - address lender_, - address collector_, - uint256 feePercentage_ - ) Owned(owner_) { - // Validation - if (feePercentage_ > ONE_HUNDRED_PERCENT) revert Params_FeePercentageOutOfRange(); - if (collector_ == address(0)) revert Params_InvalidAddress(); - if (owner_ == address(0)) revert Params_InvalidAddress(); - if (lender_ == address(0)) revert Params_InvalidAddress(); - if (gohm_ == address(0)) revert Params_InvalidAddress(); - if (sdai_ == address(0)) revert Params_InvalidAddress(); - if (dai_ == address(0)) revert Params_InvalidAddress(); - - // store contracts - gohm = IERC20(gohm_); - sdai = IERC4626(sdai_); - dai = IERC20(dai_); - - lender = IERC3156FlashLender(lender_); - - // store protocol data - owner = owner_; - collector = collector_; - feePercentage = feePercentage_; - } - - // --- OPERATION --------------------------------------------------------------- - - /// @notice Consolidate loans (taken with a single Cooler contract) into a single loan by using - /// Maker flashloans. - /// - /// @dev This function will revert unless the message sender has: - /// - Approved this contract to spend the `useFunds_`. - /// - Approved this contract to spend the gOHM escrowed by the target Cooler. - /// For flexibility purposes, the user can either pay with DAI or sDAI. - /// - /// @param clearinghouse_ Olympus Clearinghouse to be used to issue the consolidated loan. - /// @param cooler_ Cooler to which the loans will be consolidated. - /// @param ids_ Array containing the ids of the loans to be consolidated. - /// @param useFunds_ Amount of DAI/sDAI available to repay the fee. - /// @param sdai_ Whether the available funds are in sDAI or DAI. - function consolidateWithFlashLoan( - address clearinghouse_, - address cooler_, - uint256[] calldata ids_, - uint256 useFunds_, - bool sdai_ - ) public { - Cooler cooler = Cooler(cooler_); - - // Ensure at least two loans are being consolidated - if (ids_.length < 2) revert InsufficientCoolerCount(); - - // Cache batch debt and principal - (uint256 totalDebt, uint256 totalPrincipal) = _getDebtForLoans(address(cooler), ids_); - - // Grant approval to the Cooler to spend the debt - dai.approve(address(cooler), totalDebt); - - // Ensure `msg.sender` is allowed to spend cooler funds on behalf of this contract - if (cooler.owner() != msg.sender) revert OnlyCoolerOwner(); - - // Transfer in necessary funds to repay the fee - // This can also reduce the flashloan fee - if (useFunds_ != 0) { - if (sdai_) { - sdai.redeem(useFunds_, address(this), msg.sender); - } else { - dai.transferFrom(msg.sender, address(this), useFunds_); - } - } - - // Calculate the required flashloan amount based on available funds and protocol fee. - uint256 daiBalance = dai.balanceOf(address(this)); - // Prevent an underflow - if (daiBalance > totalDebt) { - revert Params_UseFundsOutOfBounds(); - } - - uint256 protocolFee = getProtocolFee(totalDebt - daiBalance); - uint256 flashloan = totalDebt - daiBalance + protocolFee; - - bytes memory params = abi.encode( - FlashLoanData({ - clearinghouse: clearinghouse_, - cooler: cooler_, - ids: ids_, - principal: totalPrincipal, - protocolFee: protocolFee - }) - ); - - // Take flashloan - // This will trigger the `onFlashLoan` function after the flashloan amount has been transferred to this contract - lender.flashLoan(this, address(dai), flashloan, params); - - // This shouldn't happen, but transfer any leftover funds back to the sender - uint256 daiBalanceAfter = dai.balanceOf(address(this)); - if (daiBalanceAfter > 0) { - dai.transfer(msg.sender, daiBalanceAfter); - } - } - - function onFlashLoan( - address initiator_, - address, - uint256 amount_, - uint256 lenderFee_, - bytes calldata params_ - ) external override returns (bytes32) { - FlashLoanData memory flashLoanData = abi.decode(params_, (FlashLoanData)); - Cooler cooler = Cooler(flashLoanData.cooler); - - // perform sanity checks - if (msg.sender != address(lender)) revert OnlyLender(); - if (initiator_ != address(this)) revert OnlyThis(); - - // Iterate over all batches, repay the debt and collect the collateral - _repayDebtForLoans(flashLoanData.cooler, flashLoanData.ids); - - // Calculate the amount of collateral that will be needed for the consolidated loan - uint256 consolidatedLoanCollateral = Clearinghouse(flashLoanData.clearinghouse) - .getCollateralForLoan(flashLoanData.principal); - - // If the collateral required is greater than the collateral that was returned, then transfer gOHM from the cooler owner - // This can happen as the collateral required for the consolidated loan can be greater than the sum of the collateral of the loans being consolidated - if (consolidatedLoanCollateral > gohm.balanceOf(address(this))) { - gohm.transferFrom( - cooler.owner(), - address(this), - consolidatedLoanCollateral - gohm.balanceOf(address(this)) - ); - } - - // Take a new Cooler loan for the principal required - gohm.approve(flashLoanData.clearinghouse, consolidatedLoanCollateral); - Clearinghouse(flashLoanData.clearinghouse).lendToCooler(cooler, flashLoanData.principal); - - // The cooler owner will receive DAI for the consolidated loan - // Transfer this amount, plus the fee, to this contract - // Approval must have already been granted by the Cooler owner - dai.transferFrom(cooler.owner(), address(this), amount_ + lenderFee_); - // Approve the flash loan provider to collect the flashloan amount and fee - dai.approve(address(lender), amount_ + lenderFee_); - - // Pay protocol fee - if (flashLoanData.protocolFee != 0) dai.transfer(collector, flashLoanData.protocolFee); - - return keccak256("ERC3156FlashBorrower.onFlashLoan"); - } - - // --- ADMIN --------------------------------------------------- - - function setFeePercentage(uint256 feePercentage_) external onlyOwner { - if (feePercentage_ > ONE_HUNDRED_PERCENT) revert Params_FeePercentageOutOfRange(); - - feePercentage = feePercentage_; - } - - function setCollector(address collector_) external onlyOwner { - if (collector_ == address(0)) revert Params_InvalidAddress(); - - collector = collector_; - } - - // --- INTERNAL FUNCTIONS ------------------------------------------------------ - - function _getDebtForLoans( - address cooler_, - uint256[] calldata ids_ - ) internal view returns (uint256, uint256) { - uint256 totalDebt; - uint256 totalPrincipal; - - uint256 numLoans = ids_.length; - for (uint256 i; i < numLoans; i++) { - (, uint256 principal, uint256 interestDue, , , , , ) = Cooler(cooler_).loans(ids_[i]); - totalDebt += principal + interestDue; - totalPrincipal += principal; - } - - return (totalDebt, totalPrincipal); - } - - /// @notice Repay the debt for a given set of loans and collect the collateral. - /// @dev This function assumes: - /// - The cooler owner has granted approval for this contract to spend the gOHM collateral - /// - /// @param cooler_ Cooler contract that issued the loans - /// @param ids_ Array of loan ids to be repaid - function _repayDebtForLoans(address cooler_, uint256[] memory ids_) internal { - uint256 totalCollateral; - Cooler cooler = Cooler(cooler_); - - // Iterate over all loans in the cooler and repay - uint256 numLoans = ids_.length; - for (uint256 i; i < numLoans; i++) { - (, uint256 principal, uint256 interestDue, , , , , ) = cooler.loans(ids_[i]); - - // Repay. This also releases the collateral to the owner. - uint256 collateralReturned = cooler.repayLoan(ids_[i], principal + interestDue); - totalCollateral += collateralReturned; - } - - // Transfers all of the gOHM collateral to this contract - gohm.transferFrom(cooler.owner(), address(this), totalCollateral); - } - - // --- AUX FUNCTIONS ----------------------------------------------------------- - - /// @notice View function to compute the protocol fee for a given total debt. - function getProtocolFee(uint256 totalDebt_) public view returns (uint256) { - return (totalDebt_ * feePercentage) / ONE_HUNDRED_PERCENT; - } - - /// @notice View function to compute the required approval amounts that the owner of a given Cooler - /// must give to this contract in order to consolidate the loans. - /// - /// @param cooler_ Contract which issued the loans. - /// @param ids_ Array of loan ids to be consolidated. - /// @return owner Owner of the Cooler (address that should grant the approval). - /// @return collateral gOHM amount to be approved. - /// @return debtWithFee Total debt to be approved in DAI, including the protocol fee (if sDAI option will be set to false). - /// @return sDaiDebtWithFee Total debt to be approved in sDAI, including the protocol fee (is sDAI option will be set to true). - /// @return protocolFee Fee to be paid to the protocol. - function requiredApprovals( - address clearinghouse_, - address cooler_, - uint256[] calldata ids_ - ) external view returns (address, uint256, uint256, uint256, uint256) { - if (ids_.length < 2) revert InsufficientCoolerCount(); - - uint256 totalPrincipal; - uint256 totalDebtWithInterest; - uint256 numLoans = ids_.length; - - // Calculate the total debt and collateral for the loans - for (uint256 i; i < numLoans; i++) { - (, uint256 principal, uint256 interestDue, , , , , ) = Cooler(cooler_).loans(ids_[i]); - totalPrincipal += principal; - totalDebtWithInterest += principal + interestDue; - } - - uint256 protocolFee = getProtocolFee(totalDebtWithInterest); - uint256 totalDebtWithFee = totalDebtWithInterest + protocolFee; - - // Calculate the collateral required for the consolidated loan principal - uint256 consolidatedLoanCollateral = Clearinghouse(clearinghouse_).getCollateralForLoan( - totalPrincipal - ); - - return ( - Cooler(cooler_).owner(), - consolidatedLoanCollateral, - totalDebtWithFee, - sdai.previewWithdraw(totalDebtWithFee), - protocolFee - ); - } - - /// @notice Calculates the collateral required to consolidate a set of loans. - /// @dev Due to rounding, the collateral required for the consolidated loan may be greater than the collateral of the loans being consolidated. - /// This function calculates the additional collateral required. - /// - /// @param clearinghouse_ Clearinghouse contract used to issue the consolidated loan. - /// @param cooler_ Cooler contract that issued the loans. - /// @param ids_ Array of loan ids to be consolidated. - /// @return consolidatedLoanCollateral Collateral required for the consolidated loan. - /// @return existingLoanCollateral Collateral of the existing loans. - /// @return additionalCollateral Additional collateral required to consolidate the loans. This will need to be supplied by the Cooler owner. - function collateralRequired( - address clearinghouse_, - address cooler_, - uint256[] memory ids_ - ) - public - view - returns ( - uint256 consolidatedLoanCollateral, - uint256 existingLoanCollateral, - uint256 additionalCollateral - ) - { - if (ids_.length == 0) revert InsufficientCoolerCount(); - - // Calculate the total principal of the existing loans - uint256 totalPrincipal; - for (uint256 i; i < ids_.length; i++) { - (, uint256 principal, , uint256 collateral, , , , ) = Cooler(cooler_).loans(ids_[i]); - totalPrincipal += principal; - existingLoanCollateral += collateral; - } - - // Calculate the collateral required for the consolidated loan - consolidatedLoanCollateral = Clearinghouse(clearinghouse_).getCollateralForLoan( - totalPrincipal - ); - - // Calculate the additional collateral required - if (consolidatedLoanCollateral > existingLoanCollateral) { - additionalCollateral = consolidatedLoanCollateral - existingLoanCollateral; - } - - return (consolidatedLoanCollateral, existingLoanCollateral, additionalCollateral); - } - - /// @notice Version of the contract - /// - /// @return Version number - function VERSION() external pure returns (uint256) { - return 3; - } -} diff --git a/src/interfaces/maker-dao/IDaiUsdsMigrator.sol b/src/interfaces/maker-dao/IDaiUsdsMigrator.sol new file mode 100644 index 000000000..863e51d63 --- /dev/null +++ b/src/interfaces/maker-dao/IDaiUsdsMigrator.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.4; + +interface IDaiUsdsMigrator { + event DaiToUsds(address indexed caller, address indexed usr, uint256 wad); + event UsdsToDai(address indexed caller, address indexed usr, uint256 wad); + + function dai() external view returns (address); + + function daiJoin() external view returns (address); + + function daiToUsds(address usr, uint256 wad) external; + + function usds() external view returns (address); + + function usdsJoin() external view returns (address); + + function usdsToDai(address usr, uint256 wad) external; +} diff --git a/src/modules/CHREG/CHREG.v1.sol b/src/modules/CHREG/CHREG.v1.sol index f94fc49af..212fbae0e 100644 --- a/src/modules/CHREG/CHREG.v1.sol +++ b/src/modules/CHREG/CHREG.v1.sol @@ -1,12 +1,12 @@ // SPDX-License-Identifier: AGPL-3.0-only pragma solidity 0.8.15; -import "src/Kernel.sol"; +import {Module} from "src/Kernel.sol"; /// @title Olympus Clearinghouse Registry /// @notice Olympus Clearinghouse Registry (Module) Contract /// @dev The Olympus Clearinghouse Registry Module tracks the lending facilities that the Olympus -/// protocol deploys to satisfy the Cooler Loan demand. This allows for a single-soure of truth +/// protocol deploys to satisfy the Cooler Loan demand. This allows for a single-source of truth /// for reporting purposes around the total Treasury holdings as well as its projected receivables. abstract contract CHREGv1 is Module { // ========= ERRORS ========= // @@ -42,11 +42,13 @@ abstract contract CHREGv1 is Module { /// @notice Adds a Clearinghouse to the registry. /// Only callable by permissioned policies. + /// /// @param clearinghouse_ The address of the clearinghouse. function activateClearinghouse(address clearinghouse_) external virtual; /// @notice Deactivates a clearinghouse from the registry. /// Only callable by permissioned policies. - /// @param clearinghouse_ The address of the clearginhouse. + /// + /// @param clearinghouse_ The address of the clearinghouse. function deactivateClearinghouse(address clearinghouse_) external virtual; } diff --git a/src/modules/CHREG/OlympusClearinghouseRegistry.sol b/src/modules/CHREG/OlympusClearinghouseRegistry.sol index 88e73b62e..857770fd5 100644 --- a/src/modules/CHREG/OlympusClearinghouseRegistry.sol +++ b/src/modules/CHREG/OlympusClearinghouseRegistry.sol @@ -7,7 +7,7 @@ import {CHREGv1} from "modules/CHREG/CHREG.v1.sol"; /// @title Olympus Clearinghouse Registry /// @notice Olympus Clearinghouse Registry (Module) Contract /// @dev The Olympus Clearinghouse Registry Module tracks the lending facilities that the Olympus -/// protocol deploys to satisfy the Cooler Loan demand. This allows for a single-soure of truth +/// protocol deploys to satisfy the Cooler Loan demand. This allows for a single-source of truth /// for reporting purposes around the total Treasury holdings as well as its projected receivables. contract OlympusClearinghouseRegistry is CHREGv1 { //============================================================================================// diff --git a/src/modules/RGSTY/OlympusContractRegistry.sol b/src/modules/RGSTY/OlympusContractRegistry.sol new file mode 100644 index 000000000..3d9926690 --- /dev/null +++ b/src/modules/RGSTY/OlympusContractRegistry.sol @@ -0,0 +1,291 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.15; + +import {Kernel, Module, Policy, Keycode, toKeycode} from "src/Kernel.sol"; +import {RGSTYv1} from "./RGSTY.v1.sol"; + +/// @title Olympus Contract Registry +/// @notice This module is used to track the addresses of contracts. +/// It supports both immutable and mutable addresses. +/// Immutable addresses can be used to track commonly-used addresses (such as tokens), where the dependent contract needs an assurance that the address is immutable. +/// Mutable addresses can be used to track contracts that are expected to change over time, such as the latest version of a Policy. +contract OlympusContractRegistry is RGSTYv1 { + // ========= STATE ========= // + + /// @notice The keycode for the Olympus Contract Registry + bytes5 public constant keycode = "RGSTY"; + + // ========= CONSTRUCTOR ========= // + + /// @notice Constructor for the Olympus Contract Registry + /// @dev This function will revert if: + /// - The provided kernel address is zero + /// + /// @param kernel_ The address of the kernel + constructor(address kernel_) Module(Kernel(kernel_)) { + if (kernel_ == address(0)) revert Params_InvalidAddress(); + } + + // ========= MODULE SETUP ========= // + + /// @inheritdoc Module + function KEYCODE() public pure override returns (Keycode) { + return toKeycode(keycode); + } + + /// @inheritdoc Module + function VERSION() public pure override returns (uint8 major, uint8 minor) { + major = 1; + minor = 0; + } + + // ========= CONTRACT REGISTRATION ========= // + + /// @inheritdoc RGSTYv1 + /// @dev This function performs the following steps: + /// - Validates the parameters + /// - Registers the contract + /// - Updates the contract names + /// - Refreshes the dependent policies + /// + /// The contract name can contain: + /// - Lowercase letters + /// - Numerals + /// + /// This function will revert if: + /// - The caller is not permissioned + /// - The name is empty + /// - The name contains punctuation or uppercase letters + /// - The contract address is zero + /// - The contract name is already registered as an immutable address + /// - The contract name is already registered as a mutable address + function registerImmutableContract( + bytes5 name_, + address contractAddress_ + ) external override permissioned { + // Check that the name is not empty + if (name_ == bytes5(0)) revert Params_InvalidName(); + + // Check that the contract has not already been registered + if (_contracts[name_] != address(0)) revert Params_ContractAlreadyRegistered(); + + // Check that the contract is not registered as an immutable address + if (_immutableContracts[name_] != address(0)) revert Params_ContractAlreadyRegistered(); + + // Check that the contract address is not zero + if (contractAddress_ == address(0)) revert Params_InvalidAddress(); + + // Validate the contract name + _validateContractName(name_); + + // Register the contract + _immutableContracts[name_] = contractAddress_; + // Update the list of immutable contract names + // By this stage, it has been validated that an entry for the name does not already exist + _immutableContractNames.push(name_); + _refreshDependents(); + + emit ContractRegistered(name_, contractAddress_, true); + } + + /// @inheritdoc RGSTYv1 + /// @dev This function performs the following steps: + /// - Validates the parameters + /// - Updates the contract address + /// - Updates the contract names (if needed) + /// - Refreshes the dependent policies + /// + /// The contract name can contain: + /// - Lowercase letters + /// - Numerals + /// + /// This function will revert if: + /// - The caller is not permissioned + /// - The name is empty + /// - The name contains punctuation or uppercase letters + /// - The contract address is zero + /// - The contract name is already registered as an immutable address + /// - The contract name is already registered as a mutable address + function registerContract( + bytes5 name_, + address contractAddress_ + ) external override permissioned { + // Check that the name is not empty + if (name_ == bytes5(0)) revert Params_InvalidName(); + + // Check that the contract has not already been registered + if (_contracts[name_] != address(0)) revert Params_ContractAlreadyRegistered(); + + // Check that the contract is not registered as an immutable address + if (_immutableContracts[name_] != address(0)) revert Params_ContractAlreadyRegistered(); + + // Check that the contract address is not zero + if (contractAddress_ == address(0)) revert Params_InvalidAddress(); + + // Validate the contract name + _validateContractName(name_); + + // Register the contract + _contracts[name_] = contractAddress_; + // Update the list of contract names + // By this stage, it has been validated that an entry for the name does not already exist + _contractNames.push(name_); + _refreshDependents(); + + emit ContractRegistered(name_, contractAddress_, false); + } + + /// @inheritdoc RGSTYv1 + /// @dev This function performs the following steps: + /// - Validates the parameters + /// - Updates the contract address + /// - Updates the contract names (if needed) + /// - Refreshes the dependent policies + /// + /// This function will revert if: + /// - The caller is not permissioned + /// - The contract is not registered as a mutable address + /// - The contract address is zero + function updateContract(bytes5 name_, address contractAddress_) external override permissioned { + // Check that the contract address is not zero + if (contractAddress_ == address(0)) revert Params_InvalidAddress(); + + // Check that the contract name is registered + if (_contracts[name_] == address(0)) revert Params_ContractNotRegistered(); + + _contracts[name_] = contractAddress_; + _refreshDependents(); + + emit ContractUpdated(name_, contractAddress_); + } + + /// @inheritdoc RGSTYv1 + /// @dev This function performs the following steps: + /// - Validates the parameters + /// - Removes the contract address + /// - Removes the contract name + /// - Refreshes the dependent policies + /// + /// This function will revert if: + /// - The caller is not permissioned + /// - The contract is not registered as a mutable address + function deregisterContract(bytes5 name_) external override permissioned { + address contractAddress = _contracts[name_]; + if (contractAddress == address(0)) revert Params_ContractNotRegistered(); + + delete _contracts[name_]; + _removeContractName(name_); + _refreshDependents(); + + emit ContractDeregistered(name_); + } + + // ========= VIEW FUNCTIONS ========= // + + /// @inheritdoc RGSTYv1 + /// @dev This function will revert if: + /// - The contract is not registered as an immutable address + function getImmutableContract(bytes5 name_) external view override returns (address) { + address contractAddress = _immutableContracts[name_]; + if (contractAddress == address(0)) revert Params_ContractNotRegistered(); + + return contractAddress; + } + + /// @inheritdoc RGSTYv1 + /// @dev Note that the order of the names in the array is not guaranteed to be consistent. + function getImmutableContractNames() external view override returns (bytes5[] memory) { + return _immutableContractNames; + } + + /// @inheritdoc RGSTYv1 + /// @dev This function will revert if: + /// - The contract is not registered + function getContract(bytes5 name_) external view override returns (address) { + address contractAddress = _contracts[name_]; + + if (contractAddress == address(0)) revert Params_ContractNotRegistered(); + + return contractAddress; + } + + /// @inheritdoc RGSTYv1 + /// @dev Note that the order of the names in the array is not guaranteed to be consistent. + function getContractNames() external view override returns (bytes5[] memory) { + return _contractNames; + } + + // ========= INTERNAL FUNCTIONS ========= // + + /// @notice Validates the contract name + /// @dev This function will revert if: + /// - The name is empty + /// - Null characters are found in the start or middle of the name + /// - The name contains punctuation or uppercase letters + function _validateContractName(bytes5 name_) internal pure { + // Check that the contract name is lowercase letters and numerals only + for (uint256 i = 0; i < 5; i++) { + bytes1 char = name_[i]; + + // When a null character is found, it should only be followed by null characters + if (char == 0x00) { + for (uint256 j = i + 1; j < 5; j++) { + if (name_[j] != 0x00) revert Params_InvalidName(); + } + + // If reaching this far, then all of the subsequent characters are null characters + return; + } + + // 0-9 + if (char >= 0x30 && char <= 0x39) { + continue; + } + + // a-z + if (char >= 0x61 && char <= 0x7A) { + continue; + } + + revert Params_InvalidName(); + } + } + + /// @notice Removes the name of a contract from the list of contract names. + /// + /// @param name_ The name of the contract + function _removeContractName(bytes5 name_) internal { + uint256 length = _contractNames.length; + for (uint256 i; i < length; ) { + if (_contractNames[i] == name_) { + // Swap the found element with the last element + _contractNames[i] = _contractNames[length - 1]; + // Remove the last element + _contractNames.pop(); + return; + } + unchecked { + ++i; + } + } + } + + /// @notice Refreshes the dependents of the module + function _refreshDependents() internal { + Keycode moduleKeycode = toKeycode(keycode); + + // Iterate over each dependent policy until the end of the array is reached + uint256 dependentIndex; + while (true) { + try kernel.moduleDependents(moduleKeycode, dependentIndex) returns (Policy dependent) { + dependent.configureDependencies(); + unchecked { + ++dependentIndex; + } + } catch { + // If the call to the moduleDependents mapping reverts, then we have reached the end of the array + break; + } + } + } +} diff --git a/src/modules/RGSTY/RGSTY.v1.sol b/src/modules/RGSTY/RGSTY.v1.sol new file mode 100644 index 000000000..b17d72f2a --- /dev/null +++ b/src/modules/RGSTY/RGSTY.v1.sol @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.15; + +import {Module} from "src/Kernel.sol"; + +/// @title Contract Registry +/// @notice Interface for a module that can track the addresses of contracts +abstract contract RGSTYv1 is Module { + // ========= EVENTS ========= // + + /// @notice Emitted when a contract is registered + event ContractRegistered( + bytes5 indexed name, + address indexed contractAddress, + bool isImmutable + ); + + /// @notice Emitted when a contract is updated + event ContractUpdated(bytes5 indexed name, address indexed contractAddress); + + /// @notice Emitted when a contract is deregistered + event ContractDeregistered(bytes5 indexed name); + + // ========= ERRORS ========= // + + /// @notice The provided name is invalid + error Params_InvalidName(); + + /// @notice The provided address is invalid + error Params_InvalidAddress(); + + /// @notice The provided contract name is already registered + error Params_ContractAlreadyRegistered(); + + /// @notice The provided contract name is not registered + error Params_ContractNotRegistered(); + + // ========= STATE ========= // + + /// @notice Stores the names of the registered immutable contracts + bytes5[] internal _immutableContractNames; + + /// @notice Stores the names of the registered contracts + bytes5[] internal _contractNames; + + /// @notice Mapping to store the immutable address of a contract + /// @dev The address of an immutable contract can be retrieved by `getImmutableContract()`, and the names of all immutable contracts can be retrieved by `getImmutableContractNames()`. + mapping(bytes5 => address) internal _immutableContracts; + + /// @notice Mapping to store the address of a contract + /// @dev The address of a registered contract can be retrieved by `getContract()`, and the names of all registered contracts can be retrieved by `getContractNames()`. + mapping(bytes5 => address) internal _contracts; + + // ========= REGISTRATION FUNCTIONS ========= // + + /// @notice Register an immutable contract name and address + /// @dev This function should be permissioned to prevent arbitrary contracts from being registered. + /// + /// @param name_ The name of the contract + /// @param contractAddress_ The address of the contract + function registerImmutableContract(bytes5 name_, address contractAddress_) external virtual; + + /// @notice Register a new contract name and address + /// @dev This function should be permissioned to prevent arbitrary contracts from being registered. + /// + /// @param name_ The name of the contract + /// @param contractAddress_ The address of the contract + function registerContract(bytes5 name_, address contractAddress_) external virtual; + + /// @notice Update the address of an existing contract name + /// @dev This function should be permissioned to prevent arbitrary contracts from being updated. + /// + /// @param name_ The name of the contract + /// @param contractAddress_ The address of the contract + function updateContract(bytes5 name_, address contractAddress_) external virtual; + + /// @notice Deregister an existing contract name + /// @dev This function should be permissioned to prevent arbitrary contracts from being deregistered. + /// + /// @param name_ The name of the contract + function deregisterContract(bytes5 name_) external virtual; + + // ========= VIEW FUNCTIONS ========= // + + /// @notice Get the address of a registered immutable contract + /// + /// @param name_ The name of the contract + /// @return The address of the contract + function getImmutableContract(bytes5 name_) external view virtual returns (address); + + /// @notice Get the address of a registered mutable contract + /// + /// @param name_ The name of the contract + /// @return The address of the contract + function getContract(bytes5 name_) external view virtual returns (address); + + /// @notice Get the names of all registered immutable contracts + /// + /// @return The names of all registered immutable contracts + function getImmutableContractNames() external view virtual returns (bytes5[] memory); + + /// @notice Get the names of all registered mutable contracts + /// + /// @return The names of all registered mutable contracts + function getContractNames() external view virtual returns (bytes5[] memory); +} diff --git a/src/policies/ContractRegistryAdmin.sol b/src/policies/ContractRegistryAdmin.sol new file mode 100644 index 000000000..d89f2cfeb --- /dev/null +++ b/src/policies/ContractRegistryAdmin.sol @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.15; + +import {ROLESv1} from "src/modules/ROLES/ROLES.v1.sol"; +import {RolesConsumer} from "src/modules/ROLES/OlympusRoles.sol"; +import {RGSTYv1} from "src/modules/RGSTY/RGSTY.v1.sol"; +import {Kernel, Policy, Keycode, toKeycode, Permissions} from "src/Kernel.sol"; + +/// @title ContractRegistryAdmin +/// @notice This policy is used to register and deregister contracts in the RGSTY module. +/// @dev This contract utilises the following roles: +/// - `contract_registry_admin`: Can register and deregister contracts +/// +/// This policy provides permissioned access to the state-changing functions on the RGSTY module. The view functions can be called directly on the module. +contract ContractRegistryAdmin is Policy, RolesConsumer { + // ============ ERRORS ============ // + + /// @notice Thrown when the address is invalid + error Params_InvalidAddress(); + + /// @notice Thrown when the contract is not activated as a policy + error OnlyPolicyActive(); + + // ============ STATE ============ // + + /// @notice The RGSTY module + /// @dev The value is set when the policy is activated + RGSTYv1 internal RGSTY; + + /// @notice The role for the contract registry admin + bytes32 public constant CONTRACT_REGISTRY_ADMIN_ROLE = "contract_registry_admin"; + + // ============ CONSTRUCTOR ============ // + + constructor(address kernel_) Policy(Kernel(kernel_)) { + // Validate that the kernel address is valid + if (kernel_ == address(0)) revert Params_InvalidAddress(); + } + + // ============ POLICY FUNCTIONS ============ // + + /// @inheritdoc Policy + function configureDependencies() external override returns (Keycode[] memory dependencies) { + dependencies = new Keycode[](2); + dependencies[0] = toKeycode("RGSTY"); + dependencies[1] = toKeycode("ROLES"); + + RGSTY = RGSTYv1(getModuleAddress(dependencies[0])); + ROLES = ROLESv1(getModuleAddress(dependencies[1])); + + // Verify the supported version + bytes memory expected = abi.encode([1, 1]); + (uint8 RGSTY_MAJOR, ) = RGSTY.VERSION(); + (uint8 ROLES_MAJOR, ) = ROLES.VERSION(); + if (RGSTY_MAJOR != 1 || ROLES_MAJOR != 1) revert Policy_WrongModuleVersion(expected); + + return dependencies; + } + + /// @inheritdoc Policy + function requestPermissions() + external + pure + override + returns (Permissions[] memory permissions) + { + Keycode rgstyKeycode = toKeycode("RGSTY"); + + permissions = new Permissions[](4); + permissions[0] = Permissions(rgstyKeycode, RGSTYv1.registerContract.selector); + permissions[1] = Permissions(rgstyKeycode, RGSTYv1.updateContract.selector); + permissions[2] = Permissions(rgstyKeycode, RGSTYv1.deregisterContract.selector); + permissions[3] = Permissions(rgstyKeycode, RGSTYv1.registerImmutableContract.selector); + + return permissions; + } + + /// @notice The version of the policy + function VERSION() external pure returns (uint8) { + return 1; + } + + // ============ MODIFIERS ============ // + + /// @notice Modifier to check that the contract is activated as a policy + modifier onlyPolicyActive() { + if (!kernel.isPolicyActive(this)) revert OnlyPolicyActive(); + _; + } + + // ============ ADMIN FUNCTIONS ============ // + + /// @notice Register an immutable contract in the contract registry + /// @dev This function will revert if: + /// - This contract is not activated as a policy + /// - The caller does not have the required role + /// - The RGSTY module reverts + /// + /// @param name_ The name of the contract + /// @param contractAddress_ The address of the contract + function registerImmutableContract( + bytes5 name_, + address contractAddress_ + ) external onlyPolicyActive onlyRole(CONTRACT_REGISTRY_ADMIN_ROLE) { + RGSTY.registerImmutableContract(name_, contractAddress_); + } + + /// @notice Register a contract in the contract registry + /// @dev This function will revert if: + /// - This contract is not activated as a policy + /// - The caller does not have the required role + /// - The RGSTY module reverts + /// + /// @param name_ The name of the contract + /// @param contractAddress_ The address of the contract + function registerContract( + bytes5 name_, + address contractAddress_ + ) external onlyPolicyActive onlyRole(CONTRACT_REGISTRY_ADMIN_ROLE) { + RGSTY.registerContract(name_, contractAddress_); + } + + /// @notice Update a contract in the contract registry + /// @dev This function will revert if: + /// - This contract is not activated as a policy + /// - The caller does not have the required role + /// - The RGSTY module reverts + /// + /// @param name_ The name of the contract + /// @param contractAddress_ The address of the contract + function updateContract( + bytes5 name_, + address contractAddress_ + ) external onlyPolicyActive onlyRole(CONTRACT_REGISTRY_ADMIN_ROLE) { + RGSTY.updateContract(name_, contractAddress_); + } + + /// @notice Deregister a contract in the contract registry + /// @dev This function will revert if: + /// - This contract is not activated as a policy + /// - The caller does not have the required role + /// - The RGSTY module reverts + /// + /// @param name_ The name of the contract + function deregisterContract( + bytes5 name_ + ) external onlyPolicyActive onlyRole(CONTRACT_REGISTRY_ADMIN_ROLE) { + RGSTY.deregisterContract(name_); + } +} diff --git a/src/policies/LoanConsolidator.sol b/src/policies/LoanConsolidator.sol new file mode 100644 index 000000000..885f9daf4 --- /dev/null +++ b/src/policies/LoanConsolidator.sol @@ -0,0 +1,905 @@ +// SPDX-License-Identifier: GLP-3.0 +pragma solidity ^0.8.15; + +import {IERC20} from "forge-std/interfaces/IERC20.sol"; +import {IERC4626} from "forge-std/interfaces/IERC4626.sol"; +import {Kernel, Keycode, toKeycode, Permissions, Policy} from "src/Kernel.sol"; + +import {CHREGv1} from "src/modules/CHREG/CHREG.v1.sol"; +import {ROLESv1} from "src/modules/ROLES/ROLES.v1.sol"; +import {TRSRYv1} from "src/modules/TRSRY/TRSRY.v1.sol"; +import {RGSTYv1} from "src/modules/RGSTY/RGSTY.v1.sol"; + +import {RolesConsumer} from "src/modules/ROLES/OlympusRoles.sol"; + +import {ReentrancyGuard} from "solmate/utils/ReentrancyGuard.sol"; + +import {IERC3156FlashBorrower} from "src/interfaces/maker-dao/IERC3156FlashBorrower.sol"; +import {IERC3156FlashLender} from "src/interfaces/maker-dao/IERC3156FlashLender.sol"; +import {IDaiUsdsMigrator} from "src/interfaces/maker-dao/IDaiUsdsMigrator.sol"; +import {Clearinghouse} from "src/policies/Clearinghouse.sol"; +import {Cooler} from "src/external/cooler/Cooler.sol"; +import {CoolerFactory} from "src/external/cooler/CoolerFactory.sol"; + +/// @title Loan Consolidator +/// @notice A policy that consolidates loans taken with a single Cooler contract into a single loan using Maker flashloans. +/// This policy can be used to consolidate loans within the same Clearinghouse, or from one Clearinghouse to another. +/// This also enables migration between debt denominated in different assets (such as DAI and USDS). +/// @dev This policy uses the `IERC3156FlashBorrower` interface to interact with Maker flashloans. +/// +/// This contract utilises the following roles: +/// - `loan_consolidator_admin`: Can set the fee percentage +/// - `emergency_shutdown`: Can activate and deactivate the contract +contract LoanConsolidator is IERC3156FlashBorrower, Policy, RolesConsumer, ReentrancyGuard { + // ========= ERRORS ========= // + + /// @notice Thrown when the caller is not the contract itself. + error OnlyThis(); + + /// @notice Thrown when the caller is not the flash lender. + error OnlyLender(); + + /// @notice Thrown when the caller is not the Cooler owner. + error OnlyCoolerOwner(); + + /// @notice Thrown when the contract is not active. + error OnlyConsolidatorActive(); + + /// @notice Thrown when the contract is not activated as a policy. + error OnlyPolicyActive(); + + /// @notice Thrown when the fee percentage is out of range. + /// @dev Valid values are 0 <= feePercentage <= 100e2 + error Params_FeePercentageOutOfRange(); + + /// @notice Thrown when the address is invalid. + error Params_InvalidAddress(); + + /// @notice Thrown when the caller attempts to consolidate too few cooler loans. The minimum is two. + error Params_InsufficientCoolerCount(); + + /// @notice Thrown when the Clearinghouse is not registered with the Bophades kernel + error Params_InvalidClearinghouse(); + + /// @notice Thrown when the Cooler is not created by the CoolerFactory for the specified Clearinghouse + error Params_InvalidCooler(); + + // ========= EVENTS ========= // + + /// @notice Emitted when the contract is activated + /// @dev Note that this is different to activation of the contract as a policy + event ConsolidatorActivated(); + + /// @notice Emitted when the contract is deactivated + /// @dev Note that this is different to deactivation of the contract as a policy + event ConsolidatorDeactivated(); + + /// @notice Emitted when the fee percentage is set + event FeePercentageSet(uint256 feePercentage); + + // ========= DATA STRUCTURES ========= // + + /// @notice Data structure used for flashloan parameters + struct FlashLoanData { + Clearinghouse clearinghouseFrom; + Clearinghouse clearinghouseTo; + Cooler coolerFrom; + Cooler coolerTo; + uint256[] ids; + uint256 principal; + uint256 interest; + uint256 protocolFee; + MigrationType migrationType; + IERC20 reserveFrom; + IERC20 reserveTo; + } + + enum MigrationType { + DAI_DAI, + USDS_USDS, + DAI_USDS, + USDS_DAI + } + + // ========= STATE ========= // + + /// @notice The Clearinghouse registry module + /// @dev The value is set when the policy is activated + CHREGv1 internal CHREG; + + /// @notice The treasury module + /// @dev The value is set when the policy is activated + TRSRYv1 internal TRSRY; + + /// @notice The contract registry module + /// @dev The value is set when the policy is activated + RGSTYv1 internal RGSTY; + + /// @notice The DAI token + /// @dev The value is set when the policy is activated + IERC20 internal DAI; + + /// @notice The USDS token + /// @dev The value is set when the policy is activated + IERC20 internal USDS; + + /// @notice The gOHM token + /// @dev The value is set when the policy is activated + IERC20 internal GOHM; + + /// @notice The DAI <> USDS Migrator + /// @dev The value is set when the policy is activated + IDaiUsdsMigrator internal MIGRATOR; + + /// @notice The ERC3156 flash loan provider + /// @dev The value is set when the policy is activated + IERC3156FlashLender internal FLASH; + + /// @notice The denominator for percentage calculations + uint256 public constant ONE_HUNDRED_PERCENT = 100e2; + + /// @notice Percentage of the debt to be paid as a fee + /// @dev In terms of `ONE_HUNDRED_PERCENT` + uint256 public feePercentage; + + /// @notice Whether the contract is active + /// @dev Note that this is different to the policy activation status + bool public consolidatorActive; + + /// @notice The role required to call admin functions + bytes32 public constant ROLE_ADMIN = "loan_consolidator_admin"; + + /// @notice The role required to call emergency shutdown functions + bytes32 public constant ROLE_EMERGENCY_SHUTDOWN = "emergency_shutdown"; + + // ========= CONSTRUCTOR ========= // + + /// @notice Constructor for the Loan Consolidator + /// @dev This function will revert if: + /// - The fee percentage is above `ONE_HUNDRED_PERCENT` + /// - The kernel address is zero + constructor(address kernel_, uint256 feePercentage_) Policy(Kernel(kernel_)) { + // Validation + if (feePercentage_ > ONE_HUNDRED_PERCENT) revert Params_FeePercentageOutOfRange(); + if (kernel_ == address(0)) revert Params_InvalidAddress(); + + // Store protocol data + feePercentage = feePercentage_; + + // Set the contract to be disabled by default + // It must be activated as a policy and activated before being used + consolidatorActive = false; + + // Emit events + emit FeePercentageSet(feePercentage); + emit ConsolidatorActivated(); + } + + /// @inheritdoc Policy + function configureDependencies() external override returns (Keycode[] memory dependencies) { + dependencies = new Keycode[](4); + dependencies[0] = toKeycode("CHREG"); + dependencies[1] = toKeycode("RGSTY"); + dependencies[2] = toKeycode("ROLES"); + dependencies[3] = toKeycode("TRSRY"); + + // Populate module dependencies + CHREG = CHREGv1(getModuleAddress(dependencies[0])); + RGSTY = RGSTYv1(getModuleAddress(dependencies[1])); + ROLES = ROLESv1(getModuleAddress(dependencies[2])); + TRSRY = TRSRYv1(getModuleAddress(dependencies[3])); + + // Ensure Modules are using the expected major version. + // Modules should be sorted in alphabetical order. + bytes memory expected = abi.encode([1, 1, 1, 1]); + (uint8 CHREG_MAJOR, ) = CHREG.VERSION(); + (uint8 RGSTY_MAJOR, ) = RGSTY.VERSION(); + (uint8 ROLES_MAJOR, ) = ROLES.VERSION(); + (uint8 TRSRY_MAJOR, ) = TRSRY.VERSION(); + if (CHREG_MAJOR != 1 || RGSTY_MAJOR != 1 || ROLES_MAJOR != 1 || TRSRY_MAJOR != 1) + revert Policy_WrongModuleVersion(expected); + + // Populate variables + // This function will be called whenever a contract is registered or deregistered, which enables caching of the values + // Token contract addresses are immutable + DAI = IERC20(RGSTY.getImmutableContract("dai")); + USDS = IERC20(RGSTY.getImmutableContract("usds")); + GOHM = IERC20(RGSTY.getImmutableContract("gohm")); + // Utility contract addresses are mutable + FLASH = IERC3156FlashLender(RGSTY.getContract("flash")); + MIGRATOR = IDaiUsdsMigrator(RGSTY.getContract("dmgtr")); + + return dependencies; + } + + /// @inheritdoc Policy + /// @dev This policy does not require any permissions + function requestPermissions() external pure override returns (Permissions[] memory requests) { + requests = new Permissions[](0); + + return requests; + } + + // ========= OPERATION ========= // + + /// @notice Consolidate loans (taken with a single Cooler contract) into a single loan by using flashloans. + /// + /// Unlike `consolidateWithNewOwner()`, the owner of the new Cooler must be the same as the Cooler being repaid. + /// + /// The caller will be required to provide additional funds to cover accrued interest on the Cooler loans and the lender and protocol fees (if applicable). Use the `requiredApprovals()` function to determine the amount of funds and approvals required. + /// + /// It is expected that the caller will have already provided approval for this contract to spend the required tokens. See `requiredApprovals()` for more details. + /// + /// @dev This function will revert if: + /// - The caller is not the 'coolerFrom' and 'coolerTo' owner. + /// - The caller has not approved this contract to spend the reserve token of `clearinghouseTo_` in order to pay the interest, lender and protocol fees. + /// - The caller has not approved this contract to spend the gOHM escrowed by `coolerFrom_`. + /// - `clearinghouseFrom_` or `clearinghouseTo_` is not registered with the Clearinghouse registry. + /// - `coolerFrom_` or `coolerTo_` is not a valid Cooler for the respective Clearinghouse. + /// - Consolidation is taking place within the same Cooler, and less than two loans are being consolidated. + /// - The available funds are less than the required flashloan amount. + /// - The contract is not active. + /// - The contract has not been activated as a policy. + /// - Re-entrancy is detected. + /// + /// @param clearinghouseFrom_ Olympus Clearinghouse that issued the existing loans. + /// @param clearinghouseTo_ Olympus Clearinghouse to be used to issue the consolidated loan. + /// @param coolerFrom_ Cooler from which the loans will be consolidated. + /// @param coolerTo_ Cooler to which the loans will be consolidated + /// @param ids_ Array containing the ids of the loans to be consolidated. + function consolidate( + address clearinghouseFrom_, + address clearinghouseTo_, + address coolerFrom_, + address coolerTo_, + uint256[] calldata ids_ + ) public onlyPolicyActive onlyConsolidatorActive nonReentrant { + // Ensure `msg.sender` is allowed to spend cooler funds on behalf of this contract + if (Cooler(coolerFrom_).owner() != msg.sender || Cooler(coolerTo_).owner() != msg.sender) + revert OnlyCoolerOwner(); + + _consolidateWithFlashLoan( + clearinghouseFrom_, + clearinghouseTo_, + coolerFrom_, + coolerTo_, + ids_ + ); + } + + /// @notice Consolidate loans (taken with a single Cooler contract) into a single loan by using flashloans. + /// + /// Unlike `consolidate()`, the owner of the new Cooler can be different from the Cooler being repaid. + /// + /// The caller will be required to provide additional funds to cover accrued interest on the Cooler loans and the lender and protocol fees (if applicable). Use the `requiredApprovals()` function to determine the amount of funds and approvals required. + /// + /// It is expected that the caller will have already provided approval for this contract to spend the required tokens. See `requiredApprovals()` for more details. + /// + /// @dev This function will revert if: + /// - The caller is not the `coolerFrom_` owner. + /// - `coolerFrom_` is the same as `coolerTo_` (in which case `consolidate()` should be used). + /// - The owner of `coolerFrom_` is the same as `coolerTo_` (in which case `consolidate()` should be used). + /// - The caller has not approved this contract to spend the reserve token of `clearinghouseTo_` in order to pay the interest, lender and protocol fees. + /// - The caller has not approved this contract to spend the gOHM escrowed by the target Cooler. + /// - `clearinghouseFrom_` or `clearinghouseTo_` is not registered with the Clearinghouse registry. + /// - `coolerFrom_` or `coolerTo_` is not a valid Cooler for the respective Clearinghouse. + /// - Consolidation is taking place within the same Cooler, and less than two loans are being consolidated. + /// - The available funds are less than the required flashloan amount. + /// - The contract is not active. + /// - The contract has not been activated as a policy. + /// - Re-entrancy is detected. + /// + /// @param clearinghouseFrom_ Olympus Clearinghouse that issued the existing loans. + /// @param clearinghouseTo_ Olympus Clearinghouse to be used to issue the consolidated loan. + /// @param coolerFrom_ Cooler from which the loans will be consolidated. + /// @param coolerTo_ Cooler to which the loans will be consolidated + /// @param ids_ Array containing the ids of the loans to be consolidated. + function consolidateWithNewOwner( + address clearinghouseFrom_, + address clearinghouseTo_, + address coolerFrom_, + address coolerTo_, + uint256[] calldata ids_ + ) public onlyPolicyActive onlyConsolidatorActive nonReentrant { + // Ensure `msg.sender` is allowed to spend cooler funds on behalf of this contract + if (Cooler(coolerFrom_).owner() != msg.sender) revert OnlyCoolerOwner(); + + // Ensure that the owner of the coolerFrom_ is not the same as coolerTo_ + // This also implicitly checks that the coolers must be different, ie. can't operate on the same Cooler + if (Cooler(coolerTo_).owner() == msg.sender) revert Params_InvalidCooler(); + + _consolidateWithFlashLoan( + clearinghouseFrom_, + clearinghouseTo_, + coolerFrom_, + coolerTo_, + ids_ + ); + } + + /// @notice Internal logic for loan consolidation + /// @dev Utilized by `consolidate()` and `consolidateWithNewOwner()` + /// + /// This function assumes: + /// - The calling external-facing function has checked that the caller is permitted to operate on `coolerFrom_`. + /// + /// @param clearinghouseFrom_ Olympus Clearinghouse that issued the existing loans. + /// @param clearinghouseTo_ Olympus Clearinghouse to be used to issue the consolidated loan. + /// @param coolerFrom_ Cooler from which the loans will be consolidated. + /// @param coolerTo_ Cooler to which the loans will be consolidated + /// @param ids_ Array containing the ids of the loans to be consolidated. + function _consolidateWithFlashLoan( + address clearinghouseFrom_, + address clearinghouseTo_, + address coolerFrom_, + address coolerTo_, + uint256[] calldata ids_ + ) internal { + // Validate that the Clearinghouses are registered with the Bophades kernel + if (!_isValidClearinghouse(clearinghouseFrom_) || !_isValidClearinghouse(clearinghouseTo_)) + revert Params_InvalidClearinghouse(); + + // Validate that the previous cooler was created by the CoolerFactory for the Clearinghouse + if ( + !_isValidCooler(clearinghouseFrom_, coolerFrom_) || + !_isValidCooler(clearinghouseTo_, coolerTo_) + ) revert Params_InvalidCooler(); + + // If consolidating within the same Cooler, ensure that at least two loans are being consolidated + if (coolerFrom_ == coolerTo_ && ids_.length < 2) revert Params_InsufficientCoolerCount(); + + // If consolidating across different Coolers, ensure that at least one loan is being consolidated + if (coolerFrom_ != coolerTo_ && ids_.length == 0) revert Params_InsufficientCoolerCount(); + + // Get the migration type and reserve tokens + (MigrationType migrationType, IERC20 reserveFrom, IERC20 reserveTo) = _getMigrationType( + clearinghouseFrom_, + clearinghouseTo_ + ); + + (uint256 flashloanAmount, FlashLoanData memory flashloanParams) = _getFlashloanParameters( + Clearinghouse(clearinghouseFrom_), + Clearinghouse(clearinghouseTo_), + Cooler(coolerFrom_), + Cooler(coolerTo_), + ids_, + migrationType, + reserveFrom, + reserveTo + ); + + // Transfer in the interest and fees from the caller, in terms of the reserveTo token + // The Cooler owner will supply the principal, so the balance needs to be provided by the caller + { + uint256 lenderFee = FLASH.flashFee(address(DAI), flashloanAmount); + reserveTo.transferFrom( + msg.sender, + address(this), + flashloanParams.interest + flashloanParams.protocolFee + lenderFee + ); + } + + // Take flashloan + // This will trigger the `onFlashLoan` function after the flashloan amount has been transferred to this contract + FLASH.flashLoan(this, address(DAI), flashloanAmount, abi.encode(flashloanParams)); + // State: + // - reserveFrom: 0 + // - reserveTo: 0 + // - gOHM: 0 + + // This shouldn't happen, but transfer any leftover funds back to the sender + uint256 daiBalanceAfter = DAI.balanceOf(address(this)); + if (daiBalanceAfter > 0) { + DAI.transfer(msg.sender, daiBalanceAfter); + } + uint256 usdsBalanceAfter = USDS.balanceOf(address(this)); + if (usdsBalanceAfter > 0) { + USDS.transfer(msg.sender, usdsBalanceAfter); + } + } + + /// @inheritdoc IERC3156FlashBorrower + /// @dev This function reverts if: + /// - The caller is not the flash loan provider + /// - The initiator is not this contract + function onFlashLoan( + address initiator_, + address, // flashloan token is only DAI + uint256 amount_, + uint256 lenderFee_, + bytes calldata params_ + ) external override returns (bytes32) { + FlashLoanData memory flashLoanData = abi.decode(params_, (FlashLoanData)); + + // perform sanity checks + if (msg.sender != address(FLASH)) revert OnlyLender(); + if (initiator_ != address(this)) revert OnlyThis(); + + // Assumptions: + // - The flashloan provider has transferred amount_ in DAI, which is equal to the principal + // - This contract has transferred from the caller the interest, lender fee and protocol fee to this contract + + // If clearinghouseFrom is in USDS, then we need to convert the flashloan DAI to USDS in order to repay the principal + if ( + flashLoanData.migrationType == MigrationType.USDS_DAI || + flashLoanData.migrationType == MigrationType.USDS_USDS + ) { + DAI.approve(address(MIGRATOR), flashLoanData.principal); + MIGRATOR.daiToUsds(address(this), flashLoanData.principal); + } + + // Ensure that the interest transferred from the caller is in terms of the reserveFrom token + // Interest was collected in terms of the reserveTo token + if (flashLoanData.migrationType == MigrationType.USDS_DAI) { + DAI.approve(address(MIGRATOR), flashLoanData.interest); + MIGRATOR.daiToUsds(address(this), flashLoanData.interest); + } + if (flashLoanData.migrationType == MigrationType.DAI_USDS) { + USDS.approve(address(MIGRATOR), flashLoanData.interest); + MIGRATOR.usdsToDai(address(this), flashLoanData.interest); + } + + // Grant approval to the Cooler to spend the debt + flashLoanData.reserveFrom.approve( + address(flashLoanData.coolerFrom), + flashLoanData.principal + flashLoanData.interest + ); + // Iterate over all batches, repay the debt + _repayDebtForLoans(address(flashLoanData.coolerFrom), flashLoanData.ids); + // State: + // - reserveFrom: reduced by principal and interest, should be 0 + // - reserveTo: no change, balance is lender fee + protocol fee + // - gOHM: no change, should be 0 + + // Calculate the amount of collateral that will be needed for the consolidated loan + // This is performed on the destination Clearinghouse, since it will be the one issuing the consolidated loan + uint256 consolidatedLoanCollateral = flashLoanData.clearinghouseTo.getCollateralForLoan( + flashLoanData.principal + ); + + // Transfer the collateral from the cooler owner to this contract + GOHM.transferFrom( + flashLoanData.coolerFrom.owner(), + address(this), + consolidatedLoanCollateral + ); + // State: + // - reserveFrom: no change + // - reserveTo: no change + // - gOHM: increased by consolidatedLoanCollateral + + // Take a new Cooler loan for the principal required + GOHM.approve(address(flashLoanData.clearinghouseTo), consolidatedLoanCollateral); + flashLoanData.clearinghouseTo.lendToCooler(flashLoanData.coolerTo, flashLoanData.principal); + // State: + // - reserveFrom: no change + // - reserveTo: no change, as the cooler owner received it + // - gOHM: reduced by the collateral used for the consolidated loan. gOHM balance in this contract is now 0. + + // The coolerTo owner will receive `principal` quantity of `reserveTo` tokens for the consolidated loan + // Transfer the principal amount in terms of `reserveTo`. The lender fee and protocol fee have already been transferred to this contract. + // Approval must have already been granted by the Cooler owner + flashLoanData.reserveTo.transferFrom( + flashLoanData.coolerTo.owner(), + address(this), + flashLoanData.principal + ); + // State: + // - reserveFrom: no change + // - reserveTo: increased by the loan principal, balance is principal + lender fee + protocol fee + // - gOHM: no change, 0 + + // The flashloan needs to be repaid in DAI + // Convert the proceeds to DAI if necessary + if ( + flashLoanData.migrationType == MigrationType.DAI_USDS || + flashLoanData.migrationType == MigrationType.USDS_USDS + ) { + USDS.approve(address(MIGRATOR), amount_ + lenderFee_); + MIGRATOR.usdsToDai(address(this), amount_ + lenderFee_); + } + + // Approve the flash loan provider to collect the flashloan amount and fee + DAI.approve(address(FLASH), amount_ + lenderFee_); + + // Pay protocol fee, which would be left over + if (flashLoanData.protocolFee != 0) + flashLoanData.reserveTo.transfer(address(TRSRY), flashLoanData.protocolFee); + // State: + // - reserveFrom: no change + // - reserveTo: reduced by the protocol fee, balance should be 0 + // - gOHM: no change, balance should be 0 + + return keccak256("ERC3156FlashBorrower.onFlashLoan"); + } + + // ========= ADMIN ========= // + + /// @notice Set the fee percentage + /// @dev This function will revert if: + /// - The contract has not been activated as a policy. + /// - The fee percentage is above `ONE_HUNDRED_PERCENT` + /// - The caller does not have the `ROLE_ADMIN` role + function setFeePercentage( + uint256 feePercentage_ + ) external onlyPolicyActive onlyRole(ROLE_ADMIN) { + if (feePercentage_ > ONE_HUNDRED_PERCENT) revert Params_FeePercentageOutOfRange(); + + feePercentage = feePercentage_; + emit FeePercentageSet(feePercentage_); + } + + /// @notice Activate the contract + /// @dev This function will revert if: + /// - The contract has not been activated as a policy. + /// - The caller does not have the `ROLE_EMERGENCY_SHUTDOWN` role + /// + /// If the contract is already active, it will do nothing. + function activate() external onlyPolicyActive onlyRole(ROLE_EMERGENCY_SHUTDOWN) { + // Skip if already activated + if (consolidatorActive) return; + + consolidatorActive = true; + emit ConsolidatorActivated(); + } + + /// @notice Deactivate the contract + /// @dev This function will revert if: + /// - The contract has not been activated as a policy. + /// - The caller does not have the `ROLE_EMERGENCY_SHUTDOWN` role + /// + /// If the contract is already deactivated, it will do nothing. + function deactivate() external onlyPolicyActive onlyRole(ROLE_EMERGENCY_SHUTDOWN) { + // Skip if already deactivated + if (!consolidatorActive) return; + + consolidatorActive = false; + emit ConsolidatorDeactivated(); + } + + /// @notice Modifier to check that the contract is active + modifier onlyConsolidatorActive() { + if (!consolidatorActive) revert OnlyConsolidatorActive(); + _; + } + + /// @notice Modifier to check that the contract is activated as a policy + modifier onlyPolicyActive() { + if (!kernel.isPolicyActive(this)) revert OnlyPolicyActive(); + _; + } + + // ========= FUNCTIONS ========= // + + /// @notice Get the total principal and interest for a given set of loans + /// + /// @param cooler_ Cooler contract that issued the loans + /// @param ids_ Array of loan ids to be consolidated + /// @return totalPrincipal_ Total principal + /// @return totalInterest_ Total interest + function _getDebtForLoans( + address cooler_, + uint256[] calldata ids_ + ) internal view returns (uint256, uint256) { + uint256 totalInterest; + uint256 totalPrincipal; + + uint256 numLoans = ids_.length; + for (uint256 i; i < numLoans; i++) { + (, uint256 principal, uint256 interestDue, , , , , ) = Cooler(cooler_).loans(ids_[i]); + totalInterest += interestDue; + totalPrincipal += principal; + } + + return (totalPrincipal, totalInterest); + } + + /// @notice Repay the debt for a given set of loans and collect the collateral. + /// @dev This function assumes: + /// - The cooler owner has granted approval for this contract to spend the gOHM collateral + /// + /// @param cooler_ Cooler contract that issued the loans + /// @param ids_ Array of loan ids to be repaid + function _repayDebtForLoans(address cooler_, uint256[] memory ids_) internal { + uint256 totalCollateral; + Cooler cooler = Cooler(cooler_); + + // Iterate over all loans in the cooler and repay + uint256 numLoans = ids_.length; + for (uint256 i; i < numLoans; i++) { + (, uint256 principal, uint256 interestDue, , , , , ) = cooler.loans(ids_[i]); + + // Repay. This also releases the collateral to the owner. + uint256 collateralReturned = cooler.repayLoan(ids_[i], principal + interestDue); + totalCollateral += collateralReturned; + } + + // Upon repayment, the collateral is released to the owner + // After this function concludes, the contract needs to transfer the collateral to itself + } + + function _isValidClearinghouse(address clearinghouse_) internal view returns (bool) { + // We check against the registry (not just active), as repayments are still allowed when a Clearinghouse is deactivated + uint256 registryCount = CHREG.registryCount(); + bool found; + for (uint256 i; i < registryCount; i++) { + if (CHREG.registry(i) == clearinghouse_) { + found = true; + break; + } + } + + return found; + } + + /// @notice Check if a given cooler was created by the CoolerFactory for a Clearinghouse + /// @dev This function assumes that the authenticity of the Clearinghouse is already verified + /// + /// @param clearinghouse_ Clearinghouse contract + /// @param cooler_ Cooler contract + /// @return bool Whether the cooler was created by the CoolerFactory for the Clearinghouse + function _isValidCooler(address clearinghouse_, address cooler_) internal view returns (bool) { + Clearinghouse clearinghouse = Clearinghouse(clearinghouse_); + CoolerFactory coolerFactory = CoolerFactory(clearinghouse.factory()); + + return coolerFactory.created(cooler_); + } + + /// @notice Get the reserve token for a given Clearinghouse + /// @dev This function will revert if the reserve token cannot be determined + /// + /// @param clearinghouse_ Clearinghouse contract + /// @return address Reserve token + function _getClearinghouseReserveToken(address clearinghouse_) internal view returns (address) { + // Clearinghouse v1, v1.1 has a `dai()` function + // Perform a low-level call to check if it exists + (bool success, bytes memory data) = address(clearinghouse_).staticcall( + abi.encodeWithSignature("dai()") + ); + if (success) { + return abi.decode(data, (address)); + } + + // Clearinghouse v2 has a `reserve()` function + // Perform a low-level call to check if it exists + (success, data) = address(clearinghouse_).staticcall(abi.encodeWithSignature("reserve()")); + if (success) { + return abi.decode(data, (address)); + } + + revert Params_InvalidClearinghouse(); + } + + /// @notice Get the migration type for a given pair of Clearinghouses + /// @dev This function will revert if the migration type cannot be determined + /// + /// @param clearinghouseFrom_ Clearinghouse that issued the existing loans + /// @param clearinghouseTo_ Clearinghouse to be used to issue the consolidated loan + /// @return migrationType Migration type + /// @return reserveFrom Reserve token for the existing loans + /// @return reserveTo Reserve token for the consolidated loan + function _getMigrationType( + address clearinghouseFrom_, + address clearinghouseTo_ + ) internal view returns (MigrationType migrationType, IERC20 reserveFrom, IERC20 reserveTo) { + // Determine the reserve token for each Clearinghouse + address reserveFromAddress = _getClearinghouseReserveToken(clearinghouseFrom_); + address reserveToAddress = _getClearinghouseReserveToken(clearinghouseTo_); + reserveFrom = IERC20(reserveFromAddress); + reserveTo = IERC20(reserveToAddress); + + // DAI, no migration + if (reserveFromAddress == address(DAI) && reserveFromAddress == reserveToAddress) { + return (MigrationType.DAI_DAI, reserveFrom, reserveTo); + } + + // USDS, no migration + if (reserveFromAddress == address(USDS) && reserveFromAddress == reserveToAddress) { + return (MigrationType.USDS_USDS, reserveFrom, reserveTo); + } + + // DAI -> USDS migration + if (reserveFromAddress == address(DAI) && reserveToAddress == address(USDS)) { + return (MigrationType.DAI_USDS, reserveFrom, reserveTo); + } + + // USDS -> DAI migration + if (reserveFromAddress == address(USDS) && reserveToAddress == address(DAI)) { + return (MigrationType.USDS_DAI, reserveFrom, reserveTo); + } + + // Otherwise it is unsupported + revert Params_InvalidClearinghouse(); + } + + /// @notice Assembles the parameters for a flashloan + /// + /// @param clearinghouseFrom_ Clearinghouse that issued the existing loans + /// @param clearinghouseTo_ Clearinghouse to be used to issue the consolidated loan + /// @param coolerFrom_ Cooler contract that issued the existing loans + /// @param coolerTo_ Cooler contract to be used to issue the consolidated loan + /// @param ids_ Array of loan ids to be consolidated + /// @param migrationType_ Migration type + /// @param reserveFrom_ Reserve token for the existing loans + /// @param reserveTo_ Reserve token for the consolidated loan + /// @return flashloanAmount Amount of the flashloan + /// @return flashloanParams Flashloan parameters + function _getFlashloanParameters( + Clearinghouse clearinghouseFrom_, + Clearinghouse clearinghouseTo_, + Cooler coolerFrom_, + Cooler coolerTo_, + uint256[] calldata ids_, + MigrationType migrationType_, + IERC20 reserveFrom_, + IERC20 reserveTo_ + ) internal view returns (uint256 flashloanAmount, FlashLoanData memory flashloanParams) { + // Cache principal and interest + (uint256 totalPrincipal, uint256 totalInterest) = _getDebtForLoans( + address(coolerFrom_), + ids_ + ); + + uint256 protocolFee = getProtocolFee(totalPrincipal + totalInterest); + + // The flashloan amount is in DAI. This assumes a 1:1 exchange rate. + // The flashloan amount is the total principal, without any interest + // This is because the interest is paid by the caller, not the flashloan provider + flashloanAmount = totalPrincipal; + + flashloanParams = FlashLoanData({ + clearinghouseFrom: clearinghouseFrom_, + clearinghouseTo: clearinghouseTo_, + coolerFrom: coolerFrom_, + coolerTo: coolerTo_, + ids: ids_, + principal: totalPrincipal, + interest: totalInterest, + protocolFee: protocolFee, + migrationType: migrationType_, + reserveFrom: reserveFrom_, + reserveTo: reserveTo_ + }); + + return (flashloanAmount, flashloanParams); + } + + // ========= AUX FUNCTIONS ========= // + + /// @notice View function to compute the protocol fee for a given total debt. + function getProtocolFee(uint256 totalDebt_) public view returns (uint256) { + return (totalDebt_ * feePercentage) / ONE_HUNDRED_PERCENT; + } + + /// @notice View function to compute the required approval amounts that the owner of a given Cooler + /// must give to this contract in order to consolidate the loans. + /// + /// @dev This function will revert if: + /// - The contract has not been activated as a policy. + /// + /// @param clearinghouseTo_ Clearinghouse contract used to issue the consolidated loan. + /// @param coolerFrom_ Cooler contract that issued the loans. + /// @param ids_ Array of loan ids to be consolidated. + /// @return owner Owner of the Cooler (address that should grant the approval). + /// @return gOhmAmount Amount of gOHM to be approved by the Cooler owner. + /// @return reserveTo Token that the approval is in terms of + /// @return ownerReserveTo Amount of `reserveTo` to be approved by the Cooler owner. This will be the principal of the consolidated loan. + /// @return callerReserveTo Amount of `reserveTo` that the caller will need to provide. + function requiredApprovals( + address clearinghouseTo_, + address coolerFrom_, + uint256[] calldata ids_ + ) external view onlyPolicyActive returns (address, uint256, address, uint256, uint256) { + // Cache the total principal and interest + (uint256 totalPrincipal, uint256 totalInterest) = _getDebtForLoans( + address(coolerFrom_), + ids_ + ); + + uint256 totalFees; + { + uint256 protocolFee = getProtocolFee(totalPrincipal + totalInterest); + uint256 lenderFee = FLASH.flashFee(address(DAI), totalPrincipal); + + totalFees = totalInterest + lenderFee + protocolFee; + } + + // Calculate the collateral required for the consolidated loan principal + uint256 consolidatedLoanCollateral = Clearinghouse(clearinghouseTo_).getCollateralForLoan( + totalPrincipal + ); + + return ( + Cooler(coolerFrom_).owner(), + consolidatedLoanCollateral, + _getClearinghouseReserveToken(clearinghouseTo_), + totalPrincipal, + totalFees + ); + } + + /// @notice Calculates the collateral required to consolidate a set of loans. + /// @dev Due to rounding, the collateral required for the consolidated loan may be greater than the collateral of the loans being consolidated. + /// This function calculates the additional collateral required. + /// + /// @param clearinghouse_ Clearinghouse contract used to issue the consolidated loan. + /// @param cooler_ Cooler contract that issued the loans. + /// @param ids_ Array of loan ids to be consolidated. + /// @return consolidatedLoanCollateral Collateral required for the consolidated loan. + /// @return existingLoanCollateral Collateral of the existing loans. + /// @return additionalCollateral Additional collateral required to consolidate the loans. This will need to be supplied by the Cooler owner. + function collateralRequired( + address clearinghouse_, + address cooler_, + uint256[] memory ids_ + ) + public + view + returns ( + uint256 consolidatedLoanCollateral, + uint256 existingLoanCollateral, + uint256 additionalCollateral + ) + { + if (ids_.length == 0) revert Params_InsufficientCoolerCount(); + + // Calculate the total principal of the existing loans + uint256 totalPrincipal; + for (uint256 i; i < ids_.length; i++) { + (, uint256 principal, , uint256 collateral, , , , ) = Cooler(cooler_).loans(ids_[i]); + totalPrincipal += principal; + existingLoanCollateral += collateral; + } + + // Calculate the collateral required for the consolidated loan + consolidatedLoanCollateral = Clearinghouse(clearinghouse_).getCollateralForLoan( + totalPrincipal + ); + + // Calculate the additional collateral required + if (consolidatedLoanCollateral > existingLoanCollateral) { + additionalCollateral = consolidatedLoanCollateral - existingLoanCollateral; + } + + return (consolidatedLoanCollateral, existingLoanCollateral, additionalCollateral); + } + + /// @notice View function to compute the funds required to consolidate a set of loans. + /// The sum of the values must be held in the caller's wallet, in terms of the reserve token. + /// + /// @param clearinghouseTo_ Clearinghouse contract to be used to issue the consolidated loan. + /// @param coolerFrom_ Cooler contract that issued the loans. + /// @param ids_ Array of loan ids to be consolidated. + /// @return reserveTo Token the fund amounts are in terms of + /// @return interest Total interest + /// @return lenderFee Lender fee + /// @return protocolFee Protocol fee + function fundsRequired( + address clearinghouseTo_, + address coolerFrom_, + uint256[] calldata ids_ + ) + public + view + onlyPolicyActive + returns (address reserveTo, uint256 interest, uint256 lenderFee, uint256 protocolFee) + { + (uint256 totalPrincipal, uint256 totalInterest) = _getDebtForLoans( + address(coolerFrom_), + ids_ + ); + reserveTo = _getClearinghouseReserveToken(clearinghouseTo_); + protocolFee = getProtocolFee(totalPrincipal + totalInterest); + interest = totalInterest; + lenderFee = FLASH.flashFee(address(DAI), totalPrincipal); + + return (reserveTo, interest, lenderFee, protocolFee); + } + + /// @notice Version of the contract + /// + /// @return Version number + function VERSION() external pure returns (uint256) { + return 4; + } +} diff --git a/src/proposals/ContractRegistryProposal.sol b/src/proposals/ContractRegistryProposal.sol new file mode 100644 index 000000000..cb821b782 --- /dev/null +++ b/src/proposals/ContractRegistryProposal.sol @@ -0,0 +1,227 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +// OCG Proposal Simulator +import {Addresses} from "proposal-sim/addresses/Addresses.sol"; +import {GovernorBravoProposal} from "proposal-sim/proposals/OlympusGovernorBravoProposal.sol"; +// Interfaces +import {IERC20} from "forge-std/interfaces/IERC20.sol"; +import {IERC4626} from "forge-std/interfaces/IERC4626.sol"; +// Olympus Kernel, Modules, and Policies +import {Kernel, Actions, toKeycode} from "src/Kernel.sol"; +import {ROLESv1} from "src/modules/ROLES/ROLES.v1.sol"; +import {RolesAdmin} from "src/policies/RolesAdmin.sol"; +import {GovernorBravoDelegate} from "src/external/governance/GovernorBravoDelegate.sol"; +import {ContractRegistryAdmin} from "src/policies/ContractRegistryAdmin.sol"; +import {RGSTYv1} from "src/modules/RGSTY/RGSTY.v1.sol"; + +// Script +import {ProposalScript} from "./ProposalScript.sol"; + +/// @notice Activates the contract registry module and associated configuration policy. +contract ContractRegistryProposal is GovernorBravoProposal { + Kernel internal _kernel; + + // Immutable contract addresses + address public constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F; + address public constant SDAI = 0x83F20F44975D03b1b09e64809B757c47f942BEeA; + address public constant USDS = 0xdC035D45d973E3EC169d2276DDab16f1e407384F; + address public constant SUSDS = 0xa3931d71877C0E7a3148CB7Eb4463524FEc27fbD; + address public constant GOHM = 0x0ab87046fBb341D058F17CBC4c1133F25a20a52f; + address public constant OHM = 0x64aa3364F17a4D01c6f1751Fd97C2BD3D7e7f1D5; + + // Mutable contract addresses + address public constant FLASH_LENDER = 0x60744434d6339a6B27d73d9Eda62b6F66a0a04FA; + address public constant DAI_USDS_MIGRATOR = 0x3225737a9Bbb6473CB4a45b7244ACa2BeFdB276A; + + // Returns the id of the proposal. + function id() public pure override returns (uint256) { + return 6; + } + + // Returns the name of the proposal. + function name() public pure override returns (string memory) { + return "Contract Registry Activation"; + } + + // Provides a brief description of the proposal. + function description() public pure override returns (string memory) { + return + string.concat( + "# Contract Registry Activation\n", + "\n", + "This proposal activates the RGSTY module (and associated ContractRegistryAdmin configuration policy).\n", + "\n", + "The RGSTY module is used to register commonly-used addresses that can be referenced by other contracts. These addresses are marked as either mutable or immutable.\n", + "\n", + "The ContractRegistryAdmin policy is used to manage the addresses registered in the RGSTY module.\n", + "\n", + "The RGSTY module will be used by the LoanConsolidator policy to lookup contract addresses. In order to roll-out the improved LoanConsolidator, this proposal must be executed first.\n", + "\n", + "## Resources\n", + "\n", + "- [View the audit report here](https://storage.googleapis.com/olympusdao-landing-page-reports/audits/2024_10_LoanConsolidator_Audit.pdf)\n", + "- [View the pull request here](https://github.com/OlympusDAO/olympus-v3/pull/19)\n", + "\n", + "## Assumptions\n", + "\n", + "- The RGSTY module has been deployed and activated as a module by the DAO MS.\n", + "- The ContractRegistryAdmin policy has been deployed and activated as a policy by the DAO MS.\n", + "\n", + "## Proposal Steps\n", + "\n", + "1. Grant the `contract_registry_admin` role to the OCG Timelock.\n", + "2. Register immutable addresses for DAI, SDAI, USDS, SUSDS, GOHM and OHM.\n", + "3. Register mutable addresses for the Flash Lender and DAI-USDS Migrator contracts.\n" + ); + } + + // No deploy actions needed + function _deploy(Addresses addresses, address) internal override { + // Cache the kernel address in state + _kernel = Kernel(addresses.getAddress("olympus-kernel")); + } + + function _afterDeploy(Addresses addresses, address deployer) internal override {} + + // Sets up actions for the proposal + function _build(Addresses addresses) internal override { + address rolesAdmin = addresses.getAddress("olympus-policy-roles-admin"); + address timelock = addresses.getAddress("olympus-timelock"); + address contractRegistryAdmin = addresses.getAddress( + "olympus-policy-contract-registry-admin" + ); + + // STEP 1: Grant the `contract_registry_admin` role to the OCG Timelock + _pushAction( + rolesAdmin, + abi.encodeWithSelector( + RolesAdmin.grantRole.selector, + bytes32("contract_registry_admin"), + timelock + ), + "Grant contract_registry_admin to Timelock" + ); + + // STEP 2: Register immutable addresses for DAI, SDAI, USDS, SUSDS, GOHM and OHM + _pushAction( + contractRegistryAdmin, + abi.encodeWithSelector( + ContractRegistryAdmin.registerImmutableContract.selector, + bytes5("dai"), + DAI + ), + "Register immutable DAI address" + ); + _pushAction( + contractRegistryAdmin, + abi.encodeWithSelector( + ContractRegistryAdmin.registerImmutableContract.selector, + bytes5("sdai"), + SDAI + ), + "Register immutable SDAI address" + ); + _pushAction( + contractRegistryAdmin, + abi.encodeWithSelector( + ContractRegistryAdmin.registerImmutableContract.selector, + bytes5("usds"), + USDS + ), + "Register immutable USDS address" + ); + _pushAction( + contractRegistryAdmin, + abi.encodeWithSelector( + ContractRegistryAdmin.registerImmutableContract.selector, + bytes5("susds"), + SUSDS + ), + "Register immutable SUSDS address" + ); + _pushAction( + contractRegistryAdmin, + abi.encodeWithSelector( + ContractRegistryAdmin.registerImmutableContract.selector, + bytes5("gohm"), + GOHM + ), + "Register immutable GOHM address" + ); + _pushAction( + contractRegistryAdmin, + abi.encodeWithSelector( + ContractRegistryAdmin.registerImmutableContract.selector, + bytes5("ohm"), + OHM + ), + "Register immutable OHM address" + ); + + // STEP 3: Register mutable addresses for the Flash Lender and DAI-USDS Migrator contracts + _pushAction( + contractRegistryAdmin, + abi.encodeWithSelector( + ContractRegistryAdmin.registerContract.selector, + bytes5("flash"), + FLASH_LENDER + ), + "Register mutable Flash Lender address" + ); + _pushAction( + contractRegistryAdmin, + abi.encodeWithSelector( + ContractRegistryAdmin.registerContract.selector, + bytes5("dmgtr"), + DAI_USDS_MIGRATOR + ), + "Register mutable DAI-USDS Migrator address" + ); + } + + // Executes the proposal actions. + function _run(Addresses addresses, address) internal override { + // Simulates actions on TimelockController + _simulateActions( + address(_kernel), + addresses.getAddress("olympus-governor"), + addresses.getAddress("olympus-legacy-gohm"), + addresses.getAddress("proposer") + ); + } + + // Validates the post-execution state. + function _validate(Addresses addresses, address) internal view override { + // Load the contract addresses + ROLESv1 roles = ROLESv1(addresses.getAddress("olympus-module-roles")); + address timelock = addresses.getAddress("olympus-timelock"); + address rgsty = addresses.getAddress("olympus-module-rgsty"); + RGSTYv1 RGSTY = RGSTYv1(rgsty); + + // Validate that the OCG timelock has the contract_registry_admin role + require( + roles.hasRole(timelock, bytes32("contract_registry_admin")), + "Timelock does not have the contract_registry_admin role" + ); + + // Validate that the DAI, SDAI, USDS, SUSDS, GOHM and OHM addresses are registered and immutable + require(RGSTY.getImmutableContract("dai") == DAI, "DAI address is not immutable"); + require(RGSTY.getImmutableContract("sdai") == SDAI, "SDAI address is not immutable"); + require(RGSTY.getImmutableContract("usds") == USDS, "USDS address is not immutable"); + require(RGSTY.getImmutableContract("susds") == SUSDS, "SUSDS address is not immutable"); + require(RGSTY.getImmutableContract("gohm") == GOHM, "GOHM address is not immutable"); + require(RGSTY.getImmutableContract("ohm") == OHM, "OHM address is not immutable"); + + // Validate that the Flash Lender and DAI-USDS Migrator addresses are registered and mutable + require(RGSTY.getContract("flash") == FLASH_LENDER, "Flash Lender address is not mutable"); + require( + RGSTY.getContract("dmgtr") == DAI_USDS_MIGRATOR, + "DAI-USDS Migrator address is not mutable" + ); + } +} + +contract ContractRegistryProposalScript is ProposalScript { + constructor() ProposalScript(new ContractRegistryProposal()) {} +} diff --git a/src/proposals/LoanConsolidatorProposal.sol b/src/proposals/LoanConsolidatorProposal.sol new file mode 100644 index 000000000..e65e07a92 --- /dev/null +++ b/src/proposals/LoanConsolidatorProposal.sol @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +// OCG Proposal Simulator +import {Addresses} from "proposal-sim/addresses/Addresses.sol"; +import {GovernorBravoProposal} from "proposal-sim/proposals/OlympusGovernorBravoProposal.sol"; +// Interfaces +import {IERC20} from "forge-std/interfaces/IERC20.sol"; +import {IERC4626} from "forge-std/interfaces/IERC4626.sol"; +// Olympus Kernel, Modules, and Policies +import {Kernel, Actions, toKeycode} from "src/Kernel.sol"; +import {ROLESv1} from "src/modules/ROLES/ROLES.v1.sol"; +import {RolesAdmin} from "src/policies/RolesAdmin.sol"; +import {GovernorBravoDelegate} from "src/external/governance/GovernorBravoDelegate.sol"; +import {LoanConsolidator} from "src/policies/LoanConsolidator.sol"; + +// Script +import {ProposalScript} from "./ProposalScript.sol"; + +/// @notice Activates an updated LoanConsolidator policy. +contract LoanConsolidatorProposal is GovernorBravoProposal { + Kernel internal _kernel; + + // Returns the id of the proposal. + function id() public pure override returns (uint256) { + return 7; + } + + // Returns the name of the proposal. + function name() public pure override returns (string memory) { + return "LoanConsolidator Activation"; + } + + // Provides a brief description of the proposal. + function description() public pure override returns (string memory) { + return + string.concat( + "# LoanConsolidator Activation\n", + "\n", + "This proposal activates the LoanConsolidator policy.\n", + "\n", + "The previous version of LoanConsolidator contained logic that, combined with infinite approvals, enabled an attacker to steal funds from users of the CoolerUtils contract (as it was known at the time).\n", + "\n", + "This version introduces the following:\n", + "\n", + "- Strict checks on callers, ownership and Clearinghouse validity\n", + "- Allows for migration of loans from one Clearinghouse to another (in preparation for a USDS Clearinghouse)\n", + "- Allows for migration of loans from one owner to another\n", + "\n", + "## Resources\n", + "\n", + "- [View the audit report here](https://storage.googleapis.com/olympusdao-landing-page-reports/audits/2024_10_LoanConsolidator_Audit.pdf)\n", + "\n", + "- [View the pull request here](https://github.com/OlympusDAO/olympus-v3/pull/19)\n", + "\n", + "## Assumptions\n", + "\n", + "- The Contract Registry module has been deployed and activated as a module by the DAO MS.\n", + "- The ContractRegistryAdmin policy has been deployed and activated as a policy by the DAO MS.\n", + "- The mutable and immutable contract addresses required by LoanConsolidator have been registered in the Contract Registry.\n", + "- The LoanConsolidator contract has been deployed and activated as a policy by the DAO MS.\n", + "\n", + "## Proposal Steps\n", + "\n", + "1. Grant the `loan_consolidator_admin` role to the OCG Timelock.\n", + "2. Activate the LoanConsolidator.\n" + ); + } + + // No deploy actions needed + function _deploy(Addresses addresses, address) internal override { + // Cache the kernel address in state + _kernel = Kernel(addresses.getAddress("olympus-kernel")); + } + + function _afterDeploy(Addresses addresses, address deployer) internal override {} + + // Sets up actions for the proposal + function _build(Addresses addresses) internal override { + address rolesAdmin = addresses.getAddress("olympus-policy-roles-admin"); + address timelock = addresses.getAddress("olympus-timelock"); + address loanConsolidator = addresses.getAddress("olympus-policy-loan-consolidator"); + + // STEP 1: Grant the `loan_consolidator_admin` role to the OCG Timelock + _pushAction( + rolesAdmin, + abi.encodeWithSelector( + RolesAdmin.grantRole.selector, + bytes32("loan_consolidator_admin"), + timelock + ), + "Grant loan_consolidator_admin to Timelock" + ); + + // STEP 2: Activate the LoanConsolidator + _pushAction( + loanConsolidator, + abi.encodeWithSelector(LoanConsolidator.activate.selector), + "Activate LoanConsolidator" + ); + } + + // Executes the proposal actions. + function _run(Addresses addresses, address) internal override { + // Simulates actions on TimelockController + _simulateActions( + address(_kernel), + addresses.getAddress("olympus-governor"), + addresses.getAddress("olympus-legacy-gohm"), + addresses.getAddress("proposer") + ); + } + + // Validates the post-execution state. + function _validate(Addresses addresses, address) internal view override { + // Load the contract addresses + ROLESv1 roles = ROLESv1(addresses.getAddress("olympus-module-roles")); + address timelock = addresses.getAddress("olympus-timelock"); + LoanConsolidator loanConsolidator = LoanConsolidator( + addresses.getAddress("olympus-policy-loan-consolidator") + ); + address emergencyMS = addresses.getAddress("olympus-multisig-emergency"); + + // Validate that the emergency MS has the emergency shutdown role + require( + roles.hasRole(emergencyMS, bytes32("emergency_shutdown")), + "Emergency MS does not have the emergency_shutdown role" + ); + + // Validate that the OCG timelock has the emergency shutdown role + require( + roles.hasRole(timelock, bytes32("emergency_shutdown")), + "Timelock does not have the emergency_shutdown role" + ); + + // Validate that the OCG timelock has the loan_consolidator_admin role + require( + roles.hasRole(timelock, bytes32("loan_consolidator_admin")), + "Timelock does not have the loan_consolidator_admin role" + ); + + // Validate that the OCG timelock has the contract_registry_admin role + require( + roles.hasRole(timelock, bytes32("contract_registry_admin")), + "Timelock does not have the contract_registry_admin role" + ); + + // Validate that the LoanConsolidator is active + require(loanConsolidator.consolidatorActive(), "LoanConsolidator is not active"); + } +} + +contract LoanConsolidatorProposalScript is ProposalScript { + constructor() ProposalScript(new LoanConsolidatorProposal()) {} +} diff --git a/src/proposals/OIP_166.sol b/src/proposals/OIP_166.sol index c7ef403b8..bac8550cf 100644 --- a/src/proposals/OIP_166.sol +++ b/src/proposals/OIP_166.sol @@ -1,7 +1,10 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.15; + import {console2} from "forge-std/console2.sol"; +import {ProposalScript} from "src/proposals/ProposalScript.sol"; + // OCG Proposal Simulator import {Addresses} from "proposal-sim/addresses/Addresses.sol"; import {GovernorBravoProposal} from "proposal-sim/proposals/OlympusGovernorBravoProposal.sol"; @@ -14,13 +17,14 @@ import {ROLESv1} from "src/modules/ROLES/ROLES.v1.sol"; import {RolesAdmin} from "src/policies/RolesAdmin.sol"; import {GovernorBravoDelegate} from "src/external/governance/GovernorBravoDelegate.sol"; -// OIP_166 is the first step in activating Olympus Onchain Governance. +/// @notice OIP_166 is the first step in activating Olympus Onchain Governance. +// solhint-disable-next-line contract-name-camelcase contract OIP_166 is GovernorBravoProposal { Kernel internal _kernel; // Returns the id of the proposal. - function id() public view override returns (uint256) { - return 166; + function id() public pure override returns (uint256) { + return 1; } // Returns the name of the proposal. @@ -178,7 +182,7 @@ contract OIP_166 is GovernorBravoProposal { } // Validates the post-execution state. - function _validate(Addresses addresses, address) internal override { + function _validate(Addresses addresses, address) internal view override { // Load the contract addresses ROLESv1 roles = ROLESv1(addresses.getAddress("olympus-module-roles")); RolesAdmin rolesAdmin = RolesAdmin(addresses.getAddress("olympus-policy-roles-admin")); @@ -256,28 +260,7 @@ contract OIP_166 is GovernorBravoProposal { } } -import {ScriptSuite} from "proposal-sim/script/ScriptSuite.s.sol"; - -// @notice GovernorBravoScript is a script that runs BRAVO_01 proposal. -// BRAVO_01 proposal deploys a Vault contract and an ERC20 token contract -// Then the proposal transfers ownership of both Vault and ERC20 to the timelock address -// Finally the proposal whitelist the ERC20 token in the Vault contract -// @dev Use this script to simulates or run a single proposal -// Use this as a template to create your own script -// `forge script script/GovernorBravo.s.sol:GovernorBravoScript -vvvv --rpc-url {rpc} --broadcast --verify --etherscan-api-key {key}` -contract OIP_166_Script is ScriptSuite { - string public constant ADDRESSES_PATH = "./src/proposals/addresses.json"; - - constructor() ScriptSuite(ADDRESSES_PATH, new OIP_166()) {} - - function run() public override { - // set debug mode to true and run it to build the actions list - proposal.setDebug(true); - - // run the proposal to build it - proposal.run(addresses, address(0)); - - // get the calldata for the proposal, doing so in debug mode prints it to the console - proposal.getCalldata(); - } +// solhint-disable-next-line contract-name-camelcase +contract OIP_166ProposalScript is ProposalScript { + constructor() ProposalScript(new OIP_166()) {} } diff --git a/src/proposals/OIP_168.sol b/src/proposals/OIP_168.sol index 73c28e1b8..d07c9a5e1 100644 --- a/src/proposals/OIP_168.sol +++ b/src/proposals/OIP_168.sol @@ -202,7 +202,7 @@ contract OIP_168 is GovernorBravoProposal { address operator_1_5 = addresses.getAddress("olympus-policy-operator-1_5"); address heart_1_5 = addresses.getAddress("olympus-policy-heart-1_5"); address heart_1_6 = addresses.getAddress("olympus-policy-heart-1_6"); - address clearinghouse = addresses.getAddress("olympus-policy-clearinghouse-1_2"); + // address clearinghouse = addresses.getAddress("olympus-policy-clearinghouse-1_2"); // Validate the new Heart policy has the "heart" role require( diff --git a/src/proposals/OIP_XXX.sol b/src/proposals/OIP_XXX.sol index ddda9d050..ea676a629 100644 --- a/src/proposals/OIP_XXX.sol +++ b/src/proposals/OIP_XXX.sol @@ -14,7 +14,8 @@ import {CHREGv1} from "modules/CHREG/CHREG.v1.sol"; import {TRSRYv1} from "modules/TRSRY/TRSRY.v1.sol"; import {Clearinghouse} from "policies/Clearinghouse.sol"; -// OIP_XX proposal performs all the necessary steps to upgrade the Clearinghouse. +/// @notice OIP_XXX proposal performs all the necessary steps to upgrade the Clearinghouse. +// solhint-disable-next-line contract-name-camelcase contract OIP_XXX is GovernorBravoProposal { // Data struct to cache initial balances and used them in `_validate`. struct Cache { @@ -30,7 +31,7 @@ contract OIP_XXX is GovernorBravoProposal { Kernel internal _kernel; // Returns the id of the proposal. - function id() public view override returns (uint256) { + function id() public pure override returns (uint256) { return 0; } @@ -64,7 +65,7 @@ contract OIP_XXX is GovernorBravoProposal { addresses.addAddress("olympus-policy-clearinghouse-v1.1", address(clearinghouse)); } - function _afterDeploy(Addresses addresses, address deployer) internal override { + function _afterDeploy(Addresses addresses, address) internal override { // Get relevant olympus contracts address TRSRY = address(_kernel.getModuleForKeycode(toKeycode(bytes5("TRSRY")))); address clearinghouseV0 = addresses.getAddress("olympus-policy-clearinghouse"); @@ -143,34 +144,47 @@ contract OIP_XXX is GovernorBravoProposal { IERC20 dai = IERC20(addresses.getAddress("external-tokens-dai")); IERC4626 sdai = IERC4626(addresses.getAddress("external-tokens-sdai")); // Validate token balances - assertEq(dai.balanceOf(clearinghouseV0), 0); - assertEq(sdai.balanceOf(clearinghouseV0), 0); - assertEq(sdai.maxRedeem(clearinghouseV1), 0); // Should be 0 DAI since rebalance wasn't called - assertEq(dai.balanceOf(TRSRY), cacheTRSRY.daiBalance + cacheCH0.daiBalance); + assertEq(dai.balanceOf(clearinghouseV0), 0, "DAI balance of clearinghouse v1 should be 0"); + assertEq( + sdai.balanceOf(clearinghouseV0), + 0, + "sDAI balance of clearinghouse v1 should be 0" + ); + assertEq(sdai.maxRedeem(clearinghouseV1), 0, "Max redeem should be 0"); // Should be 0 DAI since rebalance wasn't called + assertEq( + dai.balanceOf(TRSRY), + cacheTRSRY.daiBalance + cacheCH0.daiBalance, + "DAI balance of treasury should be correct" + ); assertEq( sdai.balanceOf(TRSRY), - cacheTRSRY.sdaiBalance + cacheCH0.sdaiBalance - sdai.balanceOf(clearinghouseV1) + cacheTRSRY.sdaiBalance + cacheCH0.sdaiBalance - sdai.balanceOf(clearinghouseV1), + "sDAI balance of treasury should be correct" ); // Validate Clearinghouse state Clearinghouse CHv0 = Clearinghouse(clearinghouseV0); - assertEq(CHv0.active(), false); + assertEq(CHv0.active(), false, "Clearinghouse v1 should be shutdown"); // Validate Clearinghouse parameters Clearinghouse CHv1 = Clearinghouse(clearinghouseV1); - assertEq(CHv1.active(), true); - assertEq(CHv1.INTEREST_RATE(), 5e15); - assertEq(CHv1.LOAN_TO_COLLATERAL(), 289292e16); - assertEq(CHv1.DURATION(), 121 days); - assertEq(CHv1.FUND_CADENCE(), 7 days); - assertEq(CHv1.FUND_AMOUNT(), 18_000_000e18); - assertEq(CHv1.MAX_REWARD(), 1e17); + assertEq(CHv1.active(), true, "Clearinghouse v1.1 should be active"); + assertEq(CHv1.INTEREST_RATE(), 5e15, "Interest rate should be correct"); + assertEq(CHv1.LOAN_TO_COLLATERAL(), 289292e16, "Loan to collateral should be correct"); + assertEq(CHv1.DURATION(), 121 days, "Duration should be correct"); + assertEq(CHv1.FUND_CADENCE(), 7 days, "Fund cadence should be correct"); + assertEq(CHv1.FUND_AMOUNT(), 18_000_000e18, "Fund amount should be correct"); + assertEq(CHv1.MAX_REWARD(), 1e17, "Max reward should be correct"); // Validate Clearinghouse Registry state // The V0 Clearinghouse's emergencyShutdown function does NOT remove it from the registry. CHREGv1 CHRegistry = CHREGv1(CHREG); - assertEq(CHRegistry.activeCount(), 2); - assertEq(CHRegistry.active(0), clearinghouseV0); - assertEq(CHRegistry.active(1), clearinghouseV1); - assertEq(CHRegistry.registryCount(), 2); - assertEq(CHRegistry.registry(1), clearinghouseV0); - assertEq(CHRegistry.registry(2), clearinghouseV1); + assertEq(CHRegistry.activeCount(), 2, "Active count should be correct"); + assertEq(CHRegistry.active(0), clearinghouseV0, "Clearinghouse v0 should be active"); + assertEq(CHRegistry.active(1), clearinghouseV1, "Clearinghouse v1.1 should be active"); + assertEq(CHRegistry.registryCount(), 2, "Registry count should be correct"); + assertEq(CHRegistry.registry(1), clearinghouseV0, "Clearinghouse v0 should be in registry"); + assertEq( + CHRegistry.registry(2), + clearinghouseV1, + "Clearinghouse v1.1 should be in registry" + ); } } diff --git a/src/proposals/addresses.json b/src/proposals/addresses.json index 8eb8572b3..a7d3083e9 100644 --- a/src/proposals/addresses.json +++ b/src/proposals/addresses.json @@ -133,5 +133,20 @@ "addr": "0x50f441a3387625bDA8B8081cE3fd6C04CC48C0A2", "name": "olympus-policy-emissionmanager", "chainId": 1 + }, + { + "addr": "0x784cA0C006b8651BAB183829A99fA46BeCe50dBc", + "name": "olympus-policy-loan-consolidator", + "chainId": 1 + }, + { + "addr": "0x89631595649Cc6dEBa249A8012a5b2d88C8ddE48", + "name": "olympus-module-rgsty", + "chainId": 1 + }, + { + "addr": "0xBA05d48Fb94dC76820EB7ea1B360fd6DfDEabdc5", + "name": "olympus-policy-contract-registry-admin", + "chainId": 1 } ] diff --git a/src/scripts/deploy/DeployV2.sol b/src/scripts/deploy/DeployV2.sol index 5184fdfe5..3383922db 100644 --- a/src/scripts/deploy/DeployV2.sol +++ b/src/scripts/deploy/DeployV2.sol @@ -61,6 +61,8 @@ import {pOLY} from "policies/pOLY.sol"; import {ClaimTransfer} from "src/external/ClaimTransfer.sol"; import {Clearinghouse} from "policies/Clearinghouse.sol"; import {YieldRepurchaseFacility} from "policies/YieldRepurchaseFacility.sol"; +import {OlympusContractRegistry} from "modules/RGSTY/OlympusContractRegistry.sol"; +import {ContractRegistryAdmin} from "policies/ContractRegistryAdmin.sol"; import {ReserveMigrator} from "policies/ReserveMigrator.sol"; import {EmissionManager} from "policies/EmissionManager.sol"; @@ -69,7 +71,7 @@ import {MockAuraBooster, MockAuraRewardPool, MockAuraMiningLib, MockAuraVirtualR import {MockBalancerPool, MockVault} from "src/test/mocks/BalancerMocks.sol"; import {MockERC20} from "solmate/test/utils/mocks/MockERC20.sol"; import {Faucet} from "src/test/mocks/Faucet.sol"; -import {CoolerUtils} from "src/external/cooler/CoolerUtils.sol"; +import {LoanConsolidator} from "src/policies/LoanConsolidator.sol"; import {TransferHelper} from "libraries/TransferHelper.sol"; @@ -89,6 +91,7 @@ contract OlympusDeploy is Script { OlympusRoles public ROLES; OlympusBoostedLiquidityRegistry public BLREG; OlympusClearinghouseRegistry public CHREG; + OlympusContractRegistry public RGSTY; /// Policies Operator public operator; @@ -108,6 +111,8 @@ contract OlympusDeploy is Script { BLVaultLusd public lusdVault; CrossChainBridge public bridge; LegacyBurner public legacyBurner; + ContractRegistryAdmin public contractRegistryAdmin; + LoanConsolidator public loanConsolidator; YieldRepurchaseFacility public yieldRepo; ReserveMigrator public reserveMigrator; EmissionManager public emissionManager; @@ -119,7 +124,6 @@ contract OlympusDeploy is Script { address public inverseBondDepository; pOLY public poly; Clearinghouse public clearinghouse; - CoolerUtils public coolerUtils; // Governance Timelock public timelock; @@ -189,6 +193,7 @@ contract OlympusDeploy is Script { chain = chain_; // Setup contract -> selector mappings + // Modules selectorMap["OlympusPrice"] = this._deployPrice.selector; selectorMap["OlympusRange"] = this._deployRange.selector; selectorMap["OlympusTreasury"] = this._deployTreasury.selector; @@ -198,6 +203,8 @@ contract OlympusDeploy is Script { ._deployBoostedLiquidityRegistry .selector; selectorMap["OlympusClearinghouseRegistry"] = this._deployClearinghouseRegistry.selector; + selectorMap["OlympusContractRegistry"] = this._deployContractRegistry.selector; + // Policies selectorMap["Operator"] = this._deployOperator.selector; selectorMap["OlympusHeart"] = this._deployHeart.selector; selectorMap["BondCallback"] = this._deployBondCallback.selector; @@ -219,8 +226,9 @@ contract OlympusDeploy is Script { selectorMap["pOLY"] = this._deployPoly.selector; selectorMap["ClaimTransfer"] = this._deployClaimTransfer.selector; selectorMap["Clearinghouse"] = this._deployClearinghouse.selector; - selectorMap["CoolerUtils"] = this._deployCoolerUtils.selector; + selectorMap["LoanConsolidator"] = this._deployLoanConsolidator.selector; selectorMap["YieldRepurchaseFacility"] = this._deployYieldRepurchaseFacility.selector; + selectorMap["ContractRegistryAdmin"] = this._deployContractRegistryAdmin.selector; selectorMap["ReserveMigrator"] = this._deployReserveMigrator.selector; selectorMap["EmissionManager"] = this._deployEmissionManager.selector; @@ -280,6 +288,7 @@ contract OlympusDeploy is Script { // Bophades contracts kernel = Kernel(envAddress("olympus.Kernel")); + // Modules PRICE = OlympusPrice(envAddress("olympus.modules.OlympusPriceV2")); RANGE = OlympusRange(envAddress("olympus.modules.OlympusRangeV2")); TRSRY = OlympusTreasury(envAddress("olympus.modules.OlympusTreasury")); @@ -289,6 +298,8 @@ contract OlympusDeploy is Script { BLREG = OlympusBoostedLiquidityRegistry( envAddress("olympus.modules.OlympusBoostedLiquidityRegistry") ); + RGSTY = OlympusContractRegistry(envAddress("olympus.modules.OlympusContractRegistry")); + // Policies operator = Operator(envAddress("olympus.policies.Operator")); heart = OlympusHeart(envAddress("olympus.policies.OlympusHeart")); callback = BondCallback(envAddress("olympus.policies.BondCallback")); @@ -310,6 +321,10 @@ contract OlympusDeploy is Script { claimTransfer = ClaimTransfer(envAddress("olympus.claim.ClaimTransfer")); clearinghouse = Clearinghouse(envAddress("olympus.policies.Clearinghouse")); yieldRepo = YieldRepurchaseFacility(envAddress("olympus.policies.YieldRepurchaseFacility")); + contractRegistryAdmin = ContractRegistryAdmin( + envAddress("olympus.policies.ContractRegistryAdmin") + ); + loanConsolidator = LoanConsolidator(envAddress("olympus.policies.LoanConsolidator")); reserveMigrator = ReserveMigrator(envAddress("olympus.policies.ReserveMigrator")); emissionManager = EmissionManager(envAddress("olympus.policies.EmissionManager")); @@ -1045,36 +1060,50 @@ contract OlympusDeploy is Script { return address(CHREG); } - function _deployCoolerUtils(bytes calldata args_) public returns (address) { + function _deployContractRegistry(bytes calldata) public returns (address) { // Decode arguments from the sequence file - (address collector, uint256 feePercentage, address lender, address owner) = abi.decode( - args_, - (address, uint256, address, address) - ); + // None + + // Print the arguments + console2.log(" Kernel:", address(kernel)); + + // Deploy OlympusContractRegistry + vm.broadcast(); + RGSTY = new OlympusContractRegistry(address(kernel)); + console2.log("ContractRegistry deployed at:", address(RGSTY)); + + return address(RGSTY); + } + + function _deployContractRegistryAdmin(bytes calldata) public returns (address) { + // Decode arguments from the sequence file + // None + + // Print the arguments + console2.log(" Kernel:", address(kernel)); + + // Deploy ContractRegistryAdmin + vm.broadcast(); + contractRegistryAdmin = new ContractRegistryAdmin(address(kernel)); + console2.log("ContractRegistryAdmin deployed at:", address(contractRegistryAdmin)); + + return address(contractRegistryAdmin); + } + + function _deployLoanConsolidator(bytes calldata args_) public returns (address) { + // Decode arguments from the sequence file + uint256 feePercentage = abi.decode(args_, (uint256)); // Print the arguments - console2.log(" gOHM:", address(gohm)); - console2.log(" SDAI:", address(sReserve)); - console2.log(" DAI:", address(reserve)); - console2.log(" Collector:", collector); console2.log(" Fee Percentage:", feePercentage); - console2.log(" Lender:", lender); - console2.log(" Owner:", owner); + console2.log(" Kernel:", address(kernel)); - // Deploy CoolerUtils + // Deploy LoanConsolidator vm.broadcast(); - coolerUtils = new CoolerUtils( - address(gohm), - address(sReserve), - address(reserve), - owner, - lender, - collector, - feePercentage - ); - console2.log(" CoolerUtils deployed at:", address(coolerUtils)); + loanConsolidator = new LoanConsolidator(address(kernel), feePercentage); + console2.log(" LoanConsolidator deployed at:", address(loanConsolidator)); - return address(coolerUtils); + return address(loanConsolidator); } // ========== GOVERNANCE ========== // diff --git a/src/scripts/deploy/savedDeployments/contract_registry.json b/src/scripts/deploy/savedDeployments/contract_registry.json new file mode 100644 index 000000000..06ac8d934 --- /dev/null +++ b/src/scripts/deploy/savedDeployments/contract_registry.json @@ -0,0 +1,12 @@ +{ + "sequence": [ + { + "name": "OlympusContractRegistry", + "args": {} + }, + { + "name": "ContractRegistryAdmin", + "args": {} + } + ] +} diff --git a/src/scripts/deploy/savedDeployments/cooler_loan_consolidator.json b/src/scripts/deploy/savedDeployments/cooler_loan_consolidator.json new file mode 100644 index 000000000..fac326d28 --- /dev/null +++ b/src/scripts/deploy/savedDeployments/cooler_loan_consolidator.json @@ -0,0 +1,10 @@ +{ + "sequence": [ + { + "name": "LoanConsolidator", + "args": { + "feePercentage": 0 + } + } + ] +} \ No newline at end of file diff --git a/src/scripts/deploy/savedDeployments/cooler_utils.json b/src/scripts/deploy/savedDeployments/cooler_utils.json deleted file mode 100644 index d155c2644..000000000 --- a/src/scripts/deploy/savedDeployments/cooler_utils.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "sequence": [ - { - "name": "CoolerUtils", - "args": { - "collector": "0x31F8Cc382c9898b273eff4e0b7626a6987C846E8", - "feePercentage": 0, - "lender": "0x60744434d6339a6B27d73d9Eda62b6F66a0a04FA", - "owner": "0x245cc372C84B3645Bf0Ffe6538620B04a217988B" - } - } - ] -} \ No newline at end of file diff --git a/src/scripts/env.json b/src/scripts/env.json index 716e379ae..e9092d922 100644 --- a/src/scripts/env.json +++ b/src/scripts/env.json @@ -68,7 +68,8 @@ "OlympusInstructions": "0x0000000000000000000000000000000000000000", "OlympusVotes": "0x0000000000000000000000000000000000000000", "OlympusBoostedLiquidityRegistry": "0x375E06C694B5E50aF8be8FB03495A612eA3e2275", - "OlympusClearinghouseRegistry": "0x24b96f2150BF1ed10D3e8B28Ed33E392fbB4Cad5" + "OlympusClearinghouseRegistry": "0x69a3E97027d21a5984B6a543b36603fFbC6543a4", + "OlympusContractRegistry": "0x89631595649Cc6dEBa249A8012a5b2d88C8ddE48" }, "submodules": { "PRICE": { @@ -98,11 +99,12 @@ "BLVaultLusd": "0xfbB3742628e8D19E0E2d7D8dde208821C09dE960", "Clearinghouse": "0x1e094fE00E13Fd06D64EeA4FB3cD912893606fE0", "LegacyBurner": "0x367149cf2d04D3114fFD1Cc6b273222664908D0B", - "CoolerUtils": "0xB15bcb1b6593d85890f5287Baa2245B8A29F464a", + "LoanConsolidator": "0x784cA0C006b8651BAB183829A99fA46BeCe50dBc", "pOLY": "0xb37796941cA55b7E4243841930C104Ee325Da5a1", "YieldRepurchaseFacility": "0xcaA3d3E653A626e2656d2E799564fE952D39d855", "ReserveMigrator": "0x986b99579BEc7B990331474b66CcDB94Fa2419F5", - "EmissionManager": "0x50f441a3387625bDA8B8081cE3fd6C04CC48C0A2" + "EmissionManager": "0x50f441a3387625bDA8B8081cE3fd6C04CC48C0A2", + "ContractRegistryAdmin": "0xBA05d48Fb94dC76820EB7ea1B360fd6DfDEabdc5" }, "legacy": { "OHM": "0x64aa3364F17a4D01c6f1751Fd97C2BD3D7e7f1D5", @@ -142,28 +144,29 @@ } }, "olympus": { - "Kernel": "0x0000000000000000000000000000000000000000", + "Kernel": "0xeac3eC0CC130f4826715187805d1B50e861F2DaC", "modules": { "OlympusPrice": "0x0000000000000000000000000000000000000000", "OlympusRange": "0x0000000000000000000000000000000000000000", - "OlympusRoles": "0x0000000000000000000000000000000000000000", - "OlympusTreasury": "0x0000000000000000000000000000000000000000", - "OlympusMinter": "0x0000000000000000000000000000000000000000", + "OlympusRoles": "0xFF5F09D5efE13A9a424F30EC2e1af89D867834d6", + "OlympusTreasury": "0x56db53e9801a6EA080569261b63925E0f1f3C81A", + "OlympusMinter": "0x8f6406eDbFA393e327822D4A08BcF15503570D87", "OlympusInstructions": "0x0000000000000000000000000000000000000000", - "OlympusVotes": "0x0000000000000000000000000000000000000000" + "OlympusVotes": "0x0000000000000000000000000000000000000000", + "OlympusLender": "0x868C3ae18Fdea85bBb7a303e379c5B7e23b30F03" }, "policies": { - "RolesAdmin": "0x0000000000000000000000000000000000000000", + "RolesAdmin": "0x69168c08AcF66f002fd02E1B169f38C022c93b70", "TreasuryCustodian": "0x0000000000000000000000000000000000000000", - "CrossChainBridge": "0x0000000000000000000000000000000000000000" + "CrossChainBridge": "0x20B3834091f038Ce04D8686FAC99CA44A0FB285c" }, "legacy": { - "OHM": "0x0000000000000000000000000000000000000000", + "OHM": "0xf0cb2dc0db5e6c66B9a70Ac27B06b878da017028", "sOHM": "0x0000000000000000000000000000000000000000", - "gOHM": "0x0000000000000000000000000000000000000000", + "gOHM": "0x8D9bA570D6cb60C7e3e0F31343Efe75AB8E65FB1", "Staking": "0x0000000000000000000000000000000000000000", "Treasury": "0x0000000000000000000000000000000000000000", - "OlympusAuthority": "0x0000000000000000000000000000000000000000" + "OlympusAuthority": "0x78f84998c73655ac2Da0Aa1e1270F6Cb985a343e" } } }, @@ -273,6 +276,45 @@ "OldPOLY": "0x0000000000000000000000000000000000000000" } } + }, + "optimism": { + "external": { + }, + "olympus": { + "Kernel": "0x18878Df23e2a36f81e820e4b47b4A40576D3159C", + "modules": { + "OlympusMinter": "0x623164A9Ee2556D524b08f34F1d2389d7B4e1A1C", + "OlympusRoles": "0xbC9eE0D911739cBc72cd094ADA26F56E0C49EeAE" + }, + "policies": { + "RolesAdmin": "0xb1fA0Ac44d399b778B14af0AAF4bCF8af3437ad1", + "CrossChainBridge": "0x22AE99D07584A2AE1af748De573c83f1B9Cdb4c0" + }, + "legacy": { + "OHM": "0x060cb087a9730E13aa191f31A6d86bFF8DfcdCC0", + "gOHM": "0x0b5740c6b4a97f90eF2F0220651Cca420B868FfB", + "OlympusAuthority": "0x13DFEff85779118136bB9826DcAD8f3bd25153a3" + } + } + }, + "base": { + "external": { + }, + "olympus": { + "Kernel": "0x18878Df23e2a36f81e820e4b47b4A40576D3159C", + "modules": { + "OlympusMinter": "0x623164A9Ee2556D524b08f34F1d2389d7B4e1A1C", + "OlympusRoles": "0xbC9eE0D911739cBc72cd094ADA26F56E0C49EeAE" + }, + "policies": { + "RolesAdmin": "0xb1fA0Ac44d399b778B14af0AAF4bCF8af3437ad1", + "CrossChainBridge": "0x6CA1a916e883c7ce2BFBcF59dc70F2c1EF9dac6e" + }, + "legacy": { + "OHM": "0x060cb087a9730E13aa191f31A6d86bFF8DfcdCC0", + "OlympusAuthority": "0x13DFEff85779118136bB9826DcAD8f3bd25153a3" + } + } } }, "last": { diff --git a/src/scripts/ops/CoolerUtils.s.sol b/src/scripts/ops/CoolerUtils.s.sol deleted file mode 100644 index 82735a50d..000000000 --- a/src/scripts/ops/CoolerUtils.s.sol +++ /dev/null @@ -1,123 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -pragma solidity 0.8.15; - -import {Test} from "forge-std/Test.sol"; -import {Script} from "forge-std/Script.sol"; -import {stdJson} from "forge-std/StdJson.sol"; -import {console2} from "forge-std/console2.sol"; - -import {CoolerUtils} from "../../external/cooler/CoolerUtils.sol"; -import {Cooler} from "../../external/cooler/Cooler.sol"; -import {ERC20} from "solmate/tokens/ERC20.sol"; - -contract CoolerUtilsScript is Test { - using stdJson for string; - - string internal _env; - ERC20 internal _gohm; - ERC20 internal _dai; - - function _loadEnv() internal { - _env = vm.readFile("./src/scripts/env.json"); - _gohm = ERC20(_env.readAddress(".current.mainnet.olympus.legacy.gOHM")); - _dai = ERC20(_env.readAddress(".current.mainnet.external.tokens.DAI")); - } - - /// @dev Call in the form: - /// forge script ./src/scripts/ops/CoolerUtils.s.sol:CoolerUtilsScript --chain mainnet --sig "consolidate(address,address,address,uint256[])()" --rpc-url "[,,]" - function consolidate( - address owner_, - address clearinghouse_, - address cooler_, - uint256[] memory loanIds_ - ) public { - _loadEnv(); - - console2.log("Consolidating loans for", owner_); - console2.log("Clearinghouse:", clearinghouse_); - console2.log("Cooler:", cooler_); - - // // NOTE: Couldn't figure out how to pass an array to the function using forge script. Hard-coding. - // uint256[] memory loanIds_ = new uint256[](3); - // loanIds_[0] = 0; - // loanIds_[1] = 1; - // loanIds_[2] = 2; - - Cooler cooler = Cooler(cooler_); - CoolerUtils utils = CoolerUtils( - _env.readAddress(".current.mainnet.olympus.policies.CoolerUtils") - ); - - // Determine the approvals required - (, uint256 gohmApproval, uint256 totalDebtWithFee, , ) = utils.requiredApprovals( - clearinghouse_, - cooler_, - loanIds_ - ); - - // Determine the interest payable - uint256 interestPayable; - uint256 collateral; - for (uint256 i = 0; i < loanIds_.length; i++) { - Cooler.Loan memory loan = cooler.getLoan(loanIds_[i]); - interestPayable += loan.interestDue; - collateral += loan.collateral; - } - console2.log("Interest payable:", interestPayable); - console2.log("Collateral:", collateral); - - // Determine if there is an additional amount of collateral to be paid - uint256 additionalCollateral; - if (gohmApproval > collateral) { - additionalCollateral = gohmApproval - collateral; - } - - // Provide the additional collateral - if (additionalCollateral > 0) { - console2.log("Providing additional collateral:", additionalCollateral); - deal(address(_gohm), owner_, additionalCollateral); - } - - // Grant approvals - vm.startPrank(owner_); - _gohm.approve(address(utils), gohmApproval); - _dai.approve(address(utils), totalDebtWithFee); - vm.stopPrank(); - - console2.log("---"); - console2.log("gOHM balance before:", _gohm.balanceOf(owner_)); - console2.log("DAI balance before:", _dai.balanceOf(owner_)); - - console2.log("Consolidating loans..."); - // Consolidate the loans - vm.startPrank(owner_); - utils.consolidateWithFlashLoan(clearinghouse_, cooler_, loanIds_, 0, false); - vm.stopPrank(); - - console2.log("gOHM balance after:", _gohm.balanceOf(owner_)); - console2.log("DAI balance after:", _dai.balanceOf(owner_)); - - uint256 lastLoanId; - - // Check the previous loans - for (uint256 i = 0; i < loanIds_.length; i++) { - Cooler.Loan memory loan = cooler.getLoan(loanIds_[i]); - - console2.log("---"); - console2.log("Loan ID:", loanIds_[i]); - console2.log("Principal Due:", loan.principal); - console2.log("Interest Due:", loan.interestDue); - - lastLoanId = loanIds_[i]; - } - - uint256 consolidatedLoanId = lastLoanId + 1; - - // Check the consolidated loan - Cooler.Loan memory consolidatedLoan = cooler.getLoan(consolidatedLoanId); - console2.log("---"); - console2.log("Consolidated Loan ID:", consolidatedLoanId); - console2.log("Consolidated Principal Due:", consolidatedLoan.principal); - console2.log("Consolidated Interest Due:", consolidatedLoan.interestDue); - } -} diff --git a/src/scripts/ops/LoanConsolidator.s.sol b/src/scripts/ops/LoanConsolidator.s.sol new file mode 100644 index 000000000..d6934a9e2 --- /dev/null +++ b/src/scripts/ops/LoanConsolidator.s.sol @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity 0.8.15; + +import {Test} from "forge-std/Test.sol"; +import {Script} from "forge-std/Script.sol"; +import {stdJson} from "forge-std/StdJson.sol"; +import {console2} from "forge-std/console2.sol"; + +import {LoanConsolidator} from "../../policies/LoanConsolidator.sol"; +import {Cooler} from "../../external/cooler/Cooler.sol"; +import {ERC20} from "solmate/tokens/ERC20.sol"; + +contract LoanConsolidatorScript is Test { + using stdJson for string; + + string internal _env; + ERC20 internal _gohm; + ERC20 internal _dai; + ERC20 internal _usds; + + function _loadEnv() internal { + _env = vm.readFile("./src/scripts/env.json"); + _gohm = ERC20(_env.readAddress(".current.mainnet.olympus.legacy.gOHM")); + _dai = ERC20(_env.readAddress(".current.mainnet.external.tokens.DAI")); + _usds = ERC20(_env.readAddress(".current.mainnet.external.tokens.USDS")); + } + + /// @dev Call in the form: + /// forge script ./src/scripts/ops/LoanConsolidator.s.sol:LoanConsolidatorScript --chain mainnet --sig "consolidate(address,address,address,address,address,uint256[])()" --rpc-url "[,,]" + function consolidate( + address owner_, + address clearinghouseFrom_, + address clearinghouseTo_, + address coolerFrom_, + address coolerTo_, + uint256[] memory loanIds_ + ) public { + _loadEnv(); + + console2.log("Consolidating loans for", owner_); + console2.log("Clearinghouse From:", clearinghouseFrom_); + console2.log("Clearinghouse To:", clearinghouseTo_); + console2.log("Cooler From:", coolerFrom_); + console2.log("Cooler To:", coolerTo_); + + LoanConsolidator utils = LoanConsolidator( + _env.readAddress(".current.mainnet.olympus.policies.LoanConsolidator") + ); + + // Determine the approvals required + ( + , + uint256 gohmApproval, + address reserveTo, + uint256 ownerReserveTo, + uint256 callerReserveTo + ) = utils.requiredApprovals(clearinghouseFrom_, coolerFrom_, loanIds_); + + // Determine the interest payable + { + uint256 interestPayable; + uint256 collateral; + for (uint256 i = 0; i < loanIds_.length; i++) { + Cooler.Loan memory loan = Cooler(coolerFrom_).getLoan(loanIds_[i]); + interestPayable += loan.interestDue; + collateral += loan.collateral; + } + console2.log("Interest payable:", interestPayable); + console2.log("Collateral:", collateral); + + // Determine if there is an additional amount of collateral to be paid + uint256 additionalCollateral; + if (gohmApproval > collateral) { + additionalCollateral = gohmApproval - collateral; + } + + // Provide the additional collateral + if (additionalCollateral > 0) { + console2.log("Providing additional collateral:", additionalCollateral); + deal(address(_gohm), owner_, additionalCollateral); + } + } + + // Grant approvals + vm.startPrank(owner_); + _gohm.approve(address(utils), gohmApproval); + ERC20(reserveTo).approve(address(utils), ownerReserveTo + callerReserveTo); + vm.stopPrank(); + + { + console2.log("---"); + console2.log("gOHM balance before:", _gohm.balanceOf(owner_)); + console2.log("DAI balance before:", _dai.balanceOf(owner_)); + console2.log("USDS balance before:", _usds.balanceOf(owner_)); + + console2.log("Consolidating loans..."); + } + + // Consolidate the loans + vm.startPrank(owner_); + utils.consolidate(clearinghouseFrom_, clearinghouseTo_, coolerFrom_, coolerTo_, loanIds_); + vm.stopPrank(); + + console2.log("gOHM balance after:", _gohm.balanceOf(owner_)); + console2.log("DAI balance after:", _dai.balanceOf(owner_)); + console2.log("USDS balance after:", _usds.balanceOf(owner_)); + uint256 lastLoanId; + + // Check the previous loans + for (uint256 i = 0; i < loanIds_.length; i++) { + Cooler.Loan memory loan = Cooler(coolerFrom_).getLoan(loanIds_[i]); + + console2.log("---"); + console2.log("Loan ID:", loanIds_[i]); + console2.log("Principal Due:", loan.principal); + console2.log("Interest Due:", loan.interestDue); + + lastLoanId = loanIds_[i]; + } + + uint256 consolidatedLoanId = lastLoanId + 1; + + // Check the consolidated loan + Cooler coolerTo = Cooler(coolerTo_); + Cooler.Loan memory consolidatedLoan = coolerTo.getLoan(consolidatedLoanId); + console2.log("---"); + console2.log("Consolidated Loan ID:", consolidatedLoanId); + console2.log("Consolidated Principal Due:", consolidatedLoan.principal); + console2.log("Consolidated Interest Due:", consolidatedLoan.interestDue); + } +} diff --git a/src/scripts/ops/batch.sh b/src/scripts/ops/batch.sh index c616be62b..a1c82505f 100755 --- a/src/scripts/ops/batch.sh +++ b/src/scripts/ops/batch.sh @@ -3,12 +3,17 @@ # Run a multisig batch # # Usage: -# ./batch.sh --contract --batch --broadcast --testnet --env +# ./batch.sh --contract --batch --ledger --broadcast --testnet --env # # Environment variables: # RPC_URL # SIGNER_ADDRESS # TESTNET +# CHAIN +# DAO_MS +# POLICY_MS +# EMERGENCY_MS +# LEDGER_MNEMONIC_INDEX (ledger only) # Exit if any error occurs set -e @@ -36,6 +41,7 @@ set +a # Disable automatic export # Set sane defaults BROADCAST=${broadcast:-false} TESTNET=${testnet:-false} +LEDGER=${ledger:-false} # Check if contract is set if [ -z "$contract" ]; then @@ -61,12 +67,62 @@ if [ -z "$SIGNER_ADDRESS" ]; then exit 1 fi +# Validate that LEDGER is set to true or false +if [ "$LEDGER" != "true" ] && [ "$LEDGER" != "false" ]; then + echo "Invalid value for LEDGER. Must be true or false." + exit 1 +fi + +# If LEDGER is true, validate that MNEMONIC_INDEX is set +if [ "$LEDGER" == "true" ] && [ -z "$LEDGER_MNEMONIC_INDEX" ]; then + echo "No LEDGER_MNEMONIC_INDEX provided. Specify the LEDGER_MNEMONIC_INDEX in the $ENV_FILE file." + exit 1 +fi + +# Validate that CHAIN is set +if [ -z "$CHAIN" ]; then + echo "No chain provided. Specify the CHAIN in the $ENV_FILE file." + exit 1 +fi + +# Validate that DAO_MS is set +if [ -z "$DAO_MS" ]; then + echo "No DAO MS provided. Specify the DAO_MS in the $ENV_FILE file." + exit 1 +fi + +# Validate that POLICY_MS is set +if [ -z "$POLICY_MS" ]; then + echo "No POLICY MS provided. Specify the POLICY_MS in the $ENV_FILE file." + exit 1 +fi + +# Validate that EMERGENCY_MS is set +if [ -z "$EMERGENCY_MS" ]; then + echo "No EMERGENCY MS provided. Specify the EMERGENCY_MS in the $ENV_FILE file." + exit 1 +fi + +# Set the variables for using a Ledger wallet +# Export variables that BatchScript.sol uses +if [ "$LEDGER" == "true" ]; then + export WALLET_TYPE="ledger" + export MNEMONIC_INDEX="$LEDGER_MNEMONIC_INDEX" +else + export WALLET_TYPE="local" +fi + echo "Contract name: $contract" echo "Batch name: $batch" +echo "Using chain: $CHAIN" echo "Using RPC at URL: $RPC_URL" echo "Using signer address: $SIGNER_ADDRESS" +echo "Using DAO MS: $DAO_MS" +echo "Using POLICY MS: $POLICY_MS" +echo "Using EMERGENCY MS: $EMERGENCY_MS" echo "Broadcasting: $BROADCAST" echo "Using testnet: $TESTNET" +echo "Using ledger: $LEDGER" # Execute the batch TESTNET=$TESTNET forge script ./src/scripts/ops/batches/$contract.sol:$contract --sig "$batch(bool)()" $BROADCAST --slow -vvv --sender $SIGNER_ADDRESS --rpc-url $RPC_URL diff --git a/src/scripts/ops/batches/ContractRegistryInstall.sol b/src/scripts/ops/batches/ContractRegistryInstall.sol new file mode 100644 index 000000000..570df2141 --- /dev/null +++ b/src/scripts/ops/batches/ContractRegistryInstall.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity 0.8.15; + +import {console2} from "forge-std/console2.sol"; +import {stdJson} from "forge-std/StdJson.sol"; +import {OlyBatch} from "src/scripts/ops/OlyBatch.sol"; + +// Bophades +import {Kernel, Actions} from "src/Kernel.sol"; + +/// @notice Installs the RGSTY module and the ContractRegistryAdmin policy +contract ContractRegistryInstall is OlyBatch { + address kernel; + address rolesAdmin; + address rgsty; + address contractRegistryAdmin; + + function loadEnv() internal override { + // Load contract addresses from the environment file + kernel = envAddress("current", "olympus.Kernel"); + rolesAdmin = envAddress("current", "olympus.policies.RolesAdmin"); + rgsty = envAddress("current", "olympus.modules.OlympusContractRegistry"); + contractRegistryAdmin = envAddress("current", "olympus.policies.ContractRegistryAdmin"); + } + + // Entry point for the batch #1 + function script1_install(bool send_) external isDaoBatch(send_) { + // RGSTY Install Script + + // Validate addresses + require(rgsty != address(0), "RGSTY address is not set"); + require(contractRegistryAdmin != address(0), "ContractRegistryAdmin address is not set"); + + // A. Kernel Actions + // A.1. Install the RGSTY module on the kernel + addToBatch( + kernel, + abi.encodeWithSelector(Kernel.executeAction.selector, Actions.InstallModule, rgsty) + ); + + // A.2. Install the ContractRegistryAdmin policy on the kernel + addToBatch( + kernel, + abi.encodeWithSelector( + Kernel.executeAction.selector, + Actions.ActivatePolicy, + contractRegistryAdmin + ) + ); + + console2.log("Batch completed"); + } +} diff --git a/src/scripts/ops/batches/LoanConsolidatorInstall.sol b/src/scripts/ops/batches/LoanConsolidatorInstall.sol new file mode 100644 index 000000000..c6df143c5 --- /dev/null +++ b/src/scripts/ops/batches/LoanConsolidatorInstall.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity 0.8.15; + +import {console2} from "forge-std/console2.sol"; +import {stdJson} from "forge-std/StdJson.sol"; +import {OlyBatch} from "src/scripts/ops/OlyBatch.sol"; + +// Bophades +import {Kernel, Actions} from "src/Kernel.sol"; + +/// @notice Installs the LoanConsolidator policy +contract LoanConsolidatorInstall is OlyBatch { + address kernel; + address rolesAdmin; + address loanConsolidator; + + function loadEnv() internal override { + // Load contract addresses from the environment file + kernel = envAddress("current", "olympus.Kernel"); + rolesAdmin = envAddress("current", "olympus.policies.RolesAdmin"); + loanConsolidator = envAddress("current", "olympus.policies.LoanConsolidator"); + } + + // Entry point for the batch #1 + function script1_install(bool send_) external isDaoBatch(send_) { + // LoanConsolidator Install Script + + // Validate addresses + require(loanConsolidator != address(0), "LoanConsolidator address is not set"); + + // A. Kernel Actions + // A.1. Install the LoanConsolidator policy on the kernel + addToBatch( + kernel, + abi.encodeWithSelector( + Kernel.executeAction.selector, + Actions.ActivatePolicy, + loanConsolidator + ) + ); + + console2.log("Batch completed"); + } +} diff --git a/src/scripts/proposals/README.md b/src/scripts/proposals/README.md index 9256843a5..d5eb5ce3a 100644 --- a/src/scripts/proposals/README.md +++ b/src/scripts/proposals/README.md @@ -2,7 +2,16 @@ This directory contains scripts for submitting proposals to the Olympus Governor. -## Setup +## Environment + +The following are required: + +- `bash` shell +- A [foundry](https://getfoundry.sh/) installation +- A `.env` file with the following environment variables: + - `RPC_URL`: The RPC URL of the chain you wish to submit the proposal on. + +## Creating a Proposal Script The OCG proposal must have a separate contract that inherits from `ProposalScript`. See the `ContractRegistryProposal` for an example. @@ -25,9 +34,10 @@ It is possible to test proposal submission (and execution) on a forked chain. To 6. Submit your proposal by running `./submitProposal.sh` with the appropriate arguments. 7. Alternatively, you can execute the proposal (as if the proposal has passed) by running `./executeOnTestnet.sh` with the appropriate arguments. -## Mainnet +## Submitting a Proposal 1. Configure a wallet with `cast wallet` 2. Delegate your gOHM voting power to your wallet address. - This can be done by running `./delegate.sh` with the appropriate arguments, or through the Tenderly dashboard. 3. Submit your proposal by running `./submitProposal.sh` with the appropriate arguments. + - Example: `./src/scripts/proposals/submitProposal.sh --file src/proposals/ContractRegistryProposal.sol --contract ContractRegistryProposalScript --account --broadcast true --env .env` diff --git a/src/scripts/proposals/delegate.sh b/src/scripts/proposals/delegate.sh old mode 100644 new mode 100755 diff --git a/src/scripts/proposals/submitProposal.sh b/src/scripts/proposals/submitProposal.sh index e9ef7491f..cdd499f4e 100755 --- a/src/scripts/proposals/submitProposal.sh +++ b/src/scripts/proposals/submitProposal.sh @@ -64,9 +64,15 @@ if [ -z "$account" ]; then exit 1 fi +# Get the address of the cast wallet +echo "Getting address for cast account $account" +CAST_ADDRESS=$(cast wallet address --account $account) +echo "" + echo "Using proposal contract: $file:$contract" echo "Using RPC at URL: $RPC_URL" echo "Using forge account: $account" +echo "Sender address: $CAST_ADDRESS" # Set the fork flag FORK_FLAG="" @@ -87,4 +93,6 @@ else fi # Run the forge script -forge script $file:$contract -vvv --rpc-url $RPC_URL --account $account $FORK_FLAG $BROADCAST_FLAG +echo "" +echo "Running forge script..." +forge script $file:$contract --rpc-url $RPC_URL --account $account --sender $CAST_ADDRESS $FORK_FLAG $BROADCAST_FLAG -vvv diff --git a/src/test/external/cooler/CoolerUtils.t.sol b/src/test/external/cooler/CoolerUtils.t.sol deleted file mode 100644 index fb1d537f2..000000000 --- a/src/test/external/cooler/CoolerUtils.t.sol +++ /dev/null @@ -1,1255 +0,0 @@ -// SPDX-License-Identifier: GLP-3.0 -pragma solidity ^0.8.15; - -import {Test, console2} from "forge-std/Test.sol"; - -import {ERC20} from "solmate/tokens/ERC20.sol"; -import {IERC4626} from "forge-std/interfaces/IERC4626.sol"; - -import {IERC3156FlashBorrower} from "src/interfaces/maker-dao/IERC3156FlashBorrower.sol"; -import {IERC3156FlashLender} from "src/interfaces/maker-dao/IERC3156FlashLender.sol"; -import {CoolerFactory} from "src/external/cooler/CoolerFactory.sol"; -import {Clearinghouse} from "src/policies/Clearinghouse.sol"; -import {Cooler} from "src/external/cooler/Cooler.sol"; - -import {CoolerUtils} from "src/external/cooler/CoolerUtils.sol"; - -contract CoolerUtilsForkTest is Test { - CoolerUtils public utils; - - ERC20 public gohm; - ERC20 public dai; - IERC4626 public sdai; - - CoolerFactory public coolerFactory; - Clearinghouse public clearinghouse; - - address public owner; - address public lender; - address public collector; - - address public walletA; - Cooler public coolerA; - - uint256 internal constant _GOHM_AMOUNT = 3_333 * 1e18; - uint256 internal constant _ONE_HUNDRED_PERCENT = 100e2; - - string RPC_URL = vm.envString("FORK_TEST_RPC_URL"); - - function setUp() public { - // Mainnet Fork at current block. - vm.createSelectFork(RPC_URL, 18762666); - - // Required Contracts - coolerFactory = CoolerFactory(0x30Ce56e80aA96EbbA1E1a74bC5c0FEB5B0dB4216); - clearinghouse = Clearinghouse(0xE6343ad0675C9b8D3f32679ae6aDbA0766A2ab4c); - gohm = ERC20(0x0ab87046fBb341D058F17CBC4c1133F25a20a52f); - dai = ERC20(0x6B175474E89094C44Da98b954EedeAC495271d0F); - sdai = IERC4626(0x83F20F44975D03b1b09e64809B757c47f942BEeA); - lender = 0x60744434d6339a6B27d73d9Eda62b6F66a0a04FA; - - owner = vm.addr(0x1); - collector = vm.addr(0xC); - - // Deploy CoolerUtils - utils = new CoolerUtils( - address(gohm), - address(sdai), - address(dai), - owner, - lender, - collector, - 0 - ); - - walletA = vm.addr(0xA); - - // Fund wallets with gOHM - deal(address(gohm), walletA, _GOHM_AMOUNT); - - // Ensure Clearinghouse has enough DAI - deal(address(dai), address(clearinghouse), 18_000_000 * 1e18); - - vm.startPrank(walletA); - // Deploy a cooler for walletA - address coolerA_ = coolerFactory.generateCooler(gohm, dai); - coolerA = Cooler(coolerA_); - - // Approve clearinghouse to spend gOHM - gohm.approve(address(clearinghouse), _GOHM_AMOUNT); - // Loan 0 for coolerA (collateral: 2,000 gOHM) - (uint256 loan, ) = clearinghouse.getLoanForCollateral(2_000 * 1e18); - clearinghouse.lendToCooler(coolerA, loan); - // Loan 1 for coolerA (collateral: 1,000 gOHM) - (loan, ) = clearinghouse.getLoanForCollateral(1_000 * 1e18); - clearinghouse.lendToCooler(coolerA, loan); - // Loan 2 for coolerA (collateral: 333 gOHM) - (loan, ) = clearinghouse.getLoanForCollateral(333 * 1e18); - clearinghouse.lendToCooler(coolerA, loan); - vm.stopPrank(); - } - - // ===== MODIFIERS ===== // - - modifier givenProtocolFee(uint256 feePercent_) { - vm.prank(owner); - utils.setFeePercentage(feePercent_); - _; - } - - function _setLenderFee(uint256 borrowAmount_, uint256 fee_) internal { - vm.mockCall( - lender, - abi.encodeWithSelector( - IERC3156FlashLender.flashFee.selector, - address(dai), - borrowAmount_ - ), - abi.encode(fee_) - ); - } - - function _grantCallerApprovals(uint256[] memory ids) internal { - // Will revert if there are less than 2 loans - if (ids.length < 2) { - return; - } - - (, uint256 gohmApproval, uint256 totalDebtWithFee, , ) = utils.requiredApprovals( - address(clearinghouse), - address(coolerA), - ids - ); - - vm.startPrank(walletA); - dai.approve(address(utils), totalDebtWithFee); - gohm.approve(address(utils), gohmApproval); - vm.stopPrank(); - } - - function _grantCallerApprovals(uint256 gOhmAmount_, uint256 daiAmount_) internal { - vm.startPrank(walletA); - dai.approve(address(utils), daiAmount_); - gohm.approve(address(utils), gOhmAmount_); - vm.stopPrank(); - } - - function _consolidate(uint256[] memory ids_) internal { - _consolidate(ids_, 0, false); - } - - function _consolidate(uint256[] memory ids_, uint256 useFunds_, bool sDai_) internal { - vm.prank(walletA); - utils.consolidateWithFlashLoan( - address(clearinghouse), - address(coolerA), - ids_, - useFunds_, - sDai_ - ); - } - - function _getInterestDue( - address cooler_, - uint256[] memory ids_ - ) internal view returns (uint256) { - uint256 interestDue; - - for (uint256 i = 0; i < ids_.length; i++) { - Cooler.Loan memory loan = Cooler(cooler_).getLoan(ids_[i]); - interestDue += loan.interestDue; - } - - return interestDue; - } - - function _getInterestDue(uint256[] memory ids_) internal view returns (uint256) { - return _getInterestDue(address(coolerA), ids_); - } - - // ===== ASSERTIONS ===== // - - function _assertCoolerLoans(uint256 collateral_) internal { - // Check that coolerA has a single open loan - Cooler.Loan memory loan = coolerA.getLoan(0); - assertEq(loan.collateral, 0, "loan 0: collateral"); - loan = coolerA.getLoan(1); - assertEq(loan.collateral, 0, "loan 1: collateral"); - loan = coolerA.getLoan(2); - assertEq(loan.collateral, 0, "loan 2: collateral"); - loan = coolerA.getLoan(3); - assertEq(loan.collateral, collateral_, "loan 3: collateral"); - vm.expectRevert(); - loan = coolerA.getLoan(4); - } - - function _assertTokenBalances( - uint256 walletABalance, - uint256 lenderBalance, - uint256 collectorBalance, - uint256 collateralBalance - ) internal { - assertEq(dai.balanceOf(address(utils)), 0, "dai: utils"); - assertEq(dai.balanceOf(walletA), walletABalance, "dai: walletA"); - assertEq(dai.balanceOf(address(coolerA)), 0, "dai: coolerA"); - assertEq(dai.balanceOf(lender), lenderBalance, "dai: lender"); - assertEq(dai.balanceOf(collector), collectorBalance, "dai: collector"); - assertEq(sdai.balanceOf(address(utils)), 0, "sdai: utils"); - assertEq(sdai.balanceOf(walletA), 0, "sdai: walletA"); - assertEq(sdai.balanceOf(address(coolerA)), 0, "sdai: coolerA"); - assertEq(sdai.balanceOf(lender), 0, "sdai: lender"); - assertEq(sdai.balanceOf(collector), 0, "sdai: collector"); - assertEq(gohm.balanceOf(address(utils)), 0, "gohm: utils"); - assertEq(gohm.balanceOf(walletA), 0, "gohm: walletA"); - assertEq(gohm.balanceOf(address(coolerA)), collateralBalance, "gohm: coolerA"); - assertEq(gohm.balanceOf(lender), 0, "gohm: lender"); - assertEq(gohm.balanceOf(collector), 0, "gohm: collector"); - } - - function _assertApprovals() internal { - assertEq( - dai.allowance(address(utils), address(coolerA)), - 0, - "dai allowance: utils -> coolerA" - ); - assertEq( - dai.allowance(address(utils), address(clearinghouse)), - 0, - "dai allowance: utils -> clearinghouse" - ); - assertEq( - dai.allowance(address(utils), address(lender)), - 0, - "dai allowance: utils -> lender" - ); - assertEq(gohm.allowance(walletA, address(utils)), 0, "gohm allowance: walletA -> utils"); - assertEq( - gohm.allowance(address(utils), address(coolerA)), - 0, - "gohm allowance: utils -> coolerA" - ); - assertEq( - gohm.allowance(address(utils), address(clearinghouse)), - 0, - "gohm allowance: utils -> clearinghouse" - ); - assertEq( - gohm.allowance(address(utils), address(lender)), - 0, - "gohm allowance: utils -> lender" - ); - } - - // ===== TESTS ===== // - - // consolidateWithFlashLoan - // given the caller has no loans - // [X] it reverts - // given the caller has 1 loan - // [X] it reverts - // given the caller is not the cooler owner - // [X] it reverts - // given DAI spending approval has not been given to CoolerUtils - // [X] it reverts - // given gOHM spending approval has not been given to CoolerUtils - // [X] it reverts - // given the protocol fee is non-zero - // [X] it transfers the protocol fee to the collector - // given the lender fee is non-zero - // [ ] it transfers the lender fee to the lender - // when useFunds is non-zero - // when sDAI is true - // given sDAI spending approval has not been given to CoolerUtils - // [X] it reverts - // given the sDAI amount is greater than required for fees - // [X] it returns the surplus as DAI to the caller - // given the sDAI amount is less than required for fees - // [X] it reduces the flashloan amount by the redeemed DAI amount - // [X] it redeems the specified amount of sDAI into DAI, and reduces the flashloan amount by the amount - // when sDAI is false - // given the DAI amount is greater than required for fees - // [X] it returns the surplus as DAI to the caller - // given the DAI amount is less than required for fees - // [X] it reduces the flashloan amount by the redeemed DAI amount - // [X] it transfers the specified amount of DAI into the contract, and reduces the flashloan amount by the balance - // when the protocol fee is zero - // [X] it succeeds, but does not transfer additional DAI for the fee - // [X] it takes a flashloan for the total debt amount + CoolerUtils fee, and consolidates the loans into one - - // --- consolidateWithFlashLoan -------------------------------------------- - - function test_consolidate_noLoans_reverts() public { - // Grant approvals - _grantCallerApprovals(type(uint256).max, type(uint256).max); - - // Expect revert since no loan ids are given - bytes memory err = abi.encodeWithSelector(CoolerUtils.InsufficientCoolerCount.selector); - vm.expectRevert(err); - - // Consolidate loans, but give no ids - uint256[] memory ids = new uint256[](0); - _consolidate(ids); - } - - function test_consolidate_oneLoan_reverts() public { - // Grant approvals - _grantCallerApprovals(type(uint256).max, type(uint256).max); - - // Expect revert since no loan ids are given - bytes memory err = abi.encodeWithSelector(CoolerUtils.InsufficientCoolerCount.selector); - vm.expectRevert(err); - - // Consolidate loans, but give one id - uint256[] memory ids = new uint256[](1); - ids[0] = 0; - _consolidate(ids); - } - - function test_consolidate_callerNotOwner_reverts() public { - uint256[] memory idsA = _idsA(); - - // Grant approvals - (, uint256 gohmApproval, uint256 totalDebtWithFee, , ) = utils.requiredApprovals( - address(clearinghouse), - address(coolerA), - idsA - ); - - _grantCallerApprovals(gohmApproval, totalDebtWithFee); - - // Expect revert - bytes memory err = abi.encodeWithSelector(CoolerUtils.OnlyCoolerOwner.selector); - vm.expectRevert(err); - - // Consolidate loans for coolers A, B, and C into coolerC - // Do not perform as the cooler owner - utils.consolidateWithFlashLoan(address(clearinghouse), address(coolerA), idsA, 0, false); - } - - function test_consolidate_insufficientGOhmApproval_reverts() public { - uint256[] memory idsA = _idsA(); - - // Grant approvals - (, uint256 gohmApproval, uint256 totalDebtWithFee, , ) = utils.requiredApprovals( - address(clearinghouse), - address(coolerA), - idsA - ); - - _grantCallerApprovals(gohmApproval - 1, totalDebtWithFee); - - // Expect revert - vm.expectRevert("ERC20: transfer amount exceeds allowance"); - - _consolidate(idsA); - } - - function test_consolidate_insufficientDaiApproval_reverts() public { - uint256[] memory idsA = _idsA(); - - // Grant approvals - (, uint256 gohmApproval, , , ) = utils.requiredApprovals( - address(clearinghouse), - address(coolerA), - idsA - ); - - _grantCallerApprovals(gohmApproval, 1); - - // Expect revert - vm.expectRevert("Dai/insufficient-allowance"); - - _consolidate(idsA, 2, false); - } - - function test_consolidate_insufficientSdaiApproval_reverts() public { - uint256[] memory idsA = _idsA(); - - // Grant approvals - (, uint256 gohmApproval, , , ) = utils.requiredApprovals( - address(clearinghouse), - address(coolerA), - idsA - ); - - _grantCallerApprovals(gohmApproval, 0); - vm.prank(walletA); - sdai.approve(address(utils), 1); - - // Expect revert - vm.expectRevert("SavingsDai/insufficient-balance"); - - _consolidate(idsA, 2, true); - } - - function test_consolidate_noProtocolFee() public { - uint256[] memory idsA = _idsA(); - - // Grant approvals - (, uint256 gohmApproval, uint256 totalDebtWithFee, , ) = utils.requiredApprovals( - address(clearinghouse), - address(coolerA), - idsA - ); - - // Record the amount of DAI in the wallet - uint256 initPrincipal = dai.balanceOf(walletA); - uint256 interestDue = _getInterestDue(idsA); - - _grantCallerApprovals(gohmApproval, totalDebtWithFee); - - // Consolidate loans for coolers A, B, and C into coolerC - _consolidate(idsA); - - _assertCoolerLoans(_GOHM_AMOUNT); - _assertTokenBalances(initPrincipal - interestDue, 0, 0, _GOHM_AMOUNT); - _assertApprovals(); - } - - function test_consolidate_noProtocolFee_fuzz( - uint256 loanOneCollateral_, - uint256 loanTwoCollateral_ - ) public { - // Bound the collateral values - loanOneCollateral_ = bound(loanOneCollateral_, 1, 1e18); - loanTwoCollateral_ = bound(loanTwoCollateral_, 1, 1e18); - - // Set up a new wallet - address walletB = vm.addr(0xB); - - // Fund the wallet with gOHM - deal(address(gohm), walletB, loanOneCollateral_ + loanTwoCollateral_); - - // Deploy a cooler for walletB - vm.startPrank(walletB); - address coolerB_ = coolerFactory.generateCooler(gohm, dai); - Cooler coolerB = Cooler(coolerB_); - - // Approve clearinghouse to spend gOHM - gohm.approve(address(clearinghouse), loanOneCollateral_ + loanTwoCollateral_); - - // Take loans - { - // Loan 0 for coolerB - (uint256 loanOnePrincipal, ) = clearinghouse.getLoanForCollateral(loanOneCollateral_); - clearinghouse.lendToCooler(coolerB, loanOnePrincipal); - - // Loan 1 for coolerB - (uint256 loanTwoPrincipal, ) = clearinghouse.getLoanForCollateral(loanTwoCollateral_); - clearinghouse.lendToCooler(coolerB, loanTwoPrincipal); - vm.stopPrank(); - } - - uint256[] memory loanIds = new uint256[](2); - loanIds[0] = 0; - loanIds[1] = 1; - - // Grant approvals - (, uint256 gohmApproval, uint256 totalDebtWithFee, , ) = utils.requiredApprovals( - address(clearinghouse), - address(coolerB), - loanIds - ); - - // Record the amount of DAI in the wallet - uint256 initPrincipal = dai.balanceOf(walletB); - uint256 interestDue = _getInterestDue(address(coolerB), loanIds); - - // Grant approvals - vm.startPrank(walletB); - dai.approve(address(utils), totalDebtWithFee); - gohm.approve(address(utils), gohmApproval); - vm.stopPrank(); - - // Consolidate loans for coolers 0 and 1 into 2 - vm.startPrank(walletB); - utils.consolidateWithFlashLoan(address(clearinghouse), address(coolerB), loanIds, 0, false); - vm.stopPrank(); - - // Assert loan balances - assertEq(coolerB.getLoan(0).collateral, 0, "loan 0: collateral"); - assertEq(coolerB.getLoan(1).collateral, 0, "loan 1: collateral"); - assertEq( - coolerB.getLoan(2).collateral + gohm.balanceOf(walletB), - loanOneCollateral_ + loanTwoCollateral_, - "consolidated: collateral" - ); - - // Assert token balances - assertEq(dai.balanceOf(walletB), initPrincipal - interestDue, "DAI balance"); - // Don't check gOHM balance of walletB, because it can be non-zero due to rounding - // assertEq(gohm.balanceOf(walletB), 0, "gOHM balance"); - assertEq(dai.balanceOf(address(coolerB)), 0, "DAI balance: coolerB"); - assertEq( - gohm.balanceOf(address(coolerB)) + gohm.balanceOf(walletB), - loanOneCollateral_ + loanTwoCollateral_, - "gOHM balance: coolerB" - ); - assertEq(gohm.balanceOf(address(utils)), 0, "gOHM balance: utils"); - - // Assert approvals - assertEq( - dai.allowance(address(utils), address(coolerB)), - 0, - "DAI allowance: utils -> coolerB" - ); - assertEq( - gohm.allowance(address(utils), address(coolerB)), - 0, - "gOHM allowance: utils -> coolerB" - ); - } - - function test_consolidate_protocolFee() - public - givenProtocolFee(1000) // 1% - { - uint256[] memory idsA = _idsA(); - - // Grant approvals - (, uint256 gohmApproval, uint256 totalDebtWithFee, , uint256 protocolFee) = utils - .requiredApprovals(address(clearinghouse), address(coolerA), idsA); - - // Record the amount of DAI in the wallet - uint256 initPrincipal = dai.balanceOf(walletA); - uint256 interestDue = _getInterestDue(idsA); - - _grantCallerApprovals(gohmApproval, totalDebtWithFee); - - // Consolidate loans for coolers A, B, and C into coolerC - _consolidate(idsA); - - _assertCoolerLoans(_GOHM_AMOUNT); - _assertTokenBalances( - initPrincipal - interestDue - protocolFee, - 0, - protocolFee, - _GOHM_AMOUNT - ); - _assertApprovals(); - } - - function test_consolidate_whenUseFundsLessThanTotalDebt() public { - uint256[] memory idsA = _idsA(); - - // Grant approvals - (, uint256 gohmApproval, uint256 totalDebtWithFee, , ) = utils.requiredApprovals( - address(clearinghouse), - address(coolerA), - idsA - ); - - // Record the amount of DAI in the wallet - uint256 initPrincipal = dai.balanceOf(walletA); - uint256 interestDue = _getInterestDue(idsA); - - _grantCallerApprovals(gohmApproval, totalDebtWithFee); - - // Consolidate loans for coolers A, B, and C into coolerC - _consolidate(idsA, interestDue - 1, false); - - _assertCoolerLoans(_GOHM_AMOUNT); - _assertTokenBalances(initPrincipal - interestDue, 0, 0, _GOHM_AMOUNT); - _assertApprovals(); - } - - function test_consolidate_whenUseFundsEqualToTotalDebt() public { - uint256[] memory idsA = _idsA(); - - // Grant approvals - (, uint256 gohmApproval, uint256 totalDebtWithFee, , ) = utils.requiredApprovals( - address(clearinghouse), - address(coolerA), - idsA - ); - - // Record the amount of DAI in the wallet - uint256 interestDue = _getInterestDue(idsA); - - // Ensure the caller has enough DAI - deal(address(dai), walletA, totalDebtWithFee); - - _grantCallerApprovals(gohmApproval, totalDebtWithFee); - - // Consolidate loans for coolers A, B, and C into coolerC - _consolidate(idsA, totalDebtWithFee, false); - - _assertCoolerLoans(_GOHM_AMOUNT); - _assertTokenBalances(totalDebtWithFee - interestDue, 0, 0, _GOHM_AMOUNT); - _assertApprovals(); - } - - function test_consolidate_protocolFee_whenUseFundsGreaterThanProtocolFee() - public - givenProtocolFee(1000) // 1% - { - uint256[] memory idsA = _idsA(); - - // Grant approvals - (, uint256 gohmApproval, uint256 totalDebtWithFee, , uint256 protocolFee) = utils - .requiredApprovals(address(clearinghouse), address(coolerA), idsA); - - // Record the amount of DAI in the wallet - uint256 initPrincipal = dai.balanceOf(walletA); - uint256 interestDue = _getInterestDue(idsA); - - _grantCallerApprovals(gohmApproval, totalDebtWithFee); - - // Consolidate loans for coolers A, B, and C into coolerC - uint256 useFunds = protocolFee + 1; - _consolidate(idsA, useFunds, false); - - // Assertions - uint256 protocolFeeActual = ((initPrincipal + interestDue - useFunds) * - utils.feePercentage()) / _ONE_HUNDRED_PERCENT; - - _assertCoolerLoans(_GOHM_AMOUNT); - _assertTokenBalances( - initPrincipal - interestDue - protocolFeeActual, - 0, - protocolFeeActual, - _GOHM_AMOUNT - ); - _assertApprovals(); - } - - function test_consolidate_whenUseFundsGreaterThanTotalDebt_reverts() public { - uint256[] memory idsA = _idsA(); - - // Grant approvals - (, uint256 gohmApproval, uint256 totalDebtWithFee, , ) = utils.requiredApprovals( - address(clearinghouse), - address(coolerA), - idsA - ); - - _grantCallerApprovals(gohmApproval, totalDebtWithFee + 1); - - // Ensure the caller has more DAI that the total debt - deal(address(dai), walletA, totalDebtWithFee + 1); - - // Expect revert - bytes memory err = abi.encodeWithSelector(CoolerUtils.Params_UseFundsOutOfBounds.selector); - vm.expectRevert(err); - - // Consolidate loans for coolers A, B, and C into coolerC - uint256 useFunds = totalDebtWithFee + 1; - _consolidate(idsA, useFunds, false); - } - - function test_consolidate_protocolFee_whenUseFundsLessThanProtocolFee() - public - givenProtocolFee(1000) // 1% - { - uint256[] memory idsA = _idsA(); - - // Grant approvals - (, uint256 gohmApproval, uint256 totalDebtWithFee, , uint256 protocolFee) = utils - .requiredApprovals(address(clearinghouse), address(coolerA), idsA); - - // Record the amount of DAI in the wallet - uint256 initPrincipal = dai.balanceOf(walletA); - uint256 interestDue = _getInterestDue(idsA); - - _grantCallerApprovals(gohmApproval, totalDebtWithFee); - - // Consolidate loans for coolers A, B, and C into coolerC - uint256 useFunds = protocolFee - 1; - _consolidate(idsA, useFunds, false); - - // Assertions - uint256 protocolFeeActual = ((initPrincipal + interestDue - useFunds) * - utils.feePercentage()) / _ONE_HUNDRED_PERCENT; - - _assertCoolerLoans(_GOHM_AMOUNT); - _assertTokenBalances( - initPrincipal - interestDue - protocolFeeActual, - 0, - protocolFeeActual, - _GOHM_AMOUNT - ); - _assertApprovals(); - } - - function test_consolidate_protocolFee_whenUseFundsEqualToProtocolFee() - public - givenProtocolFee(1000) // 1% - { - uint256[] memory idsA = _idsA(); - - // Grant approvals - (, uint256 gohmApproval, uint256 totalDebtWithFee, , uint256 protocolFee) = utils - .requiredApprovals(address(clearinghouse), address(coolerA), idsA); - - // Record the amount of DAI in the wallet - uint256 initPrincipal = dai.balanceOf(walletA); - uint256 interestDue = _getInterestDue(idsA); - - _grantCallerApprovals(gohmApproval, totalDebtWithFee); - - // Consolidate loans for coolers A, B, and C into coolerC - uint256 useFunds = protocolFee; - _consolidate(idsA, useFunds, false); - - // Assertions - uint256 protocolFeeActual = ((initPrincipal + interestDue - useFunds) * - utils.feePercentage()) / _ONE_HUNDRED_PERCENT; - - _assertCoolerLoans(_GOHM_AMOUNT); - _assertTokenBalances( - initPrincipal - interestDue - protocolFeeActual, - 0, - protocolFeeActual, - _GOHM_AMOUNT - ); - _assertApprovals(); - } - - function test_consolidate_protocolFee_whenUseFundsEqualToProtocolFee_usingSDai() - public - givenProtocolFee(1000) // 1% - { - uint256[] memory idsA = _idsA(); - - // Grant approvals - (, uint256 gohmApproval, uint256 totalDebtWithFee, , uint256 protocolFee) = utils - .requiredApprovals(address(clearinghouse), address(coolerA), idsA); - - // Record the amount of DAI in the wallet - uint256 initPrincipal = dai.balanceOf(walletA); - uint256 interestDue = _getInterestDue(idsA); - - _grantCallerApprovals(gohmApproval, totalDebtWithFee); - - // Mint SDai - uint256 useFunds = protocolFee; - uint256 useFundsSDai = sdai.previewWithdraw(useFunds); - deal(address(sdai), walletA, useFundsSDai); - - // Approve SDai spending - vm.prank(walletA); - sdai.approve(address(utils), useFundsSDai); - - // Consolidate loans for coolers A, B, and C into coolerC - _consolidate(idsA, useFundsSDai, true); - - // Assertions - uint256 protocolFeeActual = ((initPrincipal + interestDue - useFunds) * - utils.feePercentage()) / _ONE_HUNDRED_PERCENT; - - _assertCoolerLoans(_GOHM_AMOUNT); - _assertTokenBalances( - initPrincipal - interestDue + useFunds - protocolFeeActual, - 0, - protocolFeeActual, - _GOHM_AMOUNT - ); - _assertApprovals(); - } - - function test_consolidate_protocolFee_whenUseFundsGreaterThanProtocolFee_usingSDai() - public - givenProtocolFee(1000) // 1% - { - uint256[] memory idsA = _idsA(); - - // Grant approvals - (, uint256 gohmApproval, uint256 totalDebtWithFee, , uint256 protocolFee) = utils - .requiredApprovals(address(clearinghouse), address(coolerA), idsA); - - // Record the amount of DAI in the wallet - uint256 initPrincipal = dai.balanceOf(walletA); - uint256 interestDue = _getInterestDue(idsA); - - _grantCallerApprovals(gohmApproval, totalDebtWithFee); - - // Mint SDai - uint256 useFunds = protocolFee + 1e9; - uint256 useFundsSDai = sdai.previewWithdraw(useFunds); - deal(address(sdai), walletA, useFundsSDai); - - // Approve SDai spending - vm.prank(walletA); - sdai.approve(address(utils), useFundsSDai); - - // Consolidate loans for coolers A, B, and C into coolerC - _consolidate(idsA, useFundsSDai, true); - - // Assertions - uint256 protocolFeeActual = ((initPrincipal + interestDue - useFunds) * - utils.feePercentage()) / _ONE_HUNDRED_PERCENT; - - _assertCoolerLoans(_GOHM_AMOUNT); - _assertTokenBalances( - initPrincipal - interestDue + useFunds - protocolFeeActual, - 0, - protocolFeeActual, - _GOHM_AMOUNT - ); - _assertApprovals(); - } - - function test_consolidate_protocolFee_whenUseFundsLessThanProtocolFee_usingSDai() - public - givenProtocolFee(1000) // 1% - { - uint256[] memory idsA = _idsA(); - - // Grant approvals - (, uint256 gohmApproval, uint256 totalDebtWithFee, , uint256 protocolFee) = utils - .requiredApprovals(address(clearinghouse), address(coolerA), idsA); - - // Record the amount of DAI in the wallet - uint256 initPrincipal = dai.balanceOf(walletA); - uint256 interestDue = _getInterestDue(idsA); - - _grantCallerApprovals(gohmApproval, totalDebtWithFee); - - // Mint SDai - uint256 useFunds = protocolFee - 1e9; - uint256 useFundsSDai = sdai.previewWithdraw(useFunds); - deal(address(sdai), walletA, useFundsSDai); - - // Approve SDai spending - vm.prank(walletA); - sdai.approve(address(utils), useFundsSDai); - - // Consolidate loans for coolers A, B, and C into coolerC - _consolidate(idsA, useFundsSDai, true); - - // Assertions - uint256 protocolFeeActual = ((initPrincipal + interestDue - useFunds) * - utils.feePercentage()) / _ONE_HUNDRED_PERCENT; - - _assertCoolerLoans(_GOHM_AMOUNT); - _assertTokenBalances( - initPrincipal - interestDue + useFunds - protocolFeeActual, - 0, - protocolFeeActual, - _GOHM_AMOUNT - ); - _assertApprovals(); - } - - // setFeePercentage - // when the caller is not the owner - // [X] it reverts - // when the fee is > 100% - // [X] it reverts - // [X] it sets the fee percentage - - function test_setFeePercentage_notOwner_reverts() public { - // Expect revert - vm.expectRevert("UNAUTHORIZED"); - - // Set the fee percentage as a non-owner - utils.setFeePercentage(1000); - } - - function test_setFeePercentage_aboveMax_reverts() public { - // Expect revert - bytes memory err = abi.encodeWithSelector( - CoolerUtils.Params_FeePercentageOutOfRange.selector - ); - vm.expectRevert(err); - - vm.prank(owner); - utils.setFeePercentage(_ONE_HUNDRED_PERCENT + 1); - } - - function test_setFeePercentage(uint256 feePercentage_) public { - uint256 feePercentage = bound(feePercentage_, 0, _ONE_HUNDRED_PERCENT); - - vm.prank(owner); - utils.setFeePercentage(feePercentage); - - assertEq(utils.feePercentage(), feePercentage, "fee percentage"); - } - - // setCollector - // when the caller is not the owner - // [X] it reverts - // when the new collector is the zero address - // [X] it reverts - // [X] it sets the collector - - function test_setCollector_notOwner_reverts() public { - // Expect revert - vm.expectRevert("UNAUTHORIZED"); - - utils.setCollector(owner); - } - - function test_setCollector_zeroAddress_reverts() public { - // Expect revert - bytes memory err = abi.encodeWithSelector(CoolerUtils.Params_InvalidAddress.selector); - vm.expectRevert(err); - - vm.prank(owner); - utils.setCollector(address(0)); - } - - function test_setCollector() public { - vm.prank(owner); - utils.setCollector(owner); - - assertEq(utils.collector(), owner, "collector"); - } - - // requiredApprovals - // when the caller has no loans - // [X] it reverts - // when the caller has 1 loan - // [X] it reverts - // when the protocol fee is zero - // [X] it returns the correct values - // when the protocol fee is non-zero - // [X] it returns the correct values - // [X] it returns the correct values for owner, gOHM amount, total DAI debt and sDAI amount - - function test_requiredApprovals_noLoans() public { - uint256[] memory ids = new uint256[](0); - - // Expect revert - bytes memory err = abi.encodeWithSelector(CoolerUtils.InsufficientCoolerCount.selector); - vm.expectRevert(err); - - utils.requiredApprovals(address(clearinghouse), address(coolerA), ids); - } - - function test_requiredApprovals_oneLoan() public { - uint256[] memory ids = new uint256[](1); - ids[0] = 0; - - // Expect revert - bytes memory err = abi.encodeWithSelector(CoolerUtils.InsufficientCoolerCount.selector); - vm.expectRevert(err); - - utils.requiredApprovals(address(clearinghouse), address(coolerA), ids); - } - - function test_requiredApprovals_noProtocolFee() public { - uint256[] memory ids = _idsA(); - - ( - address owner_, - uint256 gohmApproval, - uint256 totalDebtWithFee, - uint256 sDaiApproval, - uint256 protocolFee - ) = utils.requiredApprovals(address(clearinghouse), address(coolerA), ids); - - uint256 expectedTotalDebtWithFee; - for (uint256 i = 0; i < ids.length; i++) { - Cooler.Loan memory loan = coolerA.getLoan(ids[i]); - expectedTotalDebtWithFee += loan.principal + loan.interestDue; - } - - assertEq(owner_, walletA, "owner"); - assertEq(gohmApproval, _GOHM_AMOUNT, "gOHM approval"); - assertEq(totalDebtWithFee, expectedTotalDebtWithFee, "total debt with fee"); - assertEq(sDaiApproval, sdai.previewWithdraw(expectedTotalDebtWithFee), "sDai approval"); - assertEq(protocolFee, 0, "protocol fee"); - } - - function test_requiredApprovals_ProtocolFee() - public - givenProtocolFee(1000) // 1% - { - uint256[] memory ids = _idsA(); - - ( - address owner_, - uint256 gohmApproval, - uint256 totalDebtWithFee, - uint256 sDaiApproval, - uint256 protocolFee - ) = utils.requiredApprovals(address(clearinghouse), address(coolerA), ids); - - uint256 expectedTotalDebtWithFee; - for (uint256 i = 0; i < ids.length; i++) { - Cooler.Loan memory loan = coolerA.getLoan(ids[i]); - expectedTotalDebtWithFee += loan.principal + loan.interestDue; - } - - // Calculate protocol fee - uint256 protocolFeeActual = (expectedTotalDebtWithFee * 1000) / _ONE_HUNDRED_PERCENT; - - assertEq(owner_, walletA, "owner"); - assertEq(gohmApproval, _GOHM_AMOUNT, "gOHM approval"); - assertEq( - totalDebtWithFee, - expectedTotalDebtWithFee + protocolFeeActual, - "total debt with fee" - ); - assertEq( - sDaiApproval, - sdai.previewWithdraw(expectedTotalDebtWithFee + protocolFeeActual), - "sDai approval" - ); - assertEq(protocolFee, protocolFeeActual, "protocol fee"); - } - - function test_requiredApprovals_fuzz( - uint256 loanOneCollateral_, - uint256 loanTwoCollateral_ - ) public { - // Bound the collateral values - loanOneCollateral_ = bound(loanOneCollateral_, 1, 1e18); - loanTwoCollateral_ = bound(loanTwoCollateral_, 1, 1e18); - - // Set up a new wallet - address walletB = vm.addr(0xB); - - // Fund the wallet with gOHM - deal(address(gohm), walletB, loanOneCollateral_ + loanTwoCollateral_); - - // Deploy a cooler for walletB - vm.startPrank(walletB); - address coolerB_ = coolerFactory.generateCooler(gohm, dai); - Cooler coolerB = Cooler(coolerB_); - - // Approve clearinghouse to spend gOHM - gohm.approve(address(clearinghouse), loanOneCollateral_ + loanTwoCollateral_); - - // Take loans - uint256 totalPrincipal; - { - // Loan 0 for coolerB - (uint256 loanOnePrincipal, ) = clearinghouse.getLoanForCollateral(loanOneCollateral_); - totalPrincipal += loanOnePrincipal; - clearinghouse.lendToCooler(coolerB, loanOnePrincipal); - - // Loan 1 for coolerB - (uint256 loanTwoPrincipal, ) = clearinghouse.getLoanForCollateral(loanTwoCollateral_); - totalPrincipal += loanTwoPrincipal; - clearinghouse.lendToCooler(coolerB, loanTwoPrincipal); - vm.stopPrank(); - } - - uint256[] memory loanIds = new uint256[](2); - loanIds[0] = 0; - loanIds[1] = 1; - - // Grant approvals - (, uint256 gohmApproval, , , ) = utils.requiredApprovals( - address(clearinghouse), - address(coolerB), - loanIds - ); - - // Assertions - // The gOHM approval should be the amount of collateral required for the total principal - // At small values, this may be slightly different due to rounding - assertEq(gohmApproval, clearinghouse.getCollateralForLoan(totalPrincipal), "gOHM approval"); - } - - function test_collateralRequired_fuzz( - uint256 loanOneCollateral_, - uint256 loanTwoCollateral_ - ) public { - // Bound the collateral values - loanOneCollateral_ = bound(loanOneCollateral_, 1, 1e18); - loanTwoCollateral_ = bound(loanTwoCollateral_, 1, 1e18); - - // Set up a new wallet - address walletB = vm.addr(0xB); - - // Fund the wallet with gOHM - deal(address(gohm), walletB, loanOneCollateral_ + loanTwoCollateral_); - - // Deploy a cooler for walletB - vm.startPrank(walletB); - address coolerB_ = coolerFactory.generateCooler(gohm, dai); - Cooler coolerB = Cooler(coolerB_); - - // Approve clearinghouse to spend gOHM - gohm.approve(address(clearinghouse), loanOneCollateral_ + loanTwoCollateral_); - - // Take loans - uint256 totalPrincipal; - { - // Loan 0 for coolerB - (uint256 loanOnePrincipal, ) = clearinghouse.getLoanForCollateral(loanOneCollateral_); - clearinghouse.lendToCooler(coolerB, loanOnePrincipal); - - // Loan 1 for coolerB - (uint256 loanTwoPrincipal, ) = clearinghouse.getLoanForCollateral(loanTwoCollateral_); - clearinghouse.lendToCooler(coolerB, loanTwoPrincipal); - vm.stopPrank(); - - totalPrincipal = loanOnePrincipal + loanTwoPrincipal; - } - - // Get the amount of collateral for the loans - uint256 existingLoanCollateralExpected = coolerB.getLoan(0).collateral + - coolerB.getLoan(1).collateral; - - // Get the amount of collateral required for the consolidated loan - uint256 consolidatedLoanCollateralExpected = Clearinghouse(clearinghouse) - .getCollateralForLoan(totalPrincipal); - - // Get the amount of additional collateral required - uint256 additionalCollateralExpected; - if (consolidatedLoanCollateralExpected > existingLoanCollateralExpected) { - additionalCollateralExpected = - consolidatedLoanCollateralExpected - - existingLoanCollateralExpected; - } - - // Call collateralRequired - uint256[] memory loanIds = new uint256[](2); - loanIds[0] = 0; - loanIds[1] = 1; - - ( - uint256 consolidatedLoanCollateral, - uint256 existingLoanCollateral, - uint256 additionalCollateral - ) = utils.collateralRequired(address(clearinghouse), address(coolerB), loanIds); - - // Assertions - assertEq( - consolidatedLoanCollateral, - consolidatedLoanCollateralExpected, - "consolidated loan collateral" - ); - assertEq( - existingLoanCollateral, - existingLoanCollateralExpected, - "existing loan collateral" - ); - assertEq(additionalCollateral, additionalCollateralExpected, "additional collateral"); - } - - // constructor - // when the gOHM address is the zero address - // [X] it reverts - // when the sDAI address is the zero address - // [X] it reverts - // when the DAI address is the zero address - // [X] it reverts - // when the owner address is the zero address - // [X] it reverts - // when the lender address is the zero address - // [X] it reverts - // when the collector address is the zero address - // [X] it reverts - // when the fee percentage is > 100e2 - // [X] it reverts - // [X] it sets the values - - function test_constructor_zeroGOhm_reverts() public { - // Expect revert - bytes memory err = abi.encodeWithSelector(CoolerUtils.Params_InvalidAddress.selector); - vm.expectRevert(err); - - new CoolerUtils(address(0), address(sdai), address(dai), owner, lender, collector, 0); - } - - function test_constructor_zeroSDai_reverts() public { - // Expect revert - bytes memory err = abi.encodeWithSelector(CoolerUtils.Params_InvalidAddress.selector); - vm.expectRevert(err); - - new CoolerUtils(address(gohm), address(0), address(dai), owner, lender, collector, 0); - } - - function test_constructor_zeroDai_reverts() public { - // Expect revert - bytes memory err = abi.encodeWithSelector(CoolerUtils.Params_InvalidAddress.selector); - vm.expectRevert(err); - - new CoolerUtils(address(gohm), address(sdai), address(0), owner, lender, collector, 0); - } - - function test_constructor_zeroOwner_reverts() public { - // Expect revert - bytes memory err = abi.encodeWithSelector(CoolerUtils.Params_InvalidAddress.selector); - vm.expectRevert(err); - - new CoolerUtils( - address(gohm), - address(sdai), - address(dai), - address(0), - lender, - collector, - 0 - ); - } - - function test_constructor_zeroLender_reverts() public { - // Expect revert - bytes memory err = abi.encodeWithSelector(CoolerUtils.Params_InvalidAddress.selector); - vm.expectRevert(err); - - new CoolerUtils( - address(gohm), - address(sdai), - address(dai), - owner, - address(0), - collector, - 0 - ); - } - - function test_constructor_zeroCollector_reverts() public { - // Expect revert - bytes memory err = abi.encodeWithSelector(CoolerUtils.Params_InvalidAddress.selector); - vm.expectRevert(err); - - new CoolerUtils(address(gohm), address(sdai), address(dai), owner, lender, address(0), 0); - } - - function test_constructor_feePercentageAboveMax_reverts() public { - // Expect revert - bytes memory err = abi.encodeWithSelector( - CoolerUtils.Params_FeePercentageOutOfRange.selector - ); - vm.expectRevert(err); - - new CoolerUtils( - address(gohm), - address(sdai), - address(dai), - owner, - lender, - collector, - _ONE_HUNDRED_PERCENT + 1 - ); - } - - function test_constructor(uint256 feePercentage_) public { - uint256 feePercentage = bound(feePercentage_, 0, _ONE_HUNDRED_PERCENT); - - utils = new CoolerUtils( - address(gohm), - address(sdai), - address(dai), - owner, - lender, - collector, - feePercentage - ); - - assertEq(address(utils.gohm()), address(gohm), "gOHM"); - assertEq(address(utils.sdai()), address(sdai), "sDai"); - assertEq(address(utils.dai()), address(dai), "DAI"); - assertEq(utils.owner(), owner, "owner"); - assertEq(address(utils.lender()), lender, "lender"); - assertEq(utils.collector(), collector, "collector"); - assertEq(utils.feePercentage(), feePercentage, "fee percentage"); - } - - // --- AUX FUNCTIONS ----------------------------------------------------------- - - function _idsA() internal pure returns (uint256[] memory) { - uint256[] memory ids = new uint256[](3); - ids[0] = 0; - ids[1] = 1; - ids[2] = 2; - return ids; - } -} diff --git a/src/test/lib/ClearinghouseHigherLTC.sol b/src/test/lib/ClearinghouseHigherLTC.sol new file mode 100644 index 000000000..d07903dc6 --- /dev/null +++ b/src/test/lib/ClearinghouseHigherLTC.sol @@ -0,0 +1,486 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import {ERC20} from "solmate/tokens/ERC20.sol"; +import {ERC4626} from "solmate/mixins/ERC4626.sol"; +import {IStaking} from "interfaces/IStaking.sol"; + +import {CoolerFactory, Cooler} from "src/external/cooler/CoolerFactory.sol"; +import {CoolerCallback} from "src/external/cooler/CoolerCallback.sol"; + +import "src/Kernel.sol"; +import {TRSRYv1} from "modules/TRSRY/TRSRY.v1.sol"; +import {MINTRv1} from "modules/MINTR/MINTR.v1.sol"; +import {CHREGv1} from "modules/CHREG/CHREG.v1.sol"; +import {ROLESv1, RolesConsumer} from "modules/ROLES/OlympusRoles.sol"; + +/// @notice Copy of the Clearinghouse policy with a higher LTC +contract ClearinghouseHigherLTC is Policy, RolesConsumer, CoolerCallback { + // --- ERRORS ---------------------------------------------------- + + error BadEscrow(); + error DurationMaximum(); + error OnlyBurnable(); + error TooEarlyToFund(); + error LengthDiscrepancy(); + error OnlyBorrower(); + error NotLender(); + + // --- EVENTS ---------------------------------------------------- + + /// @notice Logs whenever the Clearinghouse is initialized or reactivated. + event Activate(); + /// @notice Logs whenever the Clearinghouse is deactivated. + event Deactivate(); + /// @notice Logs whenever the treasury is defunded. + event Defund(address token, uint256 amount); + /// @notice Logs the balance change (in reserve terms) whenever a rebalance occurs. + event Rebalance(bool defund, uint256 reserveAmount); + + // --- RELEVANT CONTRACTS ---------------------------------------- + + ERC20 public immutable reserve; // Debt token + ERC4626 public immutable sReserve; // Idle reserve will be wrapped into sReserve + ERC20 public immutable gohm; // Collateral token + ERC20 public immutable ohm; // Unwrapped gOHM + IStaking public immutable staking; // Necessary to unstake (and burn) OHM from defaults + + // --- MODULES --------------------------------------------------- + + CHREGv1 public CHREG; // Olympus V3 Clearinghouse Registry Module + MINTRv1 public MINTR; // Olympus V3 Minter Module + TRSRYv1 public TRSRY; // Olympus V3 Treasury Module + + // --- PARAMETER BOUNDS ------------------------------------------ + + uint256 public constant INTEREST_RATE = 5e15; // 0.5% anually + uint256 public constant LOAN_TO_COLLATERAL = 4000e18; + uint256 public constant DURATION = 121 days; // Four months + uint256 public constant FUND_CADENCE = 7 days; // One week + uint256 public constant FUND_AMOUNT = 18_000_000e18; // 18 million + uint256 public constant MAX_REWARD = 1e17; // 0.1 gOHM + + // --- STATE VARIABLES ------------------------------------------- + + /// @notice determines whether the contract can be funded or not. + bool public active; + + /// @notice timestamp at which the next rebalance can occur. + uint256 public fundTime; + + /// @notice Outstanding receivables. + /// Incremented when a loan is taken or rolled. + /// Decremented when a loan is repaid or collateral is burned. + uint256 public interestReceivables; + uint256 public principalReceivables; + + // --- INITIALIZATION -------------------------------------------- + + constructor( + address ohm_, + address gohm_, + address staking_, + address sReserve_, + address coolerFactory_, + address kernel_ + ) Policy(Kernel(kernel_)) CoolerCallback(coolerFactory_) { + // Store the relevant contracts. + ohm = ERC20(ohm_); + gohm = ERC20(gohm_); + staking = IStaking(staking_); + sReserve = ERC4626(sReserve_); + reserve = ERC20(sReserve.asset()); + } + + /// @notice Default framework setup. Configure dependencies for olympus-v3 modules. + /// @dev This function will be called when the `executor` installs the Clearinghouse + /// policy in the olympus-v3 `Kernel`. + function configureDependencies() external override returns (Keycode[] memory dependencies) { + dependencies = new Keycode[](4); + dependencies[0] = toKeycode("CHREG"); + dependencies[1] = toKeycode("MINTR"); + dependencies[2] = toKeycode("ROLES"); + dependencies[3] = toKeycode("TRSRY"); + + CHREG = CHREGv1(getModuleAddress(toKeycode("CHREG"))); + MINTR = MINTRv1(getModuleAddress(toKeycode("MINTR"))); + ROLES = ROLESv1(getModuleAddress(toKeycode("ROLES"))); + TRSRY = TRSRYv1(getModuleAddress(toKeycode("TRSRY"))); + + (uint8 CHREG_MAJOR, ) = CHREG.VERSION(); + (uint8 MINTR_MAJOR, ) = MINTR.VERSION(); + (uint8 ROLES_MAJOR, ) = ROLES.VERSION(); + (uint8 TRSRY_MAJOR, ) = TRSRY.VERSION(); + + // Ensure Modules are using the expected major version. + // Modules should be sorted in alphabetical order. + bytes memory expected = abi.encode([1, 1, 1, 1]); + if (CHREG_MAJOR != 1 || MINTR_MAJOR != 1 || ROLES_MAJOR != 1 || TRSRY_MAJOR != 1) + revert Policy_WrongModuleVersion(expected); + + // Approve MINTR for burning OHM (called here so that it is re-approved on updates) + ohm.approve(address(MINTR), type(uint256).max); + } + + /// @notice Default framework setup. Request permissions for interacting with olympus-v3 modules. + /// @dev This function will be called when the `executor` installs the Clearinghouse + /// policy in the olympus-v3 `Kernel`. + function requestPermissions() external view override returns (Permissions[] memory requests) { + Keycode CHREG_KEYCODE = toKeycode("CHREG"); + Keycode MINTR_KEYCODE = toKeycode("MINTR"); + Keycode TRSRY_KEYCODE = toKeycode("TRSRY"); + + requests = new Permissions[](6); + requests[0] = Permissions(CHREG_KEYCODE, CHREG.activateClearinghouse.selector); + requests[1] = Permissions(CHREG_KEYCODE, CHREG.deactivateClearinghouse.selector); + requests[2] = Permissions(MINTR_KEYCODE, MINTR.burnOhm.selector); + requests[3] = Permissions(TRSRY_KEYCODE, TRSRY.setDebt.selector); + requests[4] = Permissions(TRSRY_KEYCODE, TRSRY.increaseWithdrawApproval.selector); + requests[5] = Permissions(TRSRY_KEYCODE, TRSRY.withdrawReserves.selector); + } + + /// @notice Returns the version of the policy. + /// + /// @return major The major version of the policy. + /// @return minor The minor version of the policy. + function VERSION() external pure returns (uint8 major, uint8 minor) { + return (1, 2); + } + + // --- OPERATION ------------------------------------------------- + + /// @notice Lend to a cooler. + /// @dev To simplify the UX and easily ensure that all holders get the same terms, + /// this function requests a new loan and clears it in the same transaction. + /// @param cooler_ to lend to. + /// @param amount_ of reserve to lend. + /// @return the id of the granted loan. + function lendToCooler(Cooler cooler_, uint256 amount_) external returns (uint256) { + // Attempt a Clearinghouse <> Treasury rebalance. + rebalance(); + + // Validate that cooler was deployed by the trusted factory. + if (!factory.created(address(cooler_))) revert OnlyFromFactory(); + + // Validate cooler collateral and debt tokens. + if (cooler_.collateral() != gohm || cooler_.debt() != reserve) revert BadEscrow(); + + // Transfer in collateral owed + uint256 collateral = cooler_.collateralFor(amount_, LOAN_TO_COLLATERAL); + gohm.transferFrom(msg.sender, address(this), collateral); + + // Increment interest to be expected + (, uint256 interest) = getLoanForCollateral(collateral); + interestReceivables += interest; + principalReceivables += amount_; + + // Create a new loan request. + gohm.approve(address(cooler_), collateral); + uint256 reqID = cooler_.requestLoan(amount_, INTEREST_RATE, LOAN_TO_COLLATERAL, DURATION); + + // Clear the created loan request by providing enough reserve. + sReserve.withdraw(amount_, address(this), address(this)); + reserve.approve(address(cooler_), amount_); + uint256 loanID = cooler_.clearRequest(reqID, address(this), true); + + return loanID; + } + + /// @notice Extend the loan expiry by repaying the extension interest in advance. + /// The extension cost is paid by the caller. If a third-party executes the + /// extension, the loan period is extended, but the borrower debt does not increase. + /// @param cooler_ holding the loan to be extended. + /// @param loanID_ index of loan in loans[]. + /// @param times_ Amount of times that the fixed-term loan duration is extended. + function extendLoan(Cooler cooler_, uint256 loanID_, uint8 times_) external { + Cooler.Loan memory loan = cooler_.getLoan(loanID_); + + // Validate that cooler was deployed by the trusted factory. + if (!factory.created(address(cooler_))) revert OnlyFromFactory(); + + // Calculate extension interest based on the remaining principal. + uint256 interestBase = interestForLoan(loan.principal, loan.request.duration); + + // Transfer in extension interest from the caller. + reserve.transferFrom(msg.sender, address(this), interestBase * times_); + if (active) { + _sweepIntoSavingsVault(interestBase * times_); + } else { + _defund(reserve, interestBase * times_); + } + + // Signal to cooler that loan should be extended. + cooler_.extendLoanTerms(loanID_, times_); + } + + /// @notice Batch several default claims to save gas. + /// The elements on both arrays must be paired based on their index. + /// @dev Implements an auction style reward system that linearly increases up to a max reward. + /// @param coolers_ Array of contracts where the default must be claimed. + /// @param loans_ Array of defaulted loan ids. + function claimDefaulted(address[] calldata coolers_, uint256[] calldata loans_) external { + uint256 loans = loans_.length; + if (loans != coolers_.length) revert LengthDiscrepancy(); + + uint256 keeperRewards; + uint256 totalInterest; + uint256 totalPrincipal; + for (uint256 i = 0; i < loans; ) { + // Validate that cooler was deployed by the trusted factory. + if (!factory.created(coolers_[i])) revert OnlyFromFactory(); + + // Validate that loan was written by clearinghouse. + if (Cooler(coolers_[i]).getLoan(loans_[i]).lender != address(this)) revert NotLender(); + + // Claim defaults and update cached metrics. + (uint256 principal, uint256 interest, uint256 collateral, uint256 elapsed) = Cooler( + coolers_[i] + ).claimDefaulted(loans_[i]); + + unchecked { + // Cannot overflow due to max supply limits for both tokens + totalPrincipal += principal; + totalInterest += interest; + // There will not exist more than 2**256 loans + ++i; + } + + // Cap rewards to 5% of the collateral to avoid OHM holder's dillution. + uint256 maxAuctionReward = (collateral * 5e16) / 1e18; + + // Cap rewards to avoid exorbitant amounts. + uint256 maxReward = (maxAuctionReward < MAX_REWARD) ? maxAuctionReward : MAX_REWARD; + + // Calculate rewards based on the elapsed time since default. + keeperRewards = (elapsed < 7 days) + ? keeperRewards + (maxReward * elapsed) / 7 days + : keeperRewards + maxReward; + } + + // Decrement loan receivables. + interestReceivables = (interestReceivables > totalInterest) + ? interestReceivables - totalInterest + : 0; + principalReceivables = (principalReceivables > totalPrincipal) + ? principalReceivables - totalPrincipal + : 0; + + // Update outstanding debt owed to the Treasury upon default. + uint256 outstandingDebt = TRSRY.reserveDebt(reserve, address(this)); + + // debt owed to TRSRY = user debt - user interest + TRSRY.setDebt({ + debtor_: address(this), + token_: reserve, + amount_: (outstandingDebt > totalPrincipal) ? outstandingDebt - totalPrincipal : 0 + }); + + // Reward keeper. + gohm.transfer(msg.sender, keeperRewards); + // Burn the outstanding collateral of defaulted loans. + burn(); + } + + // --- CALLBACKS ----------------------------------------------------- + + /// @notice Overridden callback to decrement loan receivables. + /// @param *unused loadID_ of the load. + /// @param principalPaid_ in reserve. + /// @param interestPaid_ in reserve. + function _onRepay(uint256, uint256 principalPaid_, uint256 interestPaid_) internal override { + if (active) { + _sweepIntoSavingsVault(principalPaid_ + interestPaid_); + } else { + _defund(reserve, principalPaid_ + interestPaid_); + } + + // Decrement loan receivables. + interestReceivables = (interestReceivables > interestPaid_) + ? interestReceivables - interestPaid_ + : 0; + principalReceivables = (principalReceivables > principalPaid_) + ? principalReceivables - principalPaid_ + : 0; + } + + /// @notice Unused callback since defaults are handled by the clearinghouse. + /// @dev Overriden and left empty to save gas. + function _onDefault(uint256, uint256, uint256, uint256) internal override {} + + // --- FUNDING --------------------------------------------------- + + /// @notice Fund loan liquidity from treasury. + /// @dev Exposure is always capped at FUND_AMOUNT and rebalanced at up to FUND_CADANCE. + /// If several rebalances are available (because some were missed), calling this + /// function several times won't impact the funds controlled by the contract. + /// If the emergency shutdown is triggered, a rebalance will send funds back to + /// the treasury. + /// @return False if too early to rebalance. Otherwise, true. + function rebalance() public returns (bool) { + // If the contract is deactivated, defund. + uint256 maxFundAmount = active ? FUND_AMOUNT : 0; + // Update funding schedule if necessary. + if (fundTime > block.timestamp) return false; + fundTime += FUND_CADENCE; + + // Sweep reserve into DSR if necessary. + uint256 idle = reserve.balanceOf(address(this)); + if (idle != 0) _sweepIntoSavingsVault(idle); + + uint256 reserveBalance = sReserve.maxWithdraw(address(this)); + uint256 outstandingDebt = TRSRY.reserveDebt(reserve, address(this)); + // Rebalance funds on hand with treasury's reserves. + if (reserveBalance < maxFundAmount) { + // Since users loans are denominated in reserve, the clearinghouse + // debt is set in reserve terms. It must be adjusted when funding. + uint256 fundAmount = maxFundAmount - reserveBalance; + TRSRY.setDebt({ + debtor_: address(this), + token_: reserve, + amount_: outstandingDebt + fundAmount + }); + + // Since TRSRY holds sReserve, a conversion must be done before + // funding the clearinghouse. + uint256 sReserveAmount = sReserve.previewWithdraw(fundAmount); + TRSRY.increaseWithdrawApproval(address(this), sReserve, sReserveAmount); + TRSRY.withdrawReserves(address(this), sReserve, sReserveAmount); + + // Log the event. + emit Rebalance(false, fundAmount); + } else if (reserveBalance > maxFundAmount) { + // Since users loans are denominated in reserve, the clearinghouse + // debt is set in reserve terms. It must be adjusted when defunding. + uint256 defundAmount = reserveBalance - maxFundAmount; + TRSRY.setDebt({ + debtor_: address(this), + token_: reserve, + amount_: (outstandingDebt > defundAmount) ? outstandingDebt - defundAmount : 0 + }); + + // Since TRSRY holds sReserve, a conversion must be done before + // sending sReserve back. + uint256 sReserveAmount = sReserve.previewWithdraw(defundAmount); + sReserve.transfer(address(TRSRY), sReserveAmount); + + // Log the event. + emit Rebalance(true, defundAmount); + } + + return true; + } + + /// @notice Sweep excess reserve into savings vault. + function sweepIntoSavingsVault() public { + uint256 reserveBalance = reserve.balanceOf(address(this)); + _sweepIntoSavingsVault(reserveBalance); + } + + /// @notice Sweep excess reserve into vault. + function _sweepIntoSavingsVault(uint256 amount_) internal { + reserve.approve(address(sReserve), amount_); + sReserve.deposit(amount_, address(this)); + } + + /// @notice Public function to burn gOHM. + /// @dev Can be used to burn any gOHM defaulted using the Cooler instead of the Clearinghouse. + function burn() public { + uint256 gohmBalance = gohm.balanceOf(address(this)); + // Unstake and burn gOHM holdings. + gohm.approve(address(staking), gohmBalance); + MINTR.burnOhm(address(this), staking.unstake(address(this), gohmBalance, false, false)); + } + + // --- ADMIN --------------------------------------------------- + + /// @notice Activate the contract. + function activate() external onlyRole("cooler_overseer") { + active = true; + fundTime = block.timestamp; + + // Signal to CHREG that the contract has been activated. + CHREG.activateClearinghouse(address(this)); + + emit Activate(); + } + + /// @notice Deactivate the contract and return funds to treasury. + function emergencyShutdown() external onlyRole("emergency_shutdown") { + active = false; + + // If necessary, defund sReserve. + uint256 sReserveBalance = sReserve.balanceOf(address(this)); + if (sReserveBalance != 0) _defund(sReserve, sReserveBalance); + + // If necessary, defund reserve. + uint256 reserveBalance = reserve.balanceOf(address(this)); + if (reserveBalance != 0) _defund(reserve, reserveBalance); + + // Signal to CHREG that the contract has been deactivated. + CHREG.deactivateClearinghouse(address(this)); + + emit Deactivate(); + } + + /// @notice Return funds to treasury. + /// @param token_ to transfer. + /// @param amount_ to transfer. + function defund(ERC20 token_, uint256 amount_) external onlyRole("cooler_overseer") { + if (token_ == gohm) revert OnlyBurnable(); + _defund(token_, amount_); + } + + /// @notice Internal function to return funds to treasury. + /// @param token_ to transfer. + /// @param amount_ to transfer. + function _defund(ERC20 token_, uint256 amount_) internal { + if (token_ == sReserve || token_ == reserve) { + // Since users loans are denominated in reserve, the clearinghouse + // debt is set in reserve terms. It must be adjusted when defunding. + uint256 outstandingDebt = TRSRY.reserveDebt(reserve, address(this)); + uint256 reserveAmount = (token_ == sReserve) + ? sReserve.previewRedeem(amount_) + : amount_; + + TRSRY.setDebt({ + debtor_: address(this), + token_: reserve, + amount_: (outstandingDebt > reserveAmount) ? outstandingDebt - reserveAmount : 0 + }); + } + + // Defund and log the event + token_.transfer(address(TRSRY), amount_); + emit Defund(address(token_), amount_); + } + + // --- AUX FUNCTIONS --------------------------------------------- + + /// @notice view function computing collateral for a loan amount. + function getCollateralForLoan(uint256 principal_) external pure returns (uint256) { + return (principal_ * 1e18) / LOAN_TO_COLLATERAL; + } + + /// @notice view function computing loan for a collateral amount. + /// @param collateral_ amount of gOHM. + /// @return debt (amount to be lent + interest) for a given collateral amount. + function getLoanForCollateral(uint256 collateral_) public pure returns (uint256, uint256) { + uint256 principal = (collateral_ * LOAN_TO_COLLATERAL) / 1e18; + uint256 interest = interestForLoan(principal, DURATION); + return (principal, interest); + } + + /// @notice view function to compute the interest for given principal amount. + /// @param principal_ amount of reserve being lent. + /// @param duration_ elapsed time in seconds. + function interestForLoan(uint256 principal_, uint256 duration_) public pure returns (uint256) { + uint256 interestPercent = (INTEREST_RATE * duration_) / 365 days; + return (principal_ * interestPercent) / 1e18; + } + + /// @notice Get total receivable reserve for the treasury. + /// Includes both principal and interest. + function getTotalReceivables() external view returns (uint256) { + return principalReceivables + interestReceivables; + } +} diff --git a/src/test/lib/ClearinghouseLowerLTC.sol b/src/test/lib/ClearinghouseLowerLTC.sol new file mode 100644 index 000000000..b65086549 --- /dev/null +++ b/src/test/lib/ClearinghouseLowerLTC.sol @@ -0,0 +1,486 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import {ERC20} from "solmate/tokens/ERC20.sol"; +import {ERC4626} from "solmate/mixins/ERC4626.sol"; +import {IStaking} from "interfaces/IStaking.sol"; + +import {CoolerFactory, Cooler} from "src/external/cooler/CoolerFactory.sol"; +import {CoolerCallback} from "src/external/cooler/CoolerCallback.sol"; + +import "src/Kernel.sol"; +import {TRSRYv1} from "modules/TRSRY/TRSRY.v1.sol"; +import {MINTRv1} from "modules/MINTR/MINTR.v1.sol"; +import {CHREGv1} from "modules/CHREG/CHREG.v1.sol"; +import {ROLESv1, RolesConsumer} from "modules/ROLES/OlympusRoles.sol"; + +/// @notice Copy of the Clearinghouse policy with a lower LTC +contract ClearinghouseLowerLTC is Policy, RolesConsumer, CoolerCallback { + // --- ERRORS ---------------------------------------------------- + + error BadEscrow(); + error DurationMaximum(); + error OnlyBurnable(); + error TooEarlyToFund(); + error LengthDiscrepancy(); + error OnlyBorrower(); + error NotLender(); + + // --- EVENTS ---------------------------------------------------- + + /// @notice Logs whenever the Clearinghouse is initialized or reactivated. + event Activate(); + /// @notice Logs whenever the Clearinghouse is deactivated. + event Deactivate(); + /// @notice Logs whenever the treasury is defunded. + event Defund(address token, uint256 amount); + /// @notice Logs the balance change (in reserve terms) whenever a rebalance occurs. + event Rebalance(bool defund, uint256 reserveAmount); + + // --- RELEVANT CONTRACTS ---------------------------------------- + + ERC20 public immutable reserve; // Debt token + ERC4626 public immutable sReserve; // Idle reserve will be wrapped into sReserve + ERC20 public immutable gohm; // Collateral token + ERC20 public immutable ohm; // Unwrapped gOHM + IStaking public immutable staking; // Necessary to unstake (and burn) OHM from defaults + + // --- MODULES --------------------------------------------------- + + CHREGv1 public CHREG; // Olympus V3 Clearinghouse Registry Module + MINTRv1 public MINTR; // Olympus V3 Minter Module + TRSRYv1 public TRSRY; // Olympus V3 Treasury Module + + // --- PARAMETER BOUNDS ------------------------------------------ + + uint256 public constant INTEREST_RATE = 5e15; // 0.5% anually + uint256 public constant LOAN_TO_COLLATERAL = 2000e18; + uint256 public constant DURATION = 121 days; // Four months + uint256 public constant FUND_CADENCE = 7 days; // One week + uint256 public constant FUND_AMOUNT = 18_000_000e18; // 18 million + uint256 public constant MAX_REWARD = 1e17; // 0.1 gOHM + + // --- STATE VARIABLES ------------------------------------------- + + /// @notice determines whether the contract can be funded or not. + bool public active; + + /// @notice timestamp at which the next rebalance can occur. + uint256 public fundTime; + + /// @notice Outstanding receivables. + /// Incremented when a loan is taken or rolled. + /// Decremented when a loan is repaid or collateral is burned. + uint256 public interestReceivables; + uint256 public principalReceivables; + + // --- INITIALIZATION -------------------------------------------- + + constructor( + address ohm_, + address gohm_, + address staking_, + address sReserve_, + address coolerFactory_, + address kernel_ + ) Policy(Kernel(kernel_)) CoolerCallback(coolerFactory_) { + // Store the relevant contracts. + ohm = ERC20(ohm_); + gohm = ERC20(gohm_); + staking = IStaking(staking_); + sReserve = ERC4626(sReserve_); + reserve = ERC20(sReserve.asset()); + } + + /// @notice Default framework setup. Configure dependencies for olympus-v3 modules. + /// @dev This function will be called when the `executor` installs the Clearinghouse + /// policy in the olympus-v3 `Kernel`. + function configureDependencies() external override returns (Keycode[] memory dependencies) { + dependencies = new Keycode[](4); + dependencies[0] = toKeycode("CHREG"); + dependencies[1] = toKeycode("MINTR"); + dependencies[2] = toKeycode("ROLES"); + dependencies[3] = toKeycode("TRSRY"); + + CHREG = CHREGv1(getModuleAddress(toKeycode("CHREG"))); + MINTR = MINTRv1(getModuleAddress(toKeycode("MINTR"))); + ROLES = ROLESv1(getModuleAddress(toKeycode("ROLES"))); + TRSRY = TRSRYv1(getModuleAddress(toKeycode("TRSRY"))); + + (uint8 CHREG_MAJOR, ) = CHREG.VERSION(); + (uint8 MINTR_MAJOR, ) = MINTR.VERSION(); + (uint8 ROLES_MAJOR, ) = ROLES.VERSION(); + (uint8 TRSRY_MAJOR, ) = TRSRY.VERSION(); + + // Ensure Modules are using the expected major version. + // Modules should be sorted in alphabetical order. + bytes memory expected = abi.encode([1, 1, 1, 1]); + if (CHREG_MAJOR != 1 || MINTR_MAJOR != 1 || ROLES_MAJOR != 1 || TRSRY_MAJOR != 1) + revert Policy_WrongModuleVersion(expected); + + // Approve MINTR for burning OHM (called here so that it is re-approved on updates) + ohm.approve(address(MINTR), type(uint256).max); + } + + /// @notice Default framework setup. Request permissions for interacting with olympus-v3 modules. + /// @dev This function will be called when the `executor` installs the Clearinghouse + /// policy in the olympus-v3 `Kernel`. + function requestPermissions() external view override returns (Permissions[] memory requests) { + Keycode CHREG_KEYCODE = toKeycode("CHREG"); + Keycode MINTR_KEYCODE = toKeycode("MINTR"); + Keycode TRSRY_KEYCODE = toKeycode("TRSRY"); + + requests = new Permissions[](6); + requests[0] = Permissions(CHREG_KEYCODE, CHREG.activateClearinghouse.selector); + requests[1] = Permissions(CHREG_KEYCODE, CHREG.deactivateClearinghouse.selector); + requests[2] = Permissions(MINTR_KEYCODE, MINTR.burnOhm.selector); + requests[3] = Permissions(TRSRY_KEYCODE, TRSRY.setDebt.selector); + requests[4] = Permissions(TRSRY_KEYCODE, TRSRY.increaseWithdrawApproval.selector); + requests[5] = Permissions(TRSRY_KEYCODE, TRSRY.withdrawReserves.selector); + } + + /// @notice Returns the version of the policy. + /// + /// @return major The major version of the policy. + /// @return minor The minor version of the policy. + function VERSION() external pure returns (uint8 major, uint8 minor) { + return (1, 2); + } + + // --- OPERATION ------------------------------------------------- + + /// @notice Lend to a cooler. + /// @dev To simplify the UX and easily ensure that all holders get the same terms, + /// this function requests a new loan and clears it in the same transaction. + /// @param cooler_ to lend to. + /// @param amount_ of reserve to lend. + /// @return the id of the granted loan. + function lendToCooler(Cooler cooler_, uint256 amount_) external returns (uint256) { + // Attempt a Clearinghouse <> Treasury rebalance. + rebalance(); + + // Validate that cooler was deployed by the trusted factory. + if (!factory.created(address(cooler_))) revert OnlyFromFactory(); + + // Validate cooler collateral and debt tokens. + if (cooler_.collateral() != gohm || cooler_.debt() != reserve) revert BadEscrow(); + + // Transfer in collateral owed + uint256 collateral = cooler_.collateralFor(amount_, LOAN_TO_COLLATERAL); + gohm.transferFrom(msg.sender, address(this), collateral); + + // Increment interest to be expected + (, uint256 interest) = getLoanForCollateral(collateral); + interestReceivables += interest; + principalReceivables += amount_; + + // Create a new loan request. + gohm.approve(address(cooler_), collateral); + uint256 reqID = cooler_.requestLoan(amount_, INTEREST_RATE, LOAN_TO_COLLATERAL, DURATION); + + // Clear the created loan request by providing enough reserve. + sReserve.withdraw(amount_, address(this), address(this)); + reserve.approve(address(cooler_), amount_); + uint256 loanID = cooler_.clearRequest(reqID, address(this), true); + + return loanID; + } + + /// @notice Extend the loan expiry by repaying the extension interest in advance. + /// The extension cost is paid by the caller. If a third-party executes the + /// extension, the loan period is extended, but the borrower debt does not increase. + /// @param cooler_ holding the loan to be extended. + /// @param loanID_ index of loan in loans[]. + /// @param times_ Amount of times that the fixed-term loan duration is extended. + function extendLoan(Cooler cooler_, uint256 loanID_, uint8 times_) external { + Cooler.Loan memory loan = cooler_.getLoan(loanID_); + + // Validate that cooler was deployed by the trusted factory. + if (!factory.created(address(cooler_))) revert OnlyFromFactory(); + + // Calculate extension interest based on the remaining principal. + uint256 interestBase = interestForLoan(loan.principal, loan.request.duration); + + // Transfer in extension interest from the caller. + reserve.transferFrom(msg.sender, address(this), interestBase * times_); + if (active) { + _sweepIntoSavingsVault(interestBase * times_); + } else { + _defund(reserve, interestBase * times_); + } + + // Signal to cooler that loan should be extended. + cooler_.extendLoanTerms(loanID_, times_); + } + + /// @notice Batch several default claims to save gas. + /// The elements on both arrays must be paired based on their index. + /// @dev Implements an auction style reward system that linearly increases up to a max reward. + /// @param coolers_ Array of contracts where the default must be claimed. + /// @param loans_ Array of defaulted loan ids. + function claimDefaulted(address[] calldata coolers_, uint256[] calldata loans_) external { + uint256 loans = loans_.length; + if (loans != coolers_.length) revert LengthDiscrepancy(); + + uint256 keeperRewards; + uint256 totalInterest; + uint256 totalPrincipal; + for (uint256 i = 0; i < loans; ) { + // Validate that cooler was deployed by the trusted factory. + if (!factory.created(coolers_[i])) revert OnlyFromFactory(); + + // Validate that loan was written by clearinghouse. + if (Cooler(coolers_[i]).getLoan(loans_[i]).lender != address(this)) revert NotLender(); + + // Claim defaults and update cached metrics. + (uint256 principal, uint256 interest, uint256 collateral, uint256 elapsed) = Cooler( + coolers_[i] + ).claimDefaulted(loans_[i]); + + unchecked { + // Cannot overflow due to max supply limits for both tokens + totalPrincipal += principal; + totalInterest += interest; + // There will not exist more than 2**256 loans + ++i; + } + + // Cap rewards to 5% of the collateral to avoid OHM holder's dillution. + uint256 maxAuctionReward = (collateral * 5e16) / 1e18; + + // Cap rewards to avoid exorbitant amounts. + uint256 maxReward = (maxAuctionReward < MAX_REWARD) ? maxAuctionReward : MAX_REWARD; + + // Calculate rewards based on the elapsed time since default. + keeperRewards = (elapsed < 7 days) + ? keeperRewards + (maxReward * elapsed) / 7 days + : keeperRewards + maxReward; + } + + // Decrement loan receivables. + interestReceivables = (interestReceivables > totalInterest) + ? interestReceivables - totalInterest + : 0; + principalReceivables = (principalReceivables > totalPrincipal) + ? principalReceivables - totalPrincipal + : 0; + + // Update outstanding debt owed to the Treasury upon default. + uint256 outstandingDebt = TRSRY.reserveDebt(reserve, address(this)); + + // debt owed to TRSRY = user debt - user interest + TRSRY.setDebt({ + debtor_: address(this), + token_: reserve, + amount_: (outstandingDebt > totalPrincipal) ? outstandingDebt - totalPrincipal : 0 + }); + + // Reward keeper. + gohm.transfer(msg.sender, keeperRewards); + // Burn the outstanding collateral of defaulted loans. + burn(); + } + + // --- CALLBACKS ----------------------------------------------------- + + /// @notice Overridden callback to decrement loan receivables. + /// @param *unused loadID_ of the load. + /// @param principalPaid_ in reserve. + /// @param interestPaid_ in reserve. + function _onRepay(uint256, uint256 principalPaid_, uint256 interestPaid_) internal override { + if (active) { + _sweepIntoSavingsVault(principalPaid_ + interestPaid_); + } else { + _defund(reserve, principalPaid_ + interestPaid_); + } + + // Decrement loan receivables. + interestReceivables = (interestReceivables > interestPaid_) + ? interestReceivables - interestPaid_ + : 0; + principalReceivables = (principalReceivables > principalPaid_) + ? principalReceivables - principalPaid_ + : 0; + } + + /// @notice Unused callback since defaults are handled by the clearinghouse. + /// @dev Overriden and left empty to save gas. + function _onDefault(uint256, uint256, uint256, uint256) internal override {} + + // --- FUNDING --------------------------------------------------- + + /// @notice Fund loan liquidity from treasury. + /// @dev Exposure is always capped at FUND_AMOUNT and rebalanced at up to FUND_CADANCE. + /// If several rebalances are available (because some were missed), calling this + /// function several times won't impact the funds controlled by the contract. + /// If the emergency shutdown is triggered, a rebalance will send funds back to + /// the treasury. + /// @return False if too early to rebalance. Otherwise, true. + function rebalance() public returns (bool) { + // If the contract is deactivated, defund. + uint256 maxFundAmount = active ? FUND_AMOUNT : 0; + // Update funding schedule if necessary. + if (fundTime > block.timestamp) return false; + fundTime += FUND_CADENCE; + + // Sweep reserve into DSR if necessary. + uint256 idle = reserve.balanceOf(address(this)); + if (idle != 0) _sweepIntoSavingsVault(idle); + + uint256 reserveBalance = sReserve.maxWithdraw(address(this)); + uint256 outstandingDebt = TRSRY.reserveDebt(reserve, address(this)); + // Rebalance funds on hand with treasury's reserves. + if (reserveBalance < maxFundAmount) { + // Since users loans are denominated in reserve, the clearinghouse + // debt is set in reserve terms. It must be adjusted when funding. + uint256 fundAmount = maxFundAmount - reserveBalance; + TRSRY.setDebt({ + debtor_: address(this), + token_: reserve, + amount_: outstandingDebt + fundAmount + }); + + // Since TRSRY holds sReserve, a conversion must be done before + // funding the clearinghouse. + uint256 sReserveAmount = sReserve.previewWithdraw(fundAmount); + TRSRY.increaseWithdrawApproval(address(this), sReserve, sReserveAmount); + TRSRY.withdrawReserves(address(this), sReserve, sReserveAmount); + + // Log the event. + emit Rebalance(false, fundAmount); + } else if (reserveBalance > maxFundAmount) { + // Since users loans are denominated in reserve, the clearinghouse + // debt is set in reserve terms. It must be adjusted when defunding. + uint256 defundAmount = reserveBalance - maxFundAmount; + TRSRY.setDebt({ + debtor_: address(this), + token_: reserve, + amount_: (outstandingDebt > defundAmount) ? outstandingDebt - defundAmount : 0 + }); + + // Since TRSRY holds sReserve, a conversion must be done before + // sending sReserve back. + uint256 sReserveAmount = sReserve.previewWithdraw(defundAmount); + sReserve.transfer(address(TRSRY), sReserveAmount); + + // Log the event. + emit Rebalance(true, defundAmount); + } + + return true; + } + + /// @notice Sweep excess reserve into savings vault. + function sweepIntoSavingsVault() public { + uint256 reserveBalance = reserve.balanceOf(address(this)); + _sweepIntoSavingsVault(reserveBalance); + } + + /// @notice Sweep excess reserve into vault. + function _sweepIntoSavingsVault(uint256 amount_) internal { + reserve.approve(address(sReserve), amount_); + sReserve.deposit(amount_, address(this)); + } + + /// @notice Public function to burn gOHM. + /// @dev Can be used to burn any gOHM defaulted using the Cooler instead of the Clearinghouse. + function burn() public { + uint256 gohmBalance = gohm.balanceOf(address(this)); + // Unstake and burn gOHM holdings. + gohm.approve(address(staking), gohmBalance); + MINTR.burnOhm(address(this), staking.unstake(address(this), gohmBalance, false, false)); + } + + // --- ADMIN --------------------------------------------------- + + /// @notice Activate the contract. + function activate() external onlyRole("cooler_overseer") { + active = true; + fundTime = block.timestamp; + + // Signal to CHREG that the contract has been activated. + CHREG.activateClearinghouse(address(this)); + + emit Activate(); + } + + /// @notice Deactivate the contract and return funds to treasury. + function emergencyShutdown() external onlyRole("emergency_shutdown") { + active = false; + + // If necessary, defund sReserve. + uint256 sReserveBalance = sReserve.balanceOf(address(this)); + if (sReserveBalance != 0) _defund(sReserve, sReserveBalance); + + // If necessary, defund reserve. + uint256 reserveBalance = reserve.balanceOf(address(this)); + if (reserveBalance != 0) _defund(reserve, reserveBalance); + + // Signal to CHREG that the contract has been deactivated. + CHREG.deactivateClearinghouse(address(this)); + + emit Deactivate(); + } + + /// @notice Return funds to treasury. + /// @param token_ to transfer. + /// @param amount_ to transfer. + function defund(ERC20 token_, uint256 amount_) external onlyRole("cooler_overseer") { + if (token_ == gohm) revert OnlyBurnable(); + _defund(token_, amount_); + } + + /// @notice Internal function to return funds to treasury. + /// @param token_ to transfer. + /// @param amount_ to transfer. + function _defund(ERC20 token_, uint256 amount_) internal { + if (token_ == sReserve || token_ == reserve) { + // Since users loans are denominated in reserve, the clearinghouse + // debt is set in reserve terms. It must be adjusted when defunding. + uint256 outstandingDebt = TRSRY.reserveDebt(reserve, address(this)); + uint256 reserveAmount = (token_ == sReserve) + ? sReserve.previewRedeem(amount_) + : amount_; + + TRSRY.setDebt({ + debtor_: address(this), + token_: reserve, + amount_: (outstandingDebt > reserveAmount) ? outstandingDebt - reserveAmount : 0 + }); + } + + // Defund and log the event + token_.transfer(address(TRSRY), amount_); + emit Defund(address(token_), amount_); + } + + // --- AUX FUNCTIONS --------------------------------------------- + + /// @notice view function computing collateral for a loan amount. + function getCollateralForLoan(uint256 principal_) external pure returns (uint256) { + return (principal_ * 1e18) / LOAN_TO_COLLATERAL; + } + + /// @notice view function computing loan for a collateral amount. + /// @param collateral_ amount of gOHM. + /// @return debt (amount to be lent + interest) for a given collateral amount. + function getLoanForCollateral(uint256 collateral_) public pure returns (uint256, uint256) { + uint256 principal = (collateral_ * LOAN_TO_COLLATERAL) / 1e18; + uint256 interest = interestForLoan(principal, DURATION); + return (principal, interest); + } + + /// @notice view function to compute the interest for given principal amount. + /// @param principal_ amount of reserve being lent. + /// @param duration_ elapsed time in seconds. + function interestForLoan(uint256 principal_, uint256 duration_) public pure returns (uint256) { + uint256 interestPercent = (INTEREST_RATE * duration_) / 365 days; + return (principal_ * interestPercent) / 1e18; + } + + /// @notice Get total receivable reserve for the treasury. + /// Includes both principal and interest. + function getTotalReceivables() external view returns (uint256) { + return principalReceivables + interestReceivables; + } +} diff --git a/src/test/mocks/MockContractRegistryPolicy.sol b/src/test/mocks/MockContractRegistryPolicy.sol new file mode 100644 index 000000000..62d94f7fd --- /dev/null +++ b/src/test/mocks/MockContractRegistryPolicy.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {Kernel, Policy, Keycode, toKeycode, Permissions} from "src/Kernel.sol"; +import {RGSTYv1} from "src/modules/RGSTY/RGSTY.v1.sol"; + +contract MockContractRegistryPolicy is Policy { + address public dai; + + RGSTYv1 public RGSTY; + + constructor(Kernel kernel_) Policy(kernel_) {} + + function configureDependencies() external override returns (Keycode[] memory dependencies) { + dependencies = new Keycode[](1); + dependencies[0] = toKeycode("RGSTY"); + + // Populate module dependencies + RGSTY = RGSTYv1(getModuleAddress(dependencies[0])); + + // Populate variables + // This function will be called whenever a contract is registered or deregistered, which enables caching of the values + dai = RGSTY.getContract("dai"); + + return dependencies; + } + + function requestPermissions() external pure override returns (Permissions[] memory requests) { + requests = new Permissions[](0); + + return requests; + } +} + +contract MockImmutableContractRegistryPolicy is Policy { + address public dai; + + RGSTYv1 public RGSTY; + + constructor(Kernel kernel_) Policy(kernel_) {} + + function configureDependencies() external override returns (Keycode[] memory dependencies) { + dependencies = new Keycode[](1); + dependencies[0] = toKeycode("RGSTY"); + + // Populate module dependencies + RGSTY = RGSTYv1(getModuleAddress(dependencies[0])); + + // Populate variables + // This function will be called whenever a contract is registered or deregistered, which enables caching of the values + dai = RGSTY.getImmutableContract("dai"); + + return dependencies; + } + + function requestPermissions() external pure override returns (Permissions[] memory requests) { + requests = new Permissions[](0); + + return requests; + } +} diff --git a/src/test/mocks/MockFlashloanLender.sol b/src/test/mocks/MockFlashloanLender.sol new file mode 100644 index 000000000..e8aabad6e --- /dev/null +++ b/src/test/mocks/MockFlashloanLender.sol @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity ^0.8.15; + +import {IERC3156FlashLender} from "src/interfaces/maker-dao/IERC3156FlashLender.sol"; +import {IERC3156FlashBorrower} from "src/interfaces/maker-dao/IERC3156FlashBorrower.sol"; +import {ERC20} from "solmate/tokens/ERC20.sol"; +import {console2} from "forge-std/console2.sol"; + +contract MockFlashloanLender is IERC3156FlashLender { + uint16 public feePercent; + uint16 public constant MAX_FEE_PERCENT = 10000; + + ERC20 public immutable token; + + error InvalidToken(); + + constructor(uint16 feePercent_, address token_) { + feePercent = feePercent_; + token = ERC20(token_); + } + + function setFeePercent(uint16 feePercent_) external { + feePercent = feePercent_; + } + + function maxFlashLoan(address token_) external view override returns (uint256) { + if (token_ != address(token)) revert InvalidToken(); + + return type(uint256).max; + } + + function _flashFee(uint256 amount) internal view returns (uint256) { + return (amount * feePercent) / MAX_FEE_PERCENT; + } + + function flashFee(address token_, uint256 amount) external view override returns (uint256) { + if (token_ != address(token)) revert InvalidToken(); + + return _flashFee(amount); + } + + function flashLoan( + IERC3156FlashBorrower receiver_, + address token_, + uint256 amount_, + bytes calldata data_ + ) external override returns (bool) { + if (token_ != address(token)) revert InvalidToken(); + + // Transfer the funds to the receiver + token.transfer(address(receiver_), amount_); + + // Calculate the lender fee + uint256 lenderFee = _flashFee(amount_); + + // Call the receiver's onFlashLoan function + receiver_.onFlashLoan(msg.sender, token_, amount_, lenderFee, data_); + + // Calculate the amount to be returned to the caller + uint256 amountToReturn = amount_ + lenderFee; + + // Transfer the funds back to this contract + token.transferFrom(address(receiver_), address(this), amountToReturn); + + return true; + } +} diff --git a/src/test/modules/CHREG.t.sol b/src/test/modules/CHREG.t.sol index 8466311fd..3e3c83a5d 100644 --- a/src/test/modules/CHREG.t.sol +++ b/src/test/modules/CHREG.t.sol @@ -138,6 +138,91 @@ contract CHREGTest is Test { assertEq(chreg.registry(0), address(1)); } + function test_activateMultiple() public { + // Verify initial state + assertEq(chreg.registryCount(), 0); + assertEq(chreg.activeCount(), 0); + + vm.startPrank(godmode); + chreg.activateClearinghouse(address(1)); + chreg.activateClearinghouse(address(2)); + vm.stopPrank(); + + // Verify clearinghouse was activateed + assertEq(chreg.registryCount(), 2); + assertEq(chreg.registry(0), address(1)); + assertEq(chreg.registry(1), address(2)); + assertEq(chreg.activeCount(), 2); + assertEq(chreg.active(0), address(1)); + assertEq(chreg.active(1), address(2)); + } + + function test_activateMultiple_givenFirstDeactivated() public { + // Verify initial state + assertEq(chreg.registryCount(), 0); + assertEq(chreg.activeCount(), 0); + + vm.startPrank(godmode); + chreg.activateClearinghouse(address(1)); + chreg.deactivateClearinghouse(address(1)); + chreg.activateClearinghouse(address(2)); + chreg.activateClearinghouse(address(3)); + vm.stopPrank(); + + // Verify clearinghouse was activated + assertEq(chreg.registryCount(), 3); + assertEq(chreg.registry(0), address(1)); + assertEq(chreg.registry(1), address(2)); + assertEq(chreg.registry(2), address(3)); + assertEq(chreg.activeCount(), 2); + assertEq(chreg.active(0), address(2)); + assertEq(chreg.active(1), address(3)); + } + + function test_activateMultiple_givenSecondDeactivated() public { + // Verify initial state + assertEq(chreg.registryCount(), 0); + assertEq(chreg.activeCount(), 0); + + vm.startPrank(godmode); + chreg.activateClearinghouse(address(1)); + chreg.activateClearinghouse(address(2)); + chreg.deactivateClearinghouse(address(2)); + chreg.activateClearinghouse(address(3)); + vm.stopPrank(); + + // Verify clearinghouse was activated + assertEq(chreg.registryCount(), 3); + assertEq(chreg.registry(0), address(1)); + assertEq(chreg.registry(1), address(2)); + assertEq(chreg.registry(2), address(3)); + assertEq(chreg.activeCount(), 2); + assertEq(chreg.active(0), address(1)); + assertEq(chreg.active(1), address(3)); + } + + function test_activateMultiple_givenThirdDeactivated() public { + // Verify initial state + assertEq(chreg.registryCount(), 0); + assertEq(chreg.activeCount(), 0); + + vm.startPrank(godmode); + chreg.activateClearinghouse(address(1)); + chreg.activateClearinghouse(address(2)); + chreg.activateClearinghouse(address(3)); + chreg.deactivateClearinghouse(address(3)); + vm.stopPrank(); + + // Verify clearinghouse was activated + assertEq(chreg.registryCount(), 3); + assertEq(chreg.registry(0), address(1)); + assertEq(chreg.registry(1), address(2)); + assertEq(chreg.registry(2), address(3)); + assertEq(chreg.activeCount(), 2); + assertEq(chreg.active(0), address(1)); + assertEq(chreg.active(1), address(2)); + } + function test_addressIsNotRegisteredTwice() public { // Verify initial state assertEq(chreg.activeCount(), 0); diff --git a/src/test/modules/RGSTY.t.sol b/src/test/modules/RGSTY.t.sol new file mode 100644 index 000000000..a7308fdfe --- /dev/null +++ b/src/test/modules/RGSTY.t.sol @@ -0,0 +1,912 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {Test} from "forge-std/Test.sol"; +import {ModuleTestFixtureGenerator} from "src/test/lib/ModuleTestFixtureGenerator.sol"; +import {MockContractRegistryPolicy, MockImmutableContractRegistryPolicy} from "src/test/mocks/MockContractRegistryPolicy.sol"; + +import {Kernel, Actions, Module, fromKeycode} from "src/Kernel.sol"; +import {RGSTYv1} from "src/modules/RGSTY/RGSTY.v1.sol"; +import {OlympusContractRegistry} from "src/modules/RGSTY/OlympusContractRegistry.sol"; + +contract ContractRegistryTest is Test { + using ModuleTestFixtureGenerator for OlympusContractRegistry; + + address public godmode; + address public notOwner = address(0x1); + + address public addressOne = address(0x2); + address public addressTwo = address(0x3); + + Kernel internal _kernel; + OlympusContractRegistry internal RGSTY; + MockContractRegistryPolicy internal _policy; + MockImmutableContractRegistryPolicy internal _policyImmutable; + + // Contract Registry Expected events + event ContractRegistered( + bytes5 indexed name, + address indexed contractAddress, + bool isImmutable + ); + event ContractUpdated(bytes5 indexed name, address indexed contractAddress); + event ContractDeregistered(bytes5 indexed name); + + function setUp() public { + // Deploy Kernel and modules + // This contract is the owner + _kernel = new Kernel(); + RGSTY = new OlympusContractRegistry(address(_kernel)); + _policy = new MockContractRegistryPolicy(_kernel); + _policyImmutable = new MockImmutableContractRegistryPolicy(_kernel); + + // Generate fixtures + godmode = RGSTY.generateGodmodeFixture(type(OlympusContractRegistry).name); + + // Install modules and policies on Kernel + _kernel.executeAction(Actions.InstallModule, address(RGSTY)); + _kernel.executeAction(Actions.ActivatePolicy, godmode); + } + + function _registerImmutableContract(bytes5 name_, address contractAddress_) internal { + vm.prank(godmode); + RGSTY.registerImmutableContract(name_, contractAddress_); + } + + function _registerContract(bytes5 name_, address contractAddress_) internal { + vm.prank(godmode); + RGSTY.registerContract(name_, contractAddress_); + } + + function _deregisterContract(bytes5 name_) internal { + vm.prank(godmode); + RGSTY.deregisterContract(name_); + } + + function _updateContract(bytes5 name_, address contractAddress_) internal { + vm.prank(godmode); + RGSTY.updateContract(name_, contractAddress_); + } + + function _activatePolicyOne() internal { + _kernel.executeAction(Actions.ActivatePolicy, address(_policy)); + } + + function _activatePolicyImmutable() internal { + _kernel.executeAction(Actions.ActivatePolicy, address(_policyImmutable)); + } + + modifier givenImmutableContractIsRegistered(bytes5 name_, address contractAddress_) { + _registerImmutableContract(name_, contractAddress_); + _; + } + + modifier givenContractIsRegistered(bytes5 name_, address contractAddress_) { + _registerContract(name_, contractAddress_); + _; + } + + modifier givenContractIsDeregistered(bytes5 name_) { + _deregisterContract(name_); + _; + } + + modifier givenContractIsUpdated(bytes5 name_, address contractAddress_) { + _updateContract(name_, contractAddress_); + _; + } + + modifier givenPolicyOneIsActive() { + _activatePolicyOne(); + _; + } + + modifier givenPolicyImmutableIsActive() { + _activatePolicyImmutable(); + _; + } + + // ========= TESTS ========= // + + // constructor + // when the kernel address is zero + // [X] it reverts + // when the kernel address is not zero + // [X] it sets the kernel address + + function test_constructor_whenKernelAddressIsZero_reverts() public { + vm.expectRevert(abi.encodeWithSelector(RGSTYv1.Params_InvalidAddress.selector)); + + new OlympusContractRegistry(address(0)); + } + + function test_constructor_whenKernelAddressIsNotZero_reverts() public { + OlympusContractRegistry rgsty = new OlympusContractRegistry(address(1)); + + assertEq(address(rgsty.kernel()), address(1), "Kernel address is not set correctly"); + } + + // registerImmutableContract + // when the caller is not permissioned + // [X] it reverts + // when the name is empty + // [X] it reverts + // when the start of the name contains null characters + // [X] it reverts + // when the end of the name contains null characters + // [X] it succeeds + // when the contract address is zero + // [X] it reverts + // when the name is not lowercase + // [X] it reverts + // when the name contains punctuation + // [X] it reverts + // when the name contains a numeral + // [X] it succeeds + // given the name is registered + // [X] it reverts + // given the name is registered as mutable address + // [X] it reverts + // given the name is not registered + // given there are existing registrations + // [X] it updates the contract address, emits an event and updates the names array + // [X] it registers the contract address, emits an event and updates the names array + // given dependent policies are registered + // [X] it refreshes the dependents + + function test_registerImmutableContract_callerNotPermissioned_reverts() public { + vm.expectRevert( + abi.encodeWithSelector(Module.Module_PolicyNotPermitted.selector, notOwner) + ); + + vm.prank(notOwner); + RGSTY.registerImmutableContract(bytes5("ohm"), addressOne); + } + + function test_registerImmutableContract_whenNameIsEmpty_reverts() public { + vm.expectRevert(abi.encodeWithSelector(RGSTYv1.Params_InvalidName.selector)); + + _registerImmutableContract(bytes5(""), addressOne); + } + + function test_registerImmutableContract_whenNameStartsWithNullCharacters_reverts() public { + vm.expectRevert(abi.encodeWithSelector(RGSTYv1.Params_InvalidName.selector)); + + _registerImmutableContract(bytes5("\x00\x00ohm"), addressOne); + } + + function test_registerImmutableContract_whenNameContainsNullCharacters_reverts() public { + vm.expectRevert(abi.encodeWithSelector(RGSTYv1.Params_InvalidName.selector)); + + _registerImmutableContract(bytes5("o\x00\x00hm"), addressOne); + } + + function test_registerImmutableContract_whenNameEndsWithNullCharacters_succeeds() public { + _registerImmutableContract(bytes5("ohm\x00"), addressOne); + + assertEq(RGSTY.getImmutableContract(bytes5("ohm")), addressOne); + } + + function test_registerImmutableContract_whenNameIsZero_reverts() public { + vm.expectRevert(abi.encodeWithSelector(RGSTYv1.Params_InvalidName.selector)); + + _registerImmutableContract(bytes5(0), addressOne); + } + + function test_registerImmutableContract_whenNameIsNotLowercase_reverts() public { + vm.expectRevert(abi.encodeWithSelector(RGSTYv1.Params_InvalidName.selector)); + _registerImmutableContract(bytes5("Ohm"), addressOne); + + vm.expectRevert(abi.encodeWithSelector(RGSTYv1.Params_InvalidName.selector)); + _registerImmutableContract(bytes5("oHm"), addressOne); + + vm.expectRevert(abi.encodeWithSelector(RGSTYv1.Params_InvalidName.selector)); + _registerImmutableContract(bytes5("ohM"), addressOne); + } + + function test_registerImmutableContract_whenNameContainsPunctuation_reverts() public { + vm.expectRevert(abi.encodeWithSelector(RGSTYv1.Params_InvalidName.selector)); + _registerImmutableContract(bytes5("ohm!"), addressOne); + + vm.expectRevert(abi.encodeWithSelector(RGSTYv1.Params_InvalidName.selector)); + _registerImmutableContract(bytes5("ohm "), addressOne); + + vm.expectRevert(abi.encodeWithSelector(RGSTYv1.Params_InvalidName.selector)); + _registerImmutableContract(bytes5("ohm-"), addressOne); + } + + function test_registerImmutableContract_whenNameContainsNumeral() public { + _registerImmutableContract(bytes5("ohm1"), addressOne); + + assertEq(RGSTY.getImmutableContract(bytes5("ohm1")), addressOne); + } + + function test_registerImmutableContract_whenContractAddressIsZero_reverts() public { + vm.expectRevert(abi.encodeWithSelector(RGSTYv1.Params_InvalidAddress.selector)); + + _registerImmutableContract(bytes5("ohm"), address(0)); + } + + function test_registerImmutableContract_whenNameIsRegistered_reverts() + public + givenContractIsRegistered(bytes5("ohm"), addressOne) + { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(RGSTYv1.Params_ContractAlreadyRegistered.selector)); + + // Register the second time + _registerImmutableContract(bytes5("ohm"), addressTwo); + } + + function test_registerImmutableContract_whenImmutableNameIsRegistered_reverts() + public + givenImmutableContractIsRegistered(bytes5("ohm"), addressOne) + { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(RGSTYv1.Params_ContractAlreadyRegistered.selector)); + + // Register the second time + _registerImmutableContract(bytes5("ohm"), addressTwo); + } + + function test_registerImmutableContract_whenNameIsNotRegistered() public { + // Expect an event to be emitted for updated registration + vm.expectEmit(); + emit ContractRegistered(bytes5("ohm"), addressOne, true); + + // Register the first time + _registerImmutableContract(bytes5("ohm"), addressOne); + + assertEq( + RGSTY.getImmutableContract(bytes5("ohm")), + addressOne, + "Contract address is not set correctly" + ); + assertEq( + RGSTY.getImmutableContractNames().length, + 1, + "Immutable names array is not updated correctly" + ); + assertEq( + RGSTY.getImmutableContractNames()[0], + bytes5("ohm"), + "Immutable names array is not updated correctly" + ); + assertEq(RGSTY.getContractNames().length, 0, "Names array is not updated correctly"); + } + + function test_registerImmutableContract_whenOtherNamesAreRegistered() + public + givenImmutableContractIsRegistered(bytes5("ohm"), addressOne) + givenImmutableContractIsRegistered(bytes5("ohm2"), addressTwo) + givenImmutableContractIsRegistered(bytes5("ohm3"), address(0x4)) + { + // Assert values + assertEq( + RGSTY.getImmutableContract(bytes5("ohm")), + addressOne, + "ohm contract address is not set correctly" + ); + assertEq( + RGSTY.getImmutableContract(bytes5("ohm2")), + addressTwo, + "ohm2 contract address is not set correctly" + ); + assertEq( + RGSTY.getImmutableContract(bytes5("ohm3")), + address(0x4), + "ohm3 contract address is not set correctly" + ); + assertEq( + RGSTY.getImmutableContractNames().length, + 3, + "Immutable names array is not updated correctly" + ); + assertEq( + RGSTY.getImmutableContractNames()[0], + bytes5("ohm"), + "Immutable names array is not updated correctly" + ); + assertEq( + RGSTY.getImmutableContractNames()[1], + bytes5("ohm2"), + "Immutable names array is not updated correctly" + ); + assertEq( + RGSTY.getImmutableContractNames()[2], + bytes5("ohm3"), + "Immutable names array is not updated correctly" + ); + assertEq(RGSTY.getContractNames().length, 0, "Names array is not updated correctly"); + } + + function test_registerImmutableContract_activatePolicy_whenContractIsRegistered() public { + // Register the contract + _registerImmutableContract(bytes5("dai"), addressOne); + + assertEq(_policyImmutable.dai(), address(0)); + + // Activate the dependent policy + _activatePolicyImmutable(); + + assertEq(_policyImmutable.dai(), addressOne); + } + + function test_registerImmutableContract_activatePolicies_whenContractNotRegistered_reverts() + public + { + // Expect the policy to revert + vm.expectRevert(abi.encodeWithSelector(RGSTYv1.Params_ContractNotRegistered.selector)); + + // Activate the dependent policies + _activatePolicyImmutable(); + } + + // registerContract + // when the caller is not permissioned + // [X] it reverts + // when the name is empty + // [X] it reverts + // when the start of the name contains null characters + // [X] it reverts + // when the end of the name contains null characters + // [X] it succeeds + // when the contract address is zero + // [X] it reverts + // when the name is not lowercase + // [X] it reverts + // when the name contains punctuation + // [X] it reverts + // when the name contains a numeral + // [X] it succeeds + // given the name is registered + // [X] it reverts + // given the name is registered as an immutable address + // [X] it reverts + // given the name is not registered + // given there are existing registrations + // [X] it updates the contract address, emits an event and updates the names array + // [X] it registers the contract address, emits an event and updates the names array + // given dependent policies are registered + // [X] it refreshes the dependents + + function test_registerContract_callerNotPermissioned_reverts() public { + vm.expectRevert( + abi.encodeWithSelector(Module.Module_PolicyNotPermitted.selector, notOwner) + ); + + vm.prank(notOwner); + RGSTY.registerContract(bytes5("ohm"), addressOne); + } + + function test_registerContract_whenNameIsEmpty_reverts() public { + vm.expectRevert(abi.encodeWithSelector(RGSTYv1.Params_InvalidName.selector)); + + _registerContract(bytes5(""), addressOne); + } + + function test_registerContract_whenNameStartsWithNullCharacters_reverts() public { + vm.expectRevert(abi.encodeWithSelector(RGSTYv1.Params_InvalidName.selector)); + + _registerContract(bytes5("\x00\x00ohm"), addressOne); + } + + function test_registerContract_whenNameContainsNullCharacters_reverts() public { + vm.expectRevert(abi.encodeWithSelector(RGSTYv1.Params_InvalidName.selector)); + + _registerContract(bytes5("o\x00\x00hm"), addressOne); + } + + function test_registerContract_whenNameEndsWithNullCharacters_succeeds() public { + _registerContract(bytes5("ohm\x00"), addressOne); + + assertEq(RGSTY.getContract(bytes5("ohm")), addressOne); + } + + function test_registerContract_whenNameIsZero_reverts() public { + vm.expectRevert(abi.encodeWithSelector(RGSTYv1.Params_InvalidName.selector)); + + _registerContract(bytes5(0), addressOne); + } + + function test_registerContract_whenNameIsNotLowercase_reverts() public { + vm.expectRevert(abi.encodeWithSelector(RGSTYv1.Params_InvalidName.selector)); + _registerContract(bytes5("Ohm"), addressOne); + + vm.expectRevert(abi.encodeWithSelector(RGSTYv1.Params_InvalidName.selector)); + _registerContract(bytes5("oHm"), addressOne); + + vm.expectRevert(abi.encodeWithSelector(RGSTYv1.Params_InvalidName.selector)); + _registerContract(bytes5("ohM"), addressOne); + } + + function test_registerContract_whenNameContainsPunctuation_reverts() public { + vm.expectRevert(abi.encodeWithSelector(RGSTYv1.Params_InvalidName.selector)); + _registerContract(bytes5("ohm!"), addressOne); + + vm.expectRevert(abi.encodeWithSelector(RGSTYv1.Params_InvalidName.selector)); + _registerContract(bytes5("ohm "), addressOne); + + vm.expectRevert(abi.encodeWithSelector(RGSTYv1.Params_InvalidName.selector)); + _registerContract(bytes5("ohm-"), addressOne); + } + + function test_registerContract_whenNameContainsNumeral() public { + _registerContract(bytes5("ohm1"), addressOne); + + assertEq(RGSTY.getContract(bytes5("ohm1")), addressOne); + } + + function test_registerContract_whenContractAddressIsZero_reverts() public { + vm.expectRevert(abi.encodeWithSelector(RGSTYv1.Params_InvalidAddress.selector)); + + _registerContract(bytes5("ohm"), address(0)); + } + + function test_registerContract_whenImmutableNameIsRegistered_reverts() + public + givenImmutableContractIsRegistered(bytes5("ohm"), addressOne) + { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(RGSTYv1.Params_ContractAlreadyRegistered.selector)); + + // Register the second time + _registerContract(bytes5("ohm"), addressTwo); + } + + function test_registerContract_whenNameIsRegistered_reverts() + public + givenContractIsRegistered(bytes5("ohm"), addressOne) + { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(RGSTYv1.Params_ContractAlreadyRegistered.selector)); + + // Register the second time + _registerContract(bytes5("ohm"), addressTwo); + } + + function test_registerContract_whenNameIsNotRegistered() public { + // Expect an event to be emitted for updated registration + vm.expectEmit(); + emit ContractRegistered(bytes5("ohm"), addressOne, false); + + // Register the first time + _registerContract(bytes5("ohm"), addressOne); + + assertEq( + RGSTY.getContract(bytes5("ohm")), + addressOne, + "Contract address is not set correctly" + ); + assertEq(RGSTY.getContractNames().length, 1, "Names array is not updated correctly"); + assertEq( + RGSTY.getContractNames()[0], + bytes5("ohm"), + "Names array is not updated correctly" + ); + assertEq( + RGSTY.getImmutableContractNames().length, + 0, + "Immutable names array is not updated correctly" + ); + } + + function test_registerContract_whenOtherNamesAreRegistered() + public + givenContractIsRegistered(bytes5("ohm"), addressOne) + givenContractIsRegistered(bytes5("ohm2"), addressTwo) + givenContractIsRegistered(bytes5("ohm3"), address(0x4)) + { + // Assert values + assertEq( + RGSTY.getContract(bytes5("ohm")), + addressOne, + "ohm contract address is not set correctly" + ); + assertEq( + RGSTY.getContract(bytes5("ohm2")), + addressTwo, + "ohm2 contract address is not set correctly" + ); + assertEq( + RGSTY.getContract(bytes5("ohm3")), + address(0x4), + "ohm3 contract address is not set correctly" + ); + assertEq(RGSTY.getContractNames().length, 3, "Names array is not updated correctly"); + assertEq( + RGSTY.getContractNames()[0], + bytes5("ohm"), + "Names array is not updated correctly" + ); + assertEq( + RGSTY.getContractNames()[1], + bytes5("ohm2"), + "Names array is not updated correctly" + ); + assertEq( + RGSTY.getContractNames()[2], + bytes5("ohm3"), + "Names array is not updated correctly" + ); + assertEq( + RGSTY.getImmutableContractNames().length, + 0, + "Immutable names array is not updated correctly" + ); + } + + function test_registerContract_activatePolicies_whenContractIsRegistered() public { + // Register the contract + _registerContract(bytes5("dai"), addressOne); + + assertEq(_policy.dai(), address(0)); + + // Activate the dependent policies + _activatePolicyOne(); + + assertEq(_policy.dai(), addressOne); + } + + function test_registerContract_activatePolicies_whenContractNotRegistered_reverts() public { + // Expect the policy to revert + vm.expectRevert(abi.encodeWithSelector(RGSTYv1.Params_ContractNotRegistered.selector)); + + // Activate the dependent policies + _activatePolicyOne(); + } + + // updateContract + // when the caller is not permissioned + // [X] it reverts + // when the name is not registered + // [X] it reverts + // when the address is zero + // [X] it reverts + // when the name is registered as an immutable address + // [X] it reverts + // given dependent policies are registered + // [X] it refreshes the dependents + // [X] it updates the contract address + + function test_updateContract_callerNotPermissioned_reverts() public { + vm.expectRevert( + abi.encodeWithSelector(Module.Module_PolicyNotPermitted.selector, notOwner) + ); + + vm.prank(notOwner); + RGSTY.updateContract(bytes5("ohm"), addressOne); + } + + function test_updateContract_whenNameIsNotRegistered_reverts() public { + vm.expectRevert(abi.encodeWithSelector(RGSTYv1.Params_ContractNotRegistered.selector)); + + _updateContract(bytes5("ohm"), addressOne); + } + + function test_updateContract_whenContractAddressIsZero_reverts() + public + givenContractIsRegistered(bytes5("ohm"), addressOne) + { + vm.expectRevert(abi.encodeWithSelector(RGSTYv1.Params_InvalidAddress.selector)); + + _updateContract(bytes5("ohm"), address(0)); + } + + function test_updateContract_whenImmutableNameIsRegistered_reverts() + public + givenImmutableContractIsRegistered(bytes5("ohm"), addressOne) + { + vm.expectRevert(abi.encodeWithSelector(RGSTYv1.Params_ContractNotRegistered.selector)); + + _updateContract(bytes5("ohm"), addressOne); + } + + function test_updateContract() public givenContractIsRegistered(bytes5("ohm"), addressOne) { + // Expect an event to be emitted + vm.expectEmit(); + emit ContractUpdated(bytes5("ohm"), addressTwo); + + // Update the contract + _updateContract(bytes5("ohm"), addressTwo); + + // Assert values + assertEq( + RGSTY.getContract(bytes5("ohm")), + addressTwo, + "Contract address is not updated correctly" + ); + } + + function test_updateContract_whenDependentPolicyIsRegistered() + public + givenContractIsRegistered(bytes5("dai"), addressOne) + givenPolicyOneIsActive + { + // Update the contract + _updateContract(bytes5("dai"), addressTwo); + + // Assert values in the policies have been updated + assertEq(_policy.dai(), addressTwo); + } + + // deregisterContract + // when the caller is not permissioned + // [X] it reverts + // given the name is not registered + // [X] it reverts + // given the name is registered as an immutable address + // [X] it reverts + // given the name is registered + // given multiple names are registered + // [X] it deregisters the name, emits an event and updates the names array + // [X] it deregisters the name, emits an event and updates the names array + // given dependent policies are registered + // given one of the required contracts is deregistered + // [X] it reverts + // [X] it refreshes the dependents + + function test_deregisterContract_whenCallerIsNotPermissioned_reverts() public { + // Register the first time + _registerContract(bytes5("ohm"), addressOne); + + vm.expectRevert( + abi.encodeWithSelector(Module.Module_PolicyNotPermitted.selector, notOwner) + ); + + vm.prank(notOwner); + RGSTY.deregisterContract(bytes5("ohm")); + } + + function test_deregisterContract_whenNameIsNotRegistered_reverts() public { + vm.expectRevert(abi.encodeWithSelector(RGSTYv1.Params_ContractNotRegistered.selector)); + + _deregisterContract(bytes5("")); + } + + function test_deregisterContract_whenImmutableNameIsRegistered_reverts() + public + givenImmutableContractIsRegistered(bytes5("ohm"), addressOne) + { + vm.expectRevert(abi.encodeWithSelector(RGSTYv1.Params_ContractNotRegistered.selector)); + + _deregisterContract(bytes5("")); + } + + function test_deregisterContract_whenNameIsRegistered() public { + // Register the first time + _registerContract(bytes5("ohm"), addressOne); + + // Deregister the first time + _deregisterContract(bytes5("ohm")); + + // Assert values + // Deregistered contract should revert + vm.expectRevert(abi.encodeWithSelector(RGSTYv1.Params_ContractNotRegistered.selector)); + RGSTY.getContract(bytes5("ohm")); + + // Names array should be empty + assertEq(RGSTY.getContractNames().length, 0, "Names array is not updated correctly"); + } + + function test_deregisterContract_whenMultipleNamesAreRegistered(uint256 index_) public { + uint256 randomIndex = bound(index_, 0, 2); + + bytes5[] memory names = new bytes5[](3); + names[0] = bytes5("ohm"); + names[1] = bytes5("ohm2"); + names[2] = bytes5("ohm3"); + + // Register the first time + _registerContract(names[0], addressOne); + + // Register the second time + _registerContract(names[1], addressTwo); + + // Register the third time + _registerContract(names[2], address(0x4)); + + // Deregister a random name + _deregisterContract(names[randomIndex]); + + // Assert values + // Deregistered contract should revert + vm.expectRevert(abi.encodeWithSelector(RGSTYv1.Params_ContractNotRegistered.selector)); + RGSTY.getContract(names[randomIndex]); + + // Other contracts should still be registered + if (randomIndex != 0) { + assertEq( + RGSTY.getContract(names[0]), + addressOne, + "ohm contract address is not set correctly" + ); + } + if (randomIndex != 1) { + assertEq( + RGSTY.getContract(names[1]), + addressTwo, + "ohm2 contract address is not set correctly" + ); + } + if (randomIndex != 2) { + assertEq( + RGSTY.getContract(names[2]), + address(0x4), + "ohm3 contract address is not set correctly" + ); + } + + // Names array should be updated + bytes5[] memory expectedNames = new bytes5[](2); + uint256 expectedIndex = 0; + for (uint256 i = 0; i < 3; i++) { + if (i != randomIndex) { + expectedNames[expectedIndex] = names[i]; + expectedIndex++; + } + } + + bytes5[] memory contractNames = RGSTY.getContractNames(); + assertEq(RGSTY.getContractNames().length, 2, "Names array is not updated correctly"); + + // Check that the expected names are in the array + // This is done as the order of names in the array is not guaranteed + for (uint256 i = 0; i < expectedNames.length; i++) { + bool found = false; + for (uint256 j = 0; j < contractNames.length; j++) { + if (expectedNames[i] == contractNames[j]) { + found = true; + break; + } + } + assertEq(found, true, "Names array is not updated correctly"); + } + } + + function test_deregisterContract_whenDependentPolicyIsRegistered_reverts() + public + givenContractIsRegistered(bytes5("dai"), addressOne) + givenPolicyOneIsActive + { + // Expect the policies to revert + vm.expectRevert(abi.encodeWithSelector(RGSTYv1.Params_ContractNotRegistered.selector)); + + // Deregister the contract + _deregisterContract(bytes5("dai")); + } + + function test_deregisterContract_whenDependentPolicyIsRegistered() + public + givenContractIsRegistered(bytes5("ohm"), addressOne) + { + // Deregister the contract + _deregisterContract(bytes5("ohm")); + + // Assert values + assertEq(_policy.dai(), address(0)); + } + + // getImmutableContract + // given the name is not registered + // [X] it reverts + // [X] it returns the contract address + + function test_getImmutableContract_whenNameIsNotRegistered_reverts() public { + vm.expectRevert(abi.encodeWithSelector(RGSTYv1.Params_ContractNotRegistered.selector)); + + RGSTY.getImmutableContract(bytes5("ohm")); + } + + function test_getImmutableContract() + public + givenImmutableContractIsRegistered(bytes5("ohm"), addressOne) + { + assertEq(RGSTY.getImmutableContract(bytes5("ohm")), addressOne); + } + + // getContract + // given the name is not registered + // [X] it reverts + // given the name is registered + // given the name has been updated + // [X] it returns the latest address + // [X] it returns the contract address + + function test_getContract_whenNameIsNotRegistered_reverts() public { + vm.expectRevert(abi.encodeWithSelector(RGSTYv1.Params_ContractNotRegistered.selector)); + + RGSTY.getContract(bytes5("ohm")); + } + + function test_getContract() public givenContractIsRegistered(bytes5("ohm"), addressOne) { + assertEq( + RGSTY.getContract(bytes5("ohm")), + addressOne, + "Contract address is not set correctly" + ); + } + + function test_getContract_whenNameIsUpdated() + public + givenContractIsRegistered(bytes5("ohm"), addressOne) + givenContractIsUpdated(bytes5("ohm"), addressTwo) + { + assertEq( + RGSTY.getContract(bytes5("ohm")), + addressTwo, + "Contract address is not updated correctly" + ); + } + + // getImmutableContractNames + // given no names are registered + // [X] it returns an empty array + // given names are registered + // [X] it returns the names array + + function test_getImmutableContractNames_whenNoNamesAreRegistered() public { + assertEq(RGSTY.getImmutableContractNames().length, 0, "Immutable names array is not empty"); + } + + function test_getImmutableContractNames() + public + givenImmutableContractIsRegistered(bytes5("ohm"), addressOne) + { + assertEq( + RGSTY.getImmutableContractNames().length, + 1, + "Immutable names array is not updated correctly" + ); + } + + // getContractNames + // given no names are registered + // [X] it returns an empty array + // given names are registered + // [X] it returns the names array + + function test_getContractNames_whenNoNamesAreRegistered() public { + assertEq(RGSTY.getContractNames().length, 0, "Names array is not empty"); + } + + function test_getContractNames_whenNamesAreRegistered() + public + givenContractIsRegistered(bytes5("ohm"), addressOne) + givenContractIsRegistered(bytes5("ohm2"), addressTwo) + givenContractIsRegistered(bytes5("ohm3"), address(0x4)) + { + assertEq(RGSTY.getContractNames().length, 3, "Names array is not updated correctly"); + assertEq( + RGSTY.getContractNames()[0], + bytes5("ohm"), + "Names array at index 0 is not updated correctly" + ); + assertEq( + RGSTY.getContractNames()[1], + bytes5("ohm2"), + "Names array at index 1 is not updated correctly" + ); + assertEq( + RGSTY.getContractNames()[2], + bytes5("ohm3"), + "Names array at index 2 is not updated correctly" + ); + } + + // KEYCODE + // [X] it returns the correct keycode + + function test_KEYCODE() public { + assertEq(fromKeycode(RGSTY.KEYCODE()), bytes5("RGSTY")); + } + + // VERSION + // [X] it returns the correct version + + function test_VERSION() public { + (uint8 major, uint8 minor) = RGSTY.VERSION(); + assertEq(major, 1); + assertEq(minor, 0); + } +} diff --git a/src/test/policies/Clearinghouse.t.sol b/src/test/policies/Clearinghouse.t.sol index 8a8a2c36c..def0ec9bc 100644 --- a/src/test/policies/Clearinghouse.t.sol +++ b/src/test/policies/Clearinghouse.t.sol @@ -48,8 +48,8 @@ import {Clearinghouse, Cooler, CoolerFactory, CoolerCallback} from "policies/Cle // [X] lendToCooler // [X] only lend to coolers issued by coolerFactory. // [X] only collateral = gOHM + only debt = DAI. -// [x] user and cooler new gOHM balances are correct. -// [x] user and cooler new DAI balances are correct. +// [X] user and cooler new gOHM balances are correct. +// [X] user and cooler new DAI balances are correct. // [X] extendLoan // [X] only roll coolers issued by coolerFactory. // [X] roll by adding more collateral. diff --git a/src/test/policies/ContractRegistryAdmin.t.sol b/src/test/policies/ContractRegistryAdmin.t.sol new file mode 100644 index 000000000..b9726b1dd --- /dev/null +++ b/src/test/policies/ContractRegistryAdmin.t.sol @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: Unlicensed +pragma solidity 0.8.15; + +import {Test} from "forge-std/Test.sol"; + +import {Kernel, Actions} from "src/Kernel.sol"; +import {ROLESv1} from "src/modules/ROLES/ROLES.v1.sol"; +import {OlympusRoles} from "src/modules/ROLES/OlympusRoles.sol"; +import {RolesAdmin} from "src/policies/RolesAdmin.sol"; +import {RGSTYv1} from "src/modules/RGSTY/RGSTY.v1.sol"; +import {OlympusContractRegistry} from "src/modules/RGSTY/OlympusContractRegistry.sol"; +import {ContractRegistryAdmin} from "src/policies/ContractRegistryAdmin.sol"; + +contract ContractRegistryAdminTest is Test { + Kernel public kernel; + OlympusContractRegistry public RGSTY; + ContractRegistryAdmin public rgstyAdmin; + OlympusRoles public ROLES; + RolesAdmin public rolesAdmin; + + address public admin = address(0x1); + address public notAdmin = address(0x2); + address public ohm = address(0x3); + + bytes32 public RGSTY_ROLE = "contract_registry_admin"; + + function setUp() public { + kernel = new Kernel(); + + // Install the ROLES module + ROLES = new OlympusRoles(kernel); + kernel.executeAction(Actions.InstallModule, address(ROLES)); + + // Install the RolesAdmin policy + rolesAdmin = new RolesAdmin(kernel); + kernel.executeAction(Actions.ActivatePolicy, address(rolesAdmin)); + + // Install the RGSTY module + RGSTY = new OlympusContractRegistry(address(kernel)); + kernel.executeAction(Actions.InstallModule, address(RGSTY)); + + // Set up the ContractRegistryAdmin policy + rgstyAdmin = new ContractRegistryAdmin(address(kernel)); + } + + modifier givenPolicyIsActivated() { + kernel.executeAction(Actions.ActivatePolicy, address(rgstyAdmin)); + _; + } + + modifier givenAdminHasRole() { + rolesAdmin.grantRole(RGSTY_ROLE, admin); + _; + } + + modifier givenContractIsRegistered() { + vm.prank(admin); + rgstyAdmin.registerContract("ohm", ohm); + _; + } + + // ===== TESTS ===== // + + // registerImmutableContract + // when the policy is not active + // [X] it reverts + // when the caller does not have the role + // [X] it reverts + // [X] it registers the contract + + function test_registerImmutableContract_policyNotActive_reverts() public { + vm.expectRevert(abi.encodeWithSelector(ContractRegistryAdmin.OnlyPolicyActive.selector)); + + rgstyAdmin.registerImmutableContract("ohm", ohm); + } + + function test_registerImmutableContract_callerDoesNotHaveRole_reverts() + public + givenPolicyIsActivated + { + vm.expectRevert(abi.encodeWithSelector(ROLESv1.ROLES_RequireRole.selector, RGSTY_ROLE)); + + vm.prank(notAdmin); + rgstyAdmin.registerImmutableContract("ohm", ohm); + } + + function test_registerImmutableContract() public givenPolicyIsActivated givenAdminHasRole { + vm.prank(admin); + rgstyAdmin.registerImmutableContract("ohm", ohm); + + assertEq(RGSTY.getImmutableContract("ohm"), ohm, "contract address"); + } + + // registerContract + // when the policy is not active + // [X] it reverts + // when the caller does not have the role + // [X] it reverts + // [X] it registers the contract + + function test_registerContract_policyNotActive_reverts() public { + vm.expectRevert(abi.encodeWithSelector(ContractRegistryAdmin.OnlyPolicyActive.selector)); + + rgstyAdmin.registerContract("ohm", ohm); + } + + function test_registerContract_callerDoesNotHaveRole_reverts() + public + givenPolicyIsActivated + givenAdminHasRole + { + vm.expectRevert(abi.encodeWithSelector(ROLESv1.ROLES_RequireRole.selector, RGSTY_ROLE)); + + vm.prank(notAdmin); + rgstyAdmin.registerContract("ohm", ohm); + } + + function test_registerContract() + public + givenPolicyIsActivated + givenAdminHasRole + givenContractIsRegistered + { + assertEq(RGSTY.getContract("ohm"), ohm, "contract address"); + } + + // updateContract + // when the policy is not active + // [X] it reverts + // when the caller does not have the role + // [X] it reverts + // [X] it updates the contract + + function test_updateContract_policyNotActive_reverts() public { + vm.expectRevert(abi.encodeWithSelector(ContractRegistryAdmin.OnlyPolicyActive.selector)); + + rgstyAdmin.updateContract("ohm", ohm); + } + + function test_updateContract_callerDoesNotHaveRole_reverts() + public + givenPolicyIsActivated + givenAdminHasRole + { + vm.expectRevert(abi.encodeWithSelector(ROLESv1.ROLES_RequireRole.selector, RGSTY_ROLE)); + + vm.prank(notAdmin); + rgstyAdmin.updateContract("ohm", ohm); + } + + function test_updateContract() + public + givenPolicyIsActivated + givenAdminHasRole + givenContractIsRegistered + { + // Update the contract + vm.prank(admin); + rgstyAdmin.updateContract("ohm", address(0x4)); + + // Assert values + assertEq(RGSTY.getContract("ohm"), address(0x4), "contract address"); + } + + // deregisterContract + // when the policy is not active + // [X] it reverts + // when the caller does not have the role + // [X] it reverts + // [X] it deregisters the contract + + function test_deregisterContract_policyNotActive_reverts() public { + vm.expectRevert(abi.encodeWithSelector(ContractRegistryAdmin.OnlyPolicyActive.selector)); + + rgstyAdmin.deregisterContract("ohm"); + } + + function test_deregisterContract_callerDoesNotHaveRole_reverts() + public + givenPolicyIsActivated + givenAdminHasRole + givenContractIsRegistered + { + vm.expectRevert(abi.encodeWithSelector(ROLESv1.ROLES_RequireRole.selector, RGSTY_ROLE)); + + vm.prank(notAdmin); + rgstyAdmin.deregisterContract("ohm"); + } + + function test_deregisterContract() + public + givenPolicyIsActivated + givenAdminHasRole + givenContractIsRegistered + { + // Deregister the contract + vm.prank(admin); + rgstyAdmin.deregisterContract("ohm"); + + // Assert values + vm.expectRevert(RGSTYv1.Params_ContractNotRegistered.selector); + RGSTY.getContract("ohm"); + } +} diff --git a/src/test/policies/LoanConsolidatorFork.t.sol b/src/test/policies/LoanConsolidatorFork.t.sol new file mode 100644 index 000000000..4009085a0 --- /dev/null +++ b/src/test/policies/LoanConsolidatorFork.t.sol @@ -0,0 +1,3729 @@ +// SPDX-License-Identifier: GLP-3.0 +pragma solidity ^0.8.15; + +import {Test, console2} from "forge-std/Test.sol"; +import {MockFlashloanLender} from "src/test/mocks/MockFlashloanLender.sol"; + +import {ERC20} from "solmate/tokens/ERC20.sol"; +import {IERC4626} from "forge-std/interfaces/IERC4626.sol"; + +import {IERC3156FlashBorrower} from "src/interfaces/maker-dao/IERC3156FlashBorrower.sol"; +import {IERC3156FlashLender} from "src/interfaces/maker-dao/IERC3156FlashLender.sol"; +import {CoolerFactory} from "src/external/cooler/CoolerFactory.sol"; +import {Clearinghouse} from "src/policies/Clearinghouse.sol"; +import {Cooler} from "src/external/cooler/Cooler.sol"; + +import {OlympusContractRegistry} from "src/modules/RGSTY/OlympusContractRegistry.sol"; +import {ContractRegistryAdmin} from "src/policies/ContractRegistryAdmin.sol"; +import {ROLESv1} from "src/modules/ROLES/ROLES.v1.sol"; +import {RolesAdmin} from "src/policies/RolesAdmin.sol"; +import {TRSRYv1} from "src/modules/TRSRY/TRSRY.v1.sol"; +import {CHREGv1} from "src/modules/CHREG/CHREG.v1.sol"; +import {OlympusClearinghouseRegistry} from "src/modules/CHREG/OlympusClearinghouseRegistry.sol"; +import {Kernel, Actions, toKeycode, Module} from "src/Kernel.sol"; +import {ClonesWithImmutableArgs} from "clones/ClonesWithImmutableArgs.sol"; + +import {LoanConsolidator} from "src/policies/LoanConsolidator.sol"; + +import {ClearinghouseLowerLTC} from "src/test/lib/ClearinghouseLowerLTC.sol"; +import {ClearinghouseHigherLTC} from "src/test/lib/ClearinghouseHigherLTC.sol"; + +contract LoanConsolidatorForkTest is Test { + using ClonesWithImmutableArgs for address; + + LoanConsolidator public utils; + + ERC20 public ohm; + ERC20 public gohm; + ERC20 public dai; + ERC20 public usds; + IERC4626 public sdai; + IERC4626 public susds; + + CoolerFactory public coolerFactory; + Clearinghouse public clearinghouse; + Clearinghouse public clearinghouseUsds; + + OlympusContractRegistry public RGSTY; + ContractRegistryAdmin public rgstyAdmin; + RolesAdmin public rolesAdmin; + TRSRYv1 public TRSRY; + CHREGv1 public CHREG; + Kernel public kernel; + + address public staking; + address public lender; + address public daiUsdsMigrator; + address public admin; + address public emergency; + address public kernelExecutor; + + address public walletA; + address public walletB; + Cooler public coolerA; + Cooler public coolerB; + + uint256 internal constant _GOHM_AMOUNT = 3_333 * 1e18; + uint256 internal constant _ONE_HUNDRED_PERCENT = 100e2; + + uint256 internal trsryDaiBalance; + uint256 internal trsryGOhmBalance; + uint256 internal trsrySDaiBalance; + uint256 internal trsryUsdsBalance; + uint256 internal trsrySusdsBalance; + string RPC_URL = vm.envString("FORK_TEST_RPC_URL"); + + // These are replicated here so that if they are updated, the tests will fail + bytes32 public constant ROLE_ADMIN = "loan_consolidator_admin"; + bytes32 public constant ROLE_EMERGENCY_SHUTDOWN = "emergency_shutdown"; + + function setUp() public { + // Mainnet Fork at a fixed block + // After sUSDS deployment + vm.createSelectFork(RPC_URL, 20900000); + + // Required Contracts + coolerFactory = CoolerFactory(0x30Ce56e80aA96EbbA1E1a74bC5c0FEB5B0dB4216); + clearinghouse = Clearinghouse(0xE6343ad0675C9b8D3f32679ae6aDbA0766A2ab4c); + + ohm = ERC20(0x64aa3364F17a4D01c6f1751Fd97C2BD3D7e7f1D5); + gohm = ERC20(0x0ab87046fBb341D058F17CBC4c1133F25a20a52f); + dai = ERC20(0x6B175474E89094C44Da98b954EedeAC495271d0F); + usds = ERC20(0xdC035D45d973E3EC169d2276DDab16f1e407384F); + sdai = IERC4626(0x83F20F44975D03b1b09e64809B757c47f942BEeA); + susds = IERC4626(0xa3931d71877C0E7a3148CB7Eb4463524FEc27fbD); + lender = 0x60744434d6339a6B27d73d9Eda62b6F66a0a04FA; + staking = 0xB63cac384247597756545b500253ff8E607a8020; + daiUsdsMigrator = 0x3225737a9Bbb6473CB4a45b7244ACa2BeFdB276A; + + kernel = Kernel(0x2286d7f9639e8158FaD1169e76d1FbC38247f54b); + rolesAdmin = RolesAdmin(0xb216d714d91eeC4F7120a732c11428857C659eC8); + TRSRY = TRSRYv1(address(kernel.getModuleForKeycode(toKeycode("TRSRY")))); + CHREG = CHREGv1(address(kernel.getModuleForKeycode(toKeycode("CHREG")))); + + // Deposit sUSDS in TRSRY + deal(address(susds), address(TRSRY), 18_000_000 * 1e18); + + // Determine the kernel executor + kernelExecutor = Kernel(kernel).executor(); + + // CHREG v1 (0x24b96f2150BF1ed10D3e8B28Ed33E392fbB4Cad5) has a bug with the registryCount. If the version is 1.0, mimic upgrading the module + if (address(CHREG) == 0x24b96f2150BF1ed10D3e8B28Ed33E392fbB4Cad5) { + console2.log("CHREG v1.0 detected, upgrading to current version..."); + + // Determine the active clearinghouse + uint256 activeClearinghouseCount = CHREG.activeCount(); + address activeClearinghouse; + if (activeClearinghouseCount >= 1) { + activeClearinghouse = CHREG.active(0); + console2.log("Setting active clearinghouse to:", activeClearinghouse); + + // CHREG only accepts one active clearinghouse + activeClearinghouseCount = 1; + } + + // Determine the inactive clearinghouses + uint256 inactiveClearinghouseCount = CHREG.registryCount(); + address[] memory inactiveClearinghouses = new address[]( + inactiveClearinghouseCount - activeClearinghouseCount + ); + for (uint256 i = 0; i < inactiveClearinghouseCount; i++) { + // Skip if active, as the constructor will check for duplicates + if (CHREG.registry(i) == activeClearinghouse) continue; + + inactiveClearinghouses[i] = CHREG.registry(i); + } + + // Deploy the current version of CHREG + CHREG = new OlympusClearinghouseRegistry( + kernel, + activeClearinghouse, + inactiveClearinghouses + ); + + // Upgrade the module + vm.prank(kernelExecutor); + kernel.executeAction(Actions.UpgradeModule, address(CHREG)); + } + + // Install RGSTY (since block is pinned, it won't be installed) + RGSTY = new OlympusContractRegistry(address(kernel)); + vm.prank(kernelExecutor); + kernel.executeAction(Actions.InstallModule, address(RGSTY)); + + // Set up and install the contract registry admin policy + rgstyAdmin = new ContractRegistryAdmin(address(kernel)); + vm.prank(kernelExecutor); + kernel.executeAction(Actions.ActivatePolicy, address(rgstyAdmin)); + + // Grant the contract registry admin role to this contract + vm.prank(kernelExecutor); + rolesAdmin.grantRole("contract_registry_admin", address(this)); + + // Grant the cooler overseer role to this contract + vm.prank(kernelExecutor); + rolesAdmin.grantRole("cooler_overseer", address(this)); + + // Register the tokens with RGSTY + vm.startPrank(address(this)); + rgstyAdmin.registerImmutableContract("dai", address(dai)); + rgstyAdmin.registerImmutableContract("gohm", address(gohm)); + rgstyAdmin.registerImmutableContract("usds", address(usds)); + rgstyAdmin.registerContract("flash", address(lender)); + rgstyAdmin.registerContract("dmgtr", address(daiUsdsMigrator)); + vm.stopPrank(); + + // Add a new Clearinghouse with USDS + clearinghouseUsds = new Clearinghouse( + address(ohm), + address(gohm), + staking, + address(susds), + address(coolerFactory), + address(kernel) + ); + vm.startPrank(kernelExecutor); + kernel.executeAction(Actions.ActivatePolicy, address(clearinghouseUsds)); + vm.stopPrank(); + // Activate the USDS Clearinghouse + clearinghouseUsds.activate(); + // Rebalance the USDS Clearinghouse + clearinghouseUsds.rebalance(); + + // Cache the TRSRY balances + // This is after the Clearinghouse, since activation may result in funds movement + trsryDaiBalance = dai.balanceOf(address(TRSRY)); + trsryGOhmBalance = gohm.balanceOf(address(TRSRY)); + trsrySDaiBalance = sdai.balanceOf(address(TRSRY)); + trsryUsdsBalance = usds.balanceOf(address(TRSRY)); + trsrySusdsBalance = susds.balanceOf(address(TRSRY)); + + admin = vm.addr(0x2); + + // Deploy LoanConsolidator + utils = new LoanConsolidator(address(kernel), 0); + + walletA = vm.addr(0xA); + walletB = vm.addr(0xB); + + // Fund wallets with gOHM + deal(address(gohm), walletA, _GOHM_AMOUNT); + + // Ensure the Clearinghouse has enough DAI and sDAI + deal(address(dai), address(clearinghouse), 18_000_000 * 1e18); + deal(address(sdai), address(clearinghouse), 18_000_000 * 1e18); + // Ensure the Clearinghouse has enough USDS and sUSDS + deal(address(usds), address(clearinghouseUsds), 18_000_000 * 1e18); + deal(address(susds), address(clearinghouseUsds), 18_000_000 * 1e18); + + _createCoolerAndLoans(clearinghouse, coolerFactory, walletA, dai); + + // LoanConsolidator is deactivated by default + // Assign the emergency role so that the contract can be activated + _assignEmergencyRole(); + } + + // ===== MODIFIERS ===== // + + modifier givenAdminHasRole() { + vm.prank(kernelExecutor); + rolesAdmin.grantRole(ROLE_ADMIN, admin); + _; + } + + function _assignEmergencyRole() internal { + vm.prank(kernelExecutor); + rolesAdmin.grantRole(ROLE_EMERGENCY_SHUTDOWN, emergency); + } + + modifier givenProtocolFee(uint256 feePercent_) { + vm.prank(admin); + utils.setFeePercentage(feePercent_); + _; + } + + function _setLenderFee(uint256 borrowAmount_, uint256 fee_) internal { + vm.mockCall( + lender, + abi.encodeWithSelector( + IERC3156FlashLender.flashFee.selector, + address(dai), + borrowAmount_ + ), + abi.encode(fee_) + ); + } + + function _grantCallerApprovals( + address caller_, + address clearinghouseTo_, + address coolerFrom_, + address coolerTo_, + uint256[] memory ids_ + ) internal { + ( + , + uint256 gohmApproval, + address reserveTo, + uint256 ownerReserveTo, + uint256 callerReserveTwo + ) = utils.requiredApprovals(clearinghouseTo_, coolerFrom_, ids_); + + // Determine the owner of coolerTo_ + address coolerToOwner = Cooler(coolerTo_).owner(); + bool coolerToOwnerIsCaller = coolerToOwner == caller_; + + // If the owner of the coolers is the same, then the caller can approve the entire amount + if (coolerToOwnerIsCaller) { + vm.startPrank(caller_); + ERC20(reserveTo).approve(address(utils), ownerReserveTo + callerReserveTwo); + gohm.approve(address(utils), gohmApproval); + vm.stopPrank(); + } + // Otherwise two different approvals are needed + else { + vm.startPrank(caller_); + ERC20(reserveTo).approve(address(utils), callerReserveTwo); + gohm.approve(address(utils), gohmApproval); + vm.stopPrank(); + + vm.startPrank(coolerToOwner); + ERC20(reserveTo).approve(address(utils), ownerReserveTo); + vm.stopPrank(); + } + } + + function _grantCallerApprovals(uint256[] memory ids_) internal { + _grantCallerApprovals( + walletA, + address(clearinghouse), + address(coolerA), + address(coolerA), + ids_ + ); + } + + function _grantCallerApprovals( + uint256 gOhmAmount_, + uint256 daiAmount_, + uint256 usdsAmount_ + ) internal { + vm.startPrank(walletA); + dai.approve(address(utils), daiAmount_); + usds.approve(address(utils), usdsAmount_); + gohm.approve(address(utils), gOhmAmount_); + vm.stopPrank(); + } + + function _consolidate( + address caller_, + address clearinghouseFrom_, + address clearinghouseTo_, + address coolerFrom_, + address coolerTo_, + uint256[] memory ids_ + ) internal { + vm.prank(caller_); + utils.consolidate(clearinghouseFrom_, clearinghouseTo_, coolerFrom_, coolerTo_, ids_); + } + + function _consolidate(uint256[] memory ids_) internal { + _consolidate( + walletA, + address(clearinghouse), + address(clearinghouse), + address(coolerA), + address(coolerA), + ids_ + ); + } + + function _consolidateWithNewOwner( + address caller_, + address clearinghouseFrom_, + address clearinghouseTo_, + address coolerFrom_, + address coolerTo_, + uint256[] memory ids_ + ) internal { + vm.prank(caller_); + utils.consolidateWithNewOwner( + clearinghouseFrom_, + clearinghouseTo_, + coolerFrom_, + coolerTo_, + ids_ + ); + } + + function _getInterestDue( + address cooler_, + uint256[] memory ids_ + ) internal view returns (uint256) { + uint256 interestDue; + + for (uint256 i = 0; i < ids_.length; i++) { + Cooler.Loan memory loan = Cooler(cooler_).getLoan(ids_[i]); + interestDue += loan.interestDue; + } + + return interestDue; + } + + function _getInterestDue(uint256[] memory ids_) internal view returns (uint256) { + return _getInterestDue(address(coolerA), ids_); + } + + modifier givenPolicyActive() { + vm.prank(kernelExecutor); + kernel.executeAction(Actions.ActivatePolicy, address(utils)); + _; + } + + modifier givenActivated() { + vm.prank(emergency); + utils.activate(); + _; + } + + modifier givenDeactivated() { + vm.prank(emergency); + utils.deactivate(); + _; + } + + modifier givenMockFlashloanLender() { + lender = address(new MockFlashloanLender(0, address(dai))); + + // Swap the maker flashloan lender for our mock + vm.startPrank(address(this)); + rgstyAdmin.updateContract("flash", lender); + vm.stopPrank(); + _; + } + + modifier givenMockFlashloanLenderFee(uint16 feePercent_) { + MockFlashloanLender(lender).setFeePercent(feePercent_); + _; + } + + modifier givenMockFlashloanLenderHasBalance(uint256 balance_) { + deal(address(dai), lender, balance_); + _; + } + + function _createCooler( + CoolerFactory coolerFactory_, + address wallet_, + ERC20 token_ + ) internal returns (address) { + console2.log("Creating cooler..."); + + if (address(token_) == address(dai)) { + console2.log("token: DAI"); + } else if (address(token_) == address(usds)) { + console2.log("token: USDS"); + } else { + console2.log("token: ", address(token_)); + } + + if (wallet_ == walletA) { + console2.log("wallet: A"); + } else if (wallet_ == walletB) { + console2.log("wallet: B"); + } else { + console2.log("wallet: ", wallet_); + } + + vm.startPrank(wallet_); + address cooler_ = coolerFactory_.generateCooler(gohm, token_); + vm.stopPrank(); + + console2.log("Cooler created:", cooler_); + + return cooler_; + } + + function _createLoans(Clearinghouse clearinghouse_, Cooler cooler_, address wallet_) internal { + vm.startPrank(wallet_); + // Approve clearinghouse to spend gOHM + gohm.approve(address(clearinghouse_), _GOHM_AMOUNT); + // Loan 0 for cooler_ (collateral: 2,000 gOHM) + (uint256 loan, ) = clearinghouse_.getLoanForCollateral(2_000 * 1e18); + clearinghouse_.lendToCooler(cooler_, loan); + // Loan 1 for cooler_ (collateral: 1,000 gOHM) + (loan, ) = clearinghouse_.getLoanForCollateral(1_000 * 1e18); + clearinghouse_.lendToCooler(cooler_, loan); + // Loan 2 for cooler_ (collateral: 333 gOHM) + (loan, ) = clearinghouse_.getLoanForCollateral(333 * 1e18); + clearinghouse_.lendToCooler(cooler_, loan); + vm.stopPrank(); + console2.log("Loans 0, 1, 2 created for cooler:", address(cooler_)); + } + + function _createCoolerAndLoans( + Clearinghouse clearinghouse_, + CoolerFactory coolerFactory_, + address wallet_, + ERC20 token_ + ) internal { + address cooler_ = _createCooler(coolerFactory_, wallet_, token_); + coolerA = Cooler(cooler_); + + _createLoans(clearinghouse_, coolerA, wallet_); + } + + /// @notice Creates a new Cooler clone + /// @dev Not that this will be regarded as a third-party Cooler, and rejected by LoanConsolidator, as CoolerFactory has no record of it. + function _cloneCooler( + address owner_, + address collateral_, + address debt_, + address factory_ + ) internal returns (Cooler) { + bytes memory coolerData = abi.encodePacked(owner_, collateral_, debt_, factory_); + return Cooler(address(coolerFactory.coolerImplementation()).clone(coolerData)); + } + + modifier givenCoolerB(ERC20 token_) { + coolerB = Cooler(_createCooler(coolerFactory, walletB, token_)); + _; + } + + function _createClearinghouseWithLowerLTC() internal returns (Clearinghouse) { + ClearinghouseLowerLTC newClearinghouse = new ClearinghouseLowerLTC( + address(ohm), + address(gohm), + address(staking), + address(sdai), + address(coolerFactory), + address(kernel) + ); + + // Activate as a policy + vm.prank(kernelExecutor); + kernel.executeAction(Actions.ActivatePolicy, address(newClearinghouse)); + + // Activate the new clearinghouse + newClearinghouse.activate(); + // Rebalance the new clearinghouse + newClearinghouse.rebalance(); + + return Clearinghouse(address(newClearinghouse)); + } + + function _createClearinghouseWithHigherLTC() internal returns (Clearinghouse) { + ClearinghouseHigherLTC newClearinghouse = new ClearinghouseHigherLTC( + address(ohm), + address(gohm), + address(staking), + address(sdai), + address(coolerFactory), + address(kernel) + ); + + // Activate as a policy + vm.prank(kernelExecutor); + kernel.executeAction(Actions.ActivatePolicy, address(newClearinghouse)); + + // Activate the new clearinghouse + newClearinghouse.activate(); + // Rebalance the new clearinghouse + newClearinghouse.rebalance(); + + return Clearinghouse(address(newClearinghouse)); + } + + // ===== ASSERTIONS ===== // + + function _assertCoolerLoans(uint256 collateral_) internal { + // Check that coolerA has a single open loan + Cooler.Loan memory loan = coolerA.getLoan(0); + assertEq(loan.collateral, 0, "loan 0: collateral"); + loan = coolerA.getLoan(1); + assertEq(loan.collateral, 0, "loan 1: collateral"); + loan = coolerA.getLoan(2); + assertEq(loan.collateral, 0, "loan 2: collateral"); + loan = coolerA.getLoan(3); + assertEq(loan.collateral, collateral_, "loan 3: collateral"); + vm.expectRevert(); + loan = coolerA.getLoan(4); + } + + function _assertCoolerLoansCrossClearinghouse( + address coolerFrom_, + address coolerTo_, + uint256 collateral_ + ) internal { + // Check that coolerFrom has no open loans + Cooler.Loan memory loan = Cooler(coolerFrom_).getLoan(0); + assertEq(loan.collateral, 0, "coolerFrom, loan 0: collateral"); + loan = Cooler(coolerFrom_).getLoan(1); + assertEq(loan.collateral, 0, "coolerFrom, loan 1: collateral"); + loan = Cooler(coolerFrom_).getLoan(2); + assertEq(loan.collateral, 0, "coolerFrom, loan 2: collateral"); + vm.expectRevert(); + loan = Cooler(coolerFrom_).getLoan(3); + + // Check that coolerTo has a single open loan + loan = Cooler(coolerTo_).getLoan(0); + assertEq(loan.collateral, collateral_, "coolerTo, loan 0: collateral"); + vm.expectRevert(); + loan = Cooler(coolerTo_).getLoan(1); + } + + function _assertTokenBalances( + uint256 walletABalance, + uint256 lenderBalance, + uint256 collectorBalance, + uint256 collateralBalance + ) internal { + assertEq(dai.balanceOf(address(utils)), 0, "dai: utils"); + assertEq(dai.balanceOf(walletA), walletABalance, "dai: walletA"); + assertEq(dai.balanceOf(address(coolerA)), 0, "dai: coolerA"); + assertEq(dai.balanceOf(lender), lenderBalance, "dai: lender"); + assertEq( + dai.balanceOf(address(TRSRY)), + trsryDaiBalance + collectorBalance, + "dai: collector" + ); + assertEq(usds.balanceOf(address(utils)), 0, "usds: utils"); + assertEq(usds.balanceOf(walletA), 0, "usds: walletA"); + assertEq(usds.balanceOf(address(coolerA)), 0, "usds: coolerA"); + assertEq(usds.balanceOf(lender), 0, "usds: lender"); + assertEq(usds.balanceOf(address(TRSRY)), trsryUsdsBalance, "usds: collector"); + assertEq(sdai.balanceOf(address(utils)), 0, "sdai: utils"); + assertEq(sdai.balanceOf(walletA), 0, "sdai: walletA"); + assertEq(sdai.balanceOf(address(coolerA)), 0, "sdai: coolerA"); + assertEq(sdai.balanceOf(lender), 0, "sdai: lender"); + assertEq(sdai.balanceOf(address(TRSRY)), trsrySDaiBalance, "sdai: collector"); + assertEq(susds.balanceOf(address(utils)), 0, "susds: utils"); + assertEq(susds.balanceOf(walletA), 0, "susds: walletA"); + assertEq(susds.balanceOf(address(coolerA)), 0, "susds: coolerA"); + assertEq(susds.balanceOf(lender), 0, "susds: lender"); + assertEq(susds.balanceOf(address(TRSRY)), trsrySusdsBalance, "susds: collector"); + assertEq(gohm.balanceOf(address(utils)), 0, "gohm: utils"); + assertEq(gohm.balanceOf(walletA), 0, "gohm: walletA"); + assertEq(gohm.balanceOf(address(coolerA)), collateralBalance, "gohm: coolerA"); + assertEq(gohm.balanceOf(lender), 0, "gohm: lender"); + assertEq(gohm.balanceOf(address(TRSRY)), trsryGOhmBalance, "gohm: collector"); + } + + function _assertTokenBalances( + address reserveTo_, + address coolerFrom_, + address coolerTo_, + uint256 walletABalance, + uint256 lenderBalance, + uint256 collectorBalance, + uint256 collateralBalance + ) internal { + assertEq(dai.balanceOf(address(utils)), 0, "dai: utils"); + assertEq( + dai.balanceOf(walletA), + reserveTo_ == address(dai) ? walletABalance : 0, + "dai: walletA" + ); + assertEq(dai.balanceOf(address(coolerFrom_)), 0, "dai: coolerFrom_"); + assertEq(dai.balanceOf(address(coolerTo_)), 0, "dai: coolerTo_"); + assertEq(dai.balanceOf(lender), lenderBalance, "dai: lender"); + assertEq( + dai.balanceOf(address(TRSRY)), + trsryDaiBalance + (reserveTo_ == address(dai) ? collectorBalance : 0), + "dai: collector" + ); + + assertEq(usds.balanceOf(address(utils)), 0, "usds: utils"); + assertEq( + usds.balanceOf(walletA), + reserveTo_ == address(usds) ? walletABalance : 0, + "usds: walletA" + ); + assertEq(usds.balanceOf(address(coolerFrom_)), 0, "usds: coolerFrom_"); + assertEq(usds.balanceOf(address(coolerTo_)), 0, "usds: coolerTo_"); + assertEq(usds.balanceOf(lender), 0, "usds: lender"); + assertEq( + usds.balanceOf(address(TRSRY)), + trsryUsdsBalance + (reserveTo_ == address(usds) ? collectorBalance : 0), + "usds: collector" + ); + + assertEq(sdai.balanceOf(address(utils)), 0, "sdai: utils"); + assertEq(sdai.balanceOf(walletA), 0, "sdai: walletA"); + assertEq(sdai.balanceOf(address(coolerFrom_)), 0, "sdai: coolerFrom_"); + assertEq(sdai.balanceOf(address(coolerTo_)), 0, "sdai: coolerTo_"); + assertEq(sdai.balanceOf(lender), 0, "sdai: lender"); + assertEq(sdai.balanceOf(address(TRSRY)), trsrySDaiBalance, "sdai: collector"); + + assertEq(susds.balanceOf(address(utils)), 0, "susds: utils"); + assertEq(susds.balanceOf(walletA), 0, "susds: walletA"); + assertEq(susds.balanceOf(address(coolerFrom_)), 0, "susds: coolerFrom_"); + assertEq(susds.balanceOf(address(coolerTo_)), 0, "susds: coolerTo_"); + assertEq(susds.balanceOf(lender), 0, "susds: lender"); + assertEq(susds.balanceOf(address(TRSRY)), trsrySusdsBalance, "susds: collector"); + + assertEq(gohm.balanceOf(address(utils)), 0, "gohm: utils"); + assertEq(gohm.balanceOf(walletA), 0, "gohm: walletA"); + assertEq( + gohm.balanceOf(address(coolerFrom_)), + address(coolerFrom_) == address(coolerTo_) ? collateralBalance : 0, + "gohm: coolerFrom_" + ); + assertEq(gohm.balanceOf(address(coolerTo_)), collateralBalance, "gohm: coolerTo_"); + assertEq(gohm.balanceOf(lender), 0, "gohm: lender"); + assertEq(gohm.balanceOf(address(TRSRY)), trsryGOhmBalance, "gohm: collector"); + } + + function _assertApprovals() internal { + _assertApprovals(address(coolerA), address(coolerA)); + } + + function _assertApprovals(address coolerFrom_, address coolerTo_) internal { + assertEq( + dai.allowance(address(utils), address(coolerFrom_)), + 0, + "dai allowance: utils -> coolerFrom_" + ); + assertEq( + dai.allowance(address(utils), address(coolerTo_)), + 0, + "dai allowance: utils -> coolerTo_" + ); + assertEq( + dai.allowance(address(utils), address(clearinghouse)), + 0, + "dai allowance: utils -> clearinghouse" + ); + assertEq( + dai.allowance(address(utils), address(clearinghouseUsds)), + 0, + "dai allowance: utils -> clearinghouseUsds" + ); + assertEq( + dai.allowance(address(utils), address(lender)), + 0, + "dai allowance: utils -> lender" + ); + + assertEq( + usds.allowance(address(utils), address(coolerFrom_)), + 0, + "usds allowance: utils -> coolerFrom_" + ); + assertEq( + usds.allowance(address(utils), address(coolerTo_)), + 0, + "usds allowance: utils -> coolerTo_" + ); + assertEq( + usds.allowance(address(utils), address(clearinghouse)), + 0, + "usds allowance: utils -> clearinghouse" + ); + assertEq( + usds.allowance(address(utils), address(clearinghouseUsds)), + 0, + "usds allowance: utils -> clearinghouseUsds" + ); + assertEq( + usds.allowance(address(utils), address(lender)), + 0, + "usds allowance: utils -> lender" + ); + + assertEq(gohm.allowance(walletA, address(utils)), 0, "gohm allowance: walletA -> utils"); + assertEq( + gohm.allowance(address(utils), address(coolerFrom_)), + 0, + "gohm allowance: utils -> coolerFrom_" + ); + assertEq( + gohm.allowance(address(utils), address(coolerTo_)), + 0, + "gohm allowance: utils -> coolerTo_" + ); + assertEq( + gohm.allowance(address(utils), address(clearinghouse)), + 0, + "gohm allowance: utils -> clearinghouse" + ); + assertEq( + gohm.allowance(address(utils), address(clearinghouseUsds)), + 0, + "gohm allowance: utils -> clearinghouseUsds" + ); + assertEq( + gohm.allowance(address(utils), address(lender)), + 0, + "gohm allowance: utils -> lender" + ); + } + + // ===== TESTS ===== // + + // consolidate + // given the contract has not been activated as a policy + // [X] it reverts + // given the contract has been disabled + // [X] it reverts + // given clearinghouseFrom is not registered with CHREG + // [X] it reverts + // given clearinghouseTo is not registered with CHREG + // [X] it reverts + // given coolerFrom was not created by clearinghouseFrom's CoolerFactory + // [X] it reverts + // given coolerTo was not created by clearinghouseTo's CoolerFactory + // [X] it reverts + // given the caller is not the owner of coolerFrom + // [X] it reverts + // given the caller is not the owner of coolerTo + // [X] it reverts + // given clearinghouseFrom is not an active policy + // given clearinghouseFrom is disabled + // [X] it succeeds + // [X] it succeeds + // given clearinghouseFrom is disabled + // [X] it succeeds + // given clearinghouseTo is disabled + // [X] it reverts + // given coolerFrom is equal to coolerTo + // given coolerFrom has no loans specified + // [X] it reverts + // given coolerFrom has 1 loan specified + // [X] it reverts + // given coolerFrom is not equal to coolerTo + // given coolerFrom has no loans specified + // [X] it reverts + // given coolerFrom has 1 loan specified + // [X] it migrates the loan to coolerTo + // given reserveTo is DAI + // given DAI spending approval has not been given to LoanConsolidator + // [X] it reverts + // given reserveTo is USDS + // given USDS spending approval has not been given to LoanConsolidator + // [X] it reverts + // given gOHM spending approval has not been given to LoanConsolidator + // [X] it reverts + // given the protocol fee is non-zero + // [X] it transfers the protocol fee to the collector + // given the lender fee is non-zero + // [X] it transfers the lender fee to the lender + // given the protocol fee is zero + // [X] it succeeds, but does not transfer additional reserveTo for the protocol fee + // given the lender fee is zero + // [X] it succeeds, but does not transfer additional reserveTo for the lender fee + // when clearinghouseFrom is DAI and clearinghouseTo is USDS + // [X] the loans on coolerFrom are migrated to coolerTo + // [X] the Cooler owner receives USDS from the new loan + // when clearinghouseFrom is USDS and clearinghouseTo is DAI + // [X] the loans on coolerFrom are migrated to coolerTo + // [X] the Cooler owner receives DAI from the new loan + // when clearinghouseFrom is USDS and clearinghouseTo is USDS + // [X] the loans on coolerFrom are migrated to coolerTo + // [X] the Cooler owner receives USDS from the new loan + // when clearinghouseFrom is DAI and clearinghouseTo is DAI + // [X] the loans on coolerFrom are migrated to coolerTo + // [X] the Cooler owner receives DAI from the new loan + // given clearinghouseFrom has a lower LTC than clearinghouseTo + // [X] the cooler owner receives a new loan for the old principal amount based on a higher LTC/higher collateral amount + // given clearinghouseFrom has a higher LTC than clearinghouseTo + // given the cooler owner does not have enough collateral for the new loan + // [X] it reverts + // [X] the Cooler owner receives a new loan for the old principal amount based on a lower LTC/lower collateral amount + + // --- consolidate -------------------------------------------- + + function test_consolidate_policyNotActive_reverts() public { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(LoanConsolidator.OnlyPolicyActive.selector)); + + // Consolidate loans for coolerA + uint256[] memory idsA = _idsA(); + _consolidate(idsA); + } + + function test_consolidate_defaultDeactivated_reverts() public givenPolicyActive { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(LoanConsolidator.OnlyConsolidatorActive.selector)); + + // Consolidate loans for coolerA + uint256[] memory idsA = _idsA(); + _consolidate(idsA); + } + + function test_consolidate_deactivated_reverts() + public + givenPolicyActive + givenActivated + givenDeactivated + { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(LoanConsolidator.OnlyConsolidatorActive.selector)); + + // Consolidate loans for coolerA + uint256[] memory idsA = _idsA(); + _consolidate(idsA); + } + + function test_consolidate_thirdPartyClearinghouseFrom_reverts() + public + givenPolicyActive + givenActivated + { + // Create a new Clearinghouse + // It is not registered with CHREG, so should be rejected + Clearinghouse newClearinghouse = new Clearinghouse( + address(ohm), + address(gohm), + staking, + address(sdai), + address(coolerFactory), + address(kernel) + ); + + // Expect revert + vm.expectRevert( + abi.encodeWithSelector(LoanConsolidator.Params_InvalidClearinghouse.selector) + ); + + // Consolidate loans + uint256[] memory idsA = _idsA(); + _consolidate( + walletA, + address(newClearinghouse), + address(clearinghouse), + address(coolerA), + address(coolerA), + idsA + ); + } + + function test_consolidate_thirdPartyClearinghouseTo_reverts() + public + givenPolicyActive + givenActivated + { + // Create a new Clearinghouse + // It is not registered with CHREG, so should be rejected + Clearinghouse newClearinghouse = new Clearinghouse( + address(ohm), + address(gohm), + staking, + address(sdai), + address(coolerFactory), + address(kernel) + ); + + // Expect revert + vm.expectRevert( + abi.encodeWithSelector(LoanConsolidator.Params_InvalidClearinghouse.selector) + ); + + // Consolidate loans + uint256[] memory idsA = _idsA(); + _consolidate( + walletA, + address(clearinghouse), + address(newClearinghouse), + address(coolerA), + address(coolerA), + idsA + ); + } + + function test_consolidate_thirdPartyCoolerFrom_reverts() + public + givenPolicyActive + givenActivated + { + // Create a new Cooler + // It was not created by the Clearinghouse's CoolerFactory, so should be rejected + Cooler newCooler = _cloneCooler( + walletA, + address(gohm), + address(dai), + address(coolerFactory) + ); + + // Expect revert + vm.expectRevert(abi.encodeWithSelector(LoanConsolidator.Params_InvalidCooler.selector)); + + // Consolidate loans for coolerA into newCooler + uint256[] memory idsA = _idsA(); + _consolidate( + walletA, + address(clearinghouse), + address(clearinghouse), + address(newCooler), + address(coolerA), + idsA + ); + } + + function test_consolidate_thirdPartyCoolerTo_reverts() public givenPolicyActive givenActivated { + // Create a new Cooler + // It was not created by the Clearinghouse's CoolerFactory, so should be rejected + Cooler newCooler = _cloneCooler( + walletA, + address(gohm), + address(dai), + address(coolerFactory) + ); + + // Expect revert + vm.expectRevert(abi.encodeWithSelector(LoanConsolidator.Params_InvalidCooler.selector)); + + // Consolidate loans for coolerA into newCooler + uint256[] memory idsA = _idsA(); + _consolidate( + walletA, + address(clearinghouse), + address(clearinghouse), + address(coolerA), + address(newCooler), + idsA + ); + } + + function test_consolidate_clearinghouseFromNotActive() public givenPolicyActive givenActivated { + uint256[] memory idsA = _idsA(); + + // Create a Cooler on the USDS Clearinghouse + address coolerUsds = _createCooler(coolerFactory, walletA, usds); + address coolerDai = address(coolerA); + + (, uint256 interest, , uint256 protocolFee) = utils.fundsRequired( + address(clearinghouseUsds), + coolerDai, + idsA + ); + + // Grant approvals + _grantCallerApprovals(walletA, address(clearinghouseUsds), coolerDai, coolerUsds, idsA); + + // Deal fees in USDS to the wallet + deal(address(usds), walletA, interest + protocolFee); + // Make sure the wallet has no DAI + deal(address(dai), walletA, 0); + + // Disable the previous clearinghouse + vm.prank(emergency); + clearinghouse.emergencyShutdown(); + + // Consolidate loans + _consolidate( + walletA, + address(clearinghouse), + address(clearinghouseUsds), + coolerDai, + coolerUsds, + idsA + ); + + _assertCoolerLoansCrossClearinghouse(coolerDai, coolerUsds, _GOHM_AMOUNT); + } + + function test_consolidate_clearinghouseFromPolicyNotActive() + public + givenPolicyActive + givenActivated + { + uint256[] memory idsA = _idsA(); + + // Create a Cooler on the USDS Clearinghouse + address coolerUsds = _createCooler(coolerFactory, walletA, usds); + address coolerDai = address(coolerA); + + (, uint256 interest, , uint256 protocolFee) = utils.fundsRequired( + address(clearinghouseUsds), + coolerDai, + idsA + ); + + // Grant approvals + _grantCallerApprovals(walletA, address(clearinghouseUsds), coolerDai, coolerUsds, idsA); + + // Deal fees in USDS to the wallet + deal(address(usds), walletA, interest + protocolFee); + // Make sure the wallet has no DAI + deal(address(dai), walletA, 0); + + // Uninstall the previous Clearinghouse as a policy + vm.prank(kernelExecutor); + kernel.executeAction(Actions.DeactivatePolicy, address(clearinghouse)); + + // Consolidate loans + _consolidate( + walletA, + address(clearinghouse), + address(clearinghouseUsds), + coolerDai, + coolerUsds, + idsA + ); + + _assertCoolerLoansCrossClearinghouse(coolerDai, coolerUsds, _GOHM_AMOUNT); + } + + function test_consolidate_clearinghouseFromNotActive_clearinghouseFromPolicyNotActive_reverts() + public + givenPolicyActive + givenActivated + { + uint256[] memory idsA = _idsA(); + + // Create a Cooler on the USDS Clearinghouse + address coolerUsds = _createCooler(coolerFactory, walletA, usds); + address coolerDai = address(coolerA); + + (, uint256 interest, , uint256 protocolFee) = utils.fundsRequired( + address(clearinghouseUsds), + coolerDai, + idsA + ); + + // Grant approvals + _grantCallerApprovals(walletA, address(clearinghouseUsds), coolerDai, coolerUsds, idsA); + + // Deal fees in USDS to the wallet + deal(address(usds), walletA, interest + protocolFee); + // Make sure the wallet has no DAI + deal(address(dai), walletA, 0); + + // Disable the previous Clearinghouse + vm.prank(emergency); + clearinghouse.emergencyShutdown(); + + // Uninstall the previous Clearinghouse as a policy + vm.prank(kernelExecutor); + kernel.executeAction(Actions.DeactivatePolicy, address(clearinghouse)); + + // Expect revert + // The Clearinghouse will attempt to be defunded, which will fail + vm.expectRevert( + abi.encodeWithSelector( + Module.Module_PolicyNotPermitted.selector, + address(clearinghouse) + ) + ); + + // Consolidate loans + _consolidate( + walletA, + address(clearinghouse), + address(clearinghouseUsds), + coolerDai, + coolerUsds, + idsA + ); + } + + function test_consolidate_sameCooler_noLoans_reverts() public givenPolicyActive givenActivated { + // Grant approvals + _grantCallerApprovals(type(uint256).max, type(uint256).max, type(uint256).max); + + // Expect revert since no loan ids are given + vm.expectRevert( + abi.encodeWithSelector(LoanConsolidator.Params_InsufficientCoolerCount.selector) + ); + + // Consolidate loans, but give no ids + uint256[] memory ids = new uint256[](0); + _consolidate(ids); + } + + function test_consolidate_sameCooler_oneLoan_reverts() public givenPolicyActive givenActivated { + // Grant approvals + _grantCallerApprovals(type(uint256).max, type(uint256).max, type(uint256).max); + + // Expect revert since no loan ids are given + vm.expectRevert( + abi.encodeWithSelector(LoanConsolidator.Params_InsufficientCoolerCount.selector) + ); + + // Consolidate loans, but give one id + uint256[] memory ids = new uint256[](1); + ids[0] = 0; + _consolidate(ids); + } + + function test_consolidate_differentCooler_noLoans_reverts() + public + givenPolicyActive + givenActivated + { + uint256[] memory idsA = _idsA(); + + // Deploy a Cooler on the USDS Clearinghouse + vm.startPrank(walletA); + address coolerUsds_ = coolerFactory.generateCooler(gohm, usds); + Cooler coolerUsds = Cooler(coolerUsds_); + vm.stopPrank(); + + (, uint256 interest, , uint256 protocolFee) = utils.fundsRequired( + address(clearinghouse), + address(coolerA), + idsA + ); + + // Grant approvals + _grantCallerApprovals(type(uint256).max, type(uint256).max, type(uint256).max); + + // Deal fees in USDS to the wallet + deal(address(usds), walletA, interest + protocolFee); + // Make sure the wallet has no DAI + deal(address(dai), walletA, 0); + + // Expect revert since no loan ids are given + vm.expectRevert( + abi.encodeWithSelector(LoanConsolidator.Params_InsufficientCoolerCount.selector) + ); + + // Consolidate loans, but give no ids + idsA = new uint256[](0); + _consolidate( + walletA, + address(clearinghouse), + address(clearinghouseUsds), + address(coolerA), + address(coolerUsds), + idsA + ); + } + + function test_consolidate_differentCooler_oneLoan() public givenPolicyActive givenActivated { + uint256[] memory idsA = _idsA(); + + // Deploy a Cooler on the USDS Clearinghouse + vm.startPrank(walletA); + address coolerUsds_ = coolerFactory.generateCooler(gohm, usds); + Cooler coolerUsds = Cooler(coolerUsds_); + vm.stopPrank(); + + (, uint256 interest, , uint256 protocolFee) = utils.fundsRequired( + address(clearinghouse), + address(coolerA), + idsA + ); + + // Grant approvals + _grantCallerApprovals(type(uint256).max, type(uint256).max, type(uint256).max); + + // Deal fees in USDS to the wallet + deal(address(usds), walletA, interest + protocolFee); + // Make sure the wallet has no DAI + deal(address(dai), walletA, 0); + + // Get the loan principal before consolidation + Cooler.Loan memory loanZero = coolerA.getLoan(0); + Cooler.Loan memory loanOne = coolerA.getLoan(1); + Cooler.Loan memory loanTwo = coolerA.getLoan(2); + + // Consolidate loans, but give only one id + idsA = new uint256[](1); + idsA[0] = 0; + _consolidate( + walletA, + address(clearinghouse), + address(clearinghouseUsds), + address(coolerA), + address(coolerUsds), + idsA + ); + + // Assert that only loan 0 has been repaid + assertEq(coolerA.getLoan(0).principal, 0, "cooler DAI, loan 0: principal"); + assertEq(coolerA.getLoan(1).principal, loanOne.principal, "cooler DAI, loan 1: principal"); + assertEq(coolerA.getLoan(2).principal, loanTwo.principal, "cooler DAI, loan 2: principal"); + // Assert that loan 0 has been migrated to coolerUsds + assertEq( + coolerUsds.getLoan(0).principal, + loanZero.principal, + "cooler USDS, loan 0: principal" + ); + // Assert that coolerUsds has no other loans + vm.expectRevert(); + coolerUsds.getLoan(1); + } + + function test_consolidate_callerNotOwner_coolerFrom_reverts() + public + givenPolicyActive + givenActivated + givenCoolerB(dai) + { + uint256[] memory idsA = _idsA(); + + // Grant approvals + _grantCallerApprovals(idsA); + + // Expect revert + vm.expectRevert(abi.encodeWithSelector(LoanConsolidator.OnlyCoolerOwner.selector)); + + // Consolidate loans + // Do not perform as the cooler owner + _consolidate( + walletA, + address(clearinghouse), + address(clearinghouse), + address(coolerB), + address(coolerA), + idsA + ); + } + + function test_consolidate_callerNotOwner_coolerTo_reverts() + public + givenPolicyActive + givenActivated + givenCoolerB(dai) + { + uint256[] memory idsA = _idsA(); + + // Grant approvals + _grantCallerApprovals(idsA); + + // Expect revert + vm.expectRevert(abi.encodeWithSelector(LoanConsolidator.OnlyCoolerOwner.selector)); + + // Consolidate loans + // Do not perform as the cooler owner + _consolidate( + walletA, + address(clearinghouse), + address(clearinghouse), + address(coolerA), + address(coolerB), + idsA + ); + } + + function test_consolidate_insufficientGOhmApproval_reverts() + public + givenPolicyActive + givenActivated + { + uint256[] memory idsA = _idsA(); + + // Grant approvals + (, uint256 gohmApproval, , uint256 ownerReserveTo, uint256 callerReserveTwo) = utils + .requiredApprovals(address(clearinghouse), address(coolerA), idsA); + + _grantCallerApprovals(gohmApproval - 1, ownerReserveTo + callerReserveTwo, 0); + + // Expect revert + vm.expectRevert("ERC20: transfer amount exceeds allowance"); + + _consolidate(idsA); + } + + function test_consolidate_insufficientDaiApproval_reverts() + public + givenPolicyActive + givenActivated + { + uint256[] memory idsA = _idsA(); + + // Grant approvals + (, uint256 gohmApproval, , , ) = utils.requiredApprovals( + address(clearinghouse), + address(coolerA), + idsA + ); + + _grantCallerApprovals(gohmApproval, 1, 0); + + // Expect revert + vm.expectRevert("Dai/insufficient-allowance"); + + _consolidate(idsA); + } + + function test_consolidate_insufficientUsdsApproval_reverts() + public + givenPolicyActive + givenActivated + { + uint256[] memory idsA = _idsA(); + + // Create a Cooler on the USDS Clearinghouse + address coolerUsds = _createCooler(coolerFactory, walletA, usds); + + // Grant approvals + (, uint256 gohmApproval, , uint256 ownerReserveTo, uint256 callerReserveTo) = utils + .requiredApprovals(address(clearinghouseUsds), address(coolerA), idsA); + + _grantCallerApprovals(gohmApproval, 0, 1); + + // Deal fees in USDS to the wallet + deal(address(usds), walletA, ownerReserveTo + callerReserveTo); + + // Expect revert + vm.expectRevert("Usds/insufficient-allowance"); + + _consolidate( + walletA, + address(clearinghouse), + address(clearinghouseUsds), + address(coolerA), + address(coolerUsds), + idsA + ); + } + + function test_consolidate_noProtocolFee() public givenPolicyActive givenActivated { + uint256[] memory idsA = _idsA(); + + // Record the amount of DAI in the wallet + uint256 initPrincipal = dai.balanceOf(walletA); + uint256 interestDue = _getInterestDue(idsA); + + // Grant approvals + _grantCallerApprovals(idsA); + + // Consolidate loans + _consolidate(idsA); + + _assertCoolerLoans(_GOHM_AMOUNT); + _assertTokenBalances(initPrincipal - interestDue, 0, 0, _GOHM_AMOUNT); + _assertApprovals(); + } + + function test_consolidate_noProtocolFee_fuzz( + uint256 loanOneCollateral_, + uint256 loanTwoCollateral_ + ) public givenPolicyActive givenActivated givenCoolerB(dai) { + // Bound the collateral values + loanOneCollateral_ = bound(loanOneCollateral_, 1, 1e18); + loanTwoCollateral_ = bound(loanTwoCollateral_, 1, 1e18); + + // Fund the wallet with gOHM + deal(address(gohm), walletB, loanOneCollateral_ + loanTwoCollateral_); + + // Approve clearinghouse to spend gOHM + vm.prank(walletB); + gohm.approve(address(clearinghouse), loanOneCollateral_ + loanTwoCollateral_); + + // Take loans + { + vm.startPrank(walletB); + // Loan 0 for coolerB + (uint256 loanOnePrincipal, ) = clearinghouse.getLoanForCollateral(loanOneCollateral_); + clearinghouse.lendToCooler(coolerB, loanOnePrincipal); + + // Loan 1 for coolerB + (uint256 loanTwoPrincipal, ) = clearinghouse.getLoanForCollateral(loanTwoCollateral_); + clearinghouse.lendToCooler(coolerB, loanTwoPrincipal); + vm.stopPrank(); + } + + uint256[] memory loanIds = new uint256[](2); + loanIds[0] = 0; + loanIds[1] = 1; + + // Record the amount of DAI in the wallet + uint256 initPrincipal = dai.balanceOf(walletB); + uint256 interestDue = _getInterestDue(address(coolerB), loanIds); + + // Grant approvals + _grantCallerApprovals( + walletB, + address(clearinghouse), + address(coolerB), + address(coolerB), + loanIds + ); + + // Consolidate loans + _consolidate( + walletB, + address(clearinghouse), + address(clearinghouse), + address(coolerB), + address(coolerB), + loanIds + ); + + // Assert loan balances + assertEq(coolerB.getLoan(0).collateral, 0, "loan 0: collateral"); + assertEq(coolerB.getLoan(1).collateral, 0, "loan 1: collateral"); + assertEq( + coolerB.getLoan(2).collateral + gohm.balanceOf(walletB), + loanOneCollateral_ + loanTwoCollateral_, + "consolidated: collateral" + ); + + // Assert token balances + assertEq(dai.balanceOf(walletB), initPrincipal - interestDue, "DAI balance"); + // Don't check gOHM balance of walletB, because it can be non-zero due to rounding + // assertEq(gohm.balanceOf(walletB), 0, "gOHM balance"); + assertEq(dai.balanceOf(address(coolerB)), 0, "DAI balance: coolerB"); + assertEq( + gohm.balanceOf(address(coolerB)) + gohm.balanceOf(walletB), + loanOneCollateral_ + loanTwoCollateral_, + "gOHM balance: coolerB" + ); + assertEq(gohm.balanceOf(address(utils)), 0, "gOHM balance: utils"); + + // Assert approvals + assertEq( + dai.allowance(address(utils), address(coolerB)), + 0, + "DAI allowance: utils -> coolerB" + ); + assertEq( + gohm.allowance(address(utils), address(coolerB)), + 0, + "gOHM allowance: utils -> coolerB" + ); + } + + function test_consolidate_lenderFee() + public + givenPolicyActive + givenActivated + givenAdminHasRole + givenMockFlashloanLender + givenMockFlashloanLenderFee(1000) // 10% + givenMockFlashloanLenderHasBalance(20_000_000e18) + { + uint256[] memory idsA = _idsA(); + + // Record the initial debt balance + (uint256 totalPrincipal, ) = clearinghouse.getLoanForCollateral(_GOHM_AMOUNT); + + // Record the amount of DAI in the wallet + uint256 initPrincipal = dai.balanceOf(walletA); + (, uint256 interest, , uint256 protocolFee) = utils.fundsRequired( + address(clearinghouse), + address(coolerA), + idsA + ); + + // Grant approvals + _grantCallerApprovals( + walletA, + address(clearinghouse), + address(coolerA), + address(coolerA), + idsA + ); + + // Calculate the expected lender fee + uint256 lenderFee = MockFlashloanLender(lender).flashFee(address(dai), totalPrincipal); + uint256 expectedLenderBalance = 20_000_000e18 + lenderFee; + + // Consolidate loans + _consolidate(idsA); + + _assertCoolerLoans(_GOHM_AMOUNT); + _assertTokenBalances( + initPrincipal - interest - protocolFee - lenderFee, + expectedLenderBalance, + protocolFee, + _GOHM_AMOUNT + ); + _assertApprovals(); + } + + function test_consolidate_protocolFee() + public + givenPolicyActive + givenActivated + givenAdminHasRole + givenProtocolFee(1000) // 1% + { + uint256[] memory idsA = _idsA(); + + // Record the amount of DAI in the wallet + uint256 initPrincipal = dai.balanceOf(walletA); + (, uint256 interest, , uint256 protocolFee) = utils.fundsRequired( + address(clearinghouse), + address(coolerA), + idsA + ); + + // Grant approvals + _grantCallerApprovals( + walletA, + address(clearinghouse), + address(coolerA), + address(coolerA), + idsA + ); + + // Consolidate loans + _consolidate(idsA); + + _assertCoolerLoans(_GOHM_AMOUNT); + _assertTokenBalances(initPrincipal - interest - protocolFee, 0, protocolFee, _GOHM_AMOUNT); + _assertApprovals(); + } + + function test_consolidate_noProtocolFee_disabledClearinghouse_reverts() + public + givenPolicyActive + givenActivated + { + // Disable the Clearinghouse + vm.prank(emergency); + clearinghouse.emergencyShutdown(); + + uint256[] memory idsA = _idsA(); + + // Grant approvals + _grantCallerApprovals(idsA); + + // Expect revert + vm.expectRevert("SavingsDai/insufficient-balance"); + + // Consolidate loans + _consolidate( + walletA, + address(clearinghouse), + address(clearinghouse), + address(coolerA), + address(coolerA), + idsA + ); + } + + function test_consolidate_protocolFee_daiToUsds() + public + givenPolicyActive + givenActivated + givenAdminHasRole + givenProtocolFee(1000) // 1% + { + uint256[] memory idsA = _idsA(); + + // Create a Cooler on the USDS Clearinghouse + address coolerUsds = _createCooler(coolerFactory, walletA, usds); + address coolerDai = address(coolerA); + + (, uint256 interest, , uint256 protocolFee) = utils.fundsRequired( + address(clearinghouseUsds), + coolerDai, + idsA + ); + + // Grant approvals + _grantCallerApprovals(walletA, address(clearinghouseUsds), coolerDai, coolerUsds, idsA); + + // Deal fees in USDS to the wallet + deal(address(usds), walletA, interest + protocolFee); + // Make sure the wallet has no DAI + deal(address(dai), walletA, 0); + + // Record the amount of USDS in the wallet + uint256 initPrincipal = usds.balanceOf(walletA); + + // Consolidate loans + _consolidate( + walletA, + address(clearinghouse), + address(clearinghouseUsds), + coolerDai, + coolerUsds, + idsA + ); + + _assertCoolerLoansCrossClearinghouse(coolerDai, coolerUsds, _GOHM_AMOUNT); + _assertTokenBalances( + address(usds), + coolerDai, + coolerUsds, + initPrincipal - interest - protocolFee, + 0, + protocolFee, + _GOHM_AMOUNT + ); + _assertApprovals(coolerDai, coolerUsds); + } + + function test_consolidate_protocolFee_usdsToDai() + public + givenPolicyActive + givenActivated + givenAdminHasRole + givenProtocolFee(1000) // 1% + { + uint256[] memory idsA = _idsA(); + + // Cache before it gets overwritten + address coolerDai = address(coolerA); + + // Create cooler loans on the USDS Clearinghouse + deal(address(gohm), walletA, _GOHM_AMOUNT); + _createCoolerAndLoans(clearinghouseUsds, coolerFactory, walletA, usds); + address coolerUsds = address(coolerA); + (, uint256 interest, , uint256 protocolFee) = utils.fundsRequired( + address(clearinghouse), + coolerUsds, + idsA + ); + + // Grant approvals + _grantCallerApprovals(walletA, address(clearinghouse), coolerUsds, coolerDai, idsA); + + // Deal fees in DAI to the wallet + deal(address(dai), walletA, interest + protocolFee); + // Make sure the wallet has no USDS + deal(address(usds), walletA, 0); + + // Record the amount of DAI in the wallet + uint256 initPrincipal = dai.balanceOf(walletA); + + // Consolidate loans + _consolidate( + walletA, + address(clearinghouseUsds), + address(clearinghouse), + coolerUsds, + coolerDai, + idsA + ); + + // Check that coolerUsds has no loans + assertEq(Cooler(coolerUsds).getLoan(0).collateral, 0, "coolerUsds: loan 0: collateral"); + assertEq(Cooler(coolerUsds).getLoan(1).collateral, 0, "coolerUsds: loan 1: collateral"); + assertEq(Cooler(coolerUsds).getLoan(2).collateral, 0, "coolerUsds: loan 2: collateral"); + vm.expectRevert(); + Cooler(coolerUsds).getLoan(3); + + // Check that coolerDai has the previous 3 loans + assertEq( + Cooler(coolerDai).getLoan(0).collateral, + 2_000 * 1e18, + "coolerDai: loan 0: collateral" + ); + assertEq( + Cooler(coolerDai).getLoan(1).collateral, + 1_000 * 1e18, + "coolerDai: loan 1: collateral" + ); + assertEq( + Cooler(coolerDai).getLoan(2).collateral, + 333 * 1e18, + "coolerDai: loan 2: collateral" + ); + // Check that it has the consolidated loan + assertEq( + Cooler(coolerDai).getLoan(3).collateral, + _GOHM_AMOUNT, + "coolerDai: loan 3: collateral" + ); + // No more loans + vm.expectRevert(); + Cooler(coolerDai).getLoan(4); + + _assertTokenBalances( + address(dai), + coolerUsds, + coolerDai, + initPrincipal - interest - protocolFee, + 0, + protocolFee, + _GOHM_AMOUNT + _GOHM_AMOUNT // 2x loans + ); + _assertApprovals(coolerUsds, coolerDai); + } + + function test_consolidate_protocolFee_usdsToUsds() + public + givenPolicyActive + givenActivated + givenAdminHasRole + givenProtocolFee(1000) // 1% + { + uint256[] memory idsA = _idsA(); + + // Create coolers + deal(address(gohm), walletA, _GOHM_AMOUNT); + _createCoolerAndLoans(clearinghouseUsds, coolerFactory, walletA, usds); + address coolerUsds = address(coolerA); + + (, uint256 interest, , uint256 protocolFee) = utils.fundsRequired( + address(clearinghouseUsds), + coolerUsds, + idsA + ); + + // Grant approvals + _grantCallerApprovals(walletA, address(clearinghouseUsds), coolerUsds, coolerUsds, idsA); + + // Deal fees in USDS to the wallet + deal(address(usds), walletA, interest + protocolFee); + // Make sure the wallet has no DAI + deal(address(dai), walletA, 0); + + // Record the amount of USDS in the wallet + uint256 initPrincipal = usds.balanceOf(walletA); + + // Consolidate loans + _consolidate( + walletA, + address(clearinghouseUsds), + address(clearinghouseUsds), + coolerUsds, + coolerUsds, + idsA + ); + + _assertCoolerLoans(_GOHM_AMOUNT); + _assertTokenBalances( + address(usds), + coolerUsds, + coolerUsds, + initPrincipal - interest - protocolFee, + 0, + protocolFee, + _GOHM_AMOUNT + ); + _assertApprovals(coolerUsds, coolerUsds); + } + + function test_consolidate_clearinghouseFromLowerLTC() public givenPolicyActive givenActivated { + // Create a new Clearinghouse with a higher LTC + Clearinghouse newClearinghouse = _createClearinghouseWithHigherLTC(); + + // Calculate the collateral required for the existing loans + (uint256 existingPrincipal, ) = clearinghouse.getLoanForCollateral(_GOHM_AMOUNT); + uint256 newCollateralRequired = newClearinghouse.getCollateralForLoan(existingPrincipal); + + uint256[] memory idsA = _idsA(); + + // Record the amount of DAI in the wallet + uint256 initPrincipal = dai.balanceOf(walletA); + uint256 interestDue = _getInterestDue(idsA); + + // Grant approvals + _grantCallerApprovals( + walletA, + address(newClearinghouse), + address(coolerA), + address(coolerA), + idsA + ); + + // Consolidate loans + _consolidate( + walletA, + address(clearinghouse), + address(newClearinghouse), + address(coolerA), + address(coolerA), + idsA + ); + + _assertCoolerLoans(newCollateralRequired); + _assertApprovals(); + + // WalletA should have received the principal amount + assertEq(dai.balanceOf(walletA), initPrincipal - interestDue, "walletA: dai balance"); + // Balance of gOHM should be the old collateral amount - new collateral required + assertEq( + gohm.balanceOf(walletA), + _GOHM_AMOUNT - newCollateralRequired, + "walletA: gOHM balance" + ); + // Balance of gOHM in the cooler should be the new collateral required + assertEq(gohm.balanceOf(address(coolerA)), newCollateralRequired, "coolerA: gOHM balance"); + // Balance of gOHM on the LoanConsolidator should be 0 + assertEq(gohm.balanceOf(address(utils)), 0, "policy: gOHM balance"); + } + + function test_consolidate_clearinghouseFromHigherLTC_insufficientCollateral_reverts() + public + givenPolicyActive + givenActivated + { + // Create a new Clearinghouse with a lower LTC + Clearinghouse newClearinghouse = _createClearinghouseWithLowerLTC(); + + // Do NOT deal more collateral to the wallet + + uint256[] memory idsA = _idsA(); + + // Grant approvals + _grantCallerApprovals( + walletA, + address(newClearinghouse), + address(coolerA), + address(coolerA), + idsA + ); + + // Expect revert + vm.expectRevert("ERC20: transfer amount exceeds balance"); + + // Consolidate loans + _consolidate( + walletA, + address(clearinghouse), + address(newClearinghouse), + address(coolerA), + address(coolerA), + idsA + ); + } + + function test_consolidate_clearinghouseFromHigherLTC() public givenPolicyActive givenActivated { + // Create a new Clearinghouse with a lower LTC + Clearinghouse newClearinghouse = _createClearinghouseWithLowerLTC(); + + // Calculate the collateral required for the existing loans + (uint256 existingPrincipal, ) = clearinghouse.getLoanForCollateral(_GOHM_AMOUNT); + uint256 newCollateralRequired = newClearinghouse.getCollateralForLoan(existingPrincipal); + + // Deal the difference in collateral to the wallet + deal(address(gohm), walletA, newCollateralRequired - _GOHM_AMOUNT); + + uint256[] memory idsA = _idsA(); + + // Record the amount of DAI in the wallet + uint256 initPrincipal = dai.balanceOf(walletA); + uint256 interestDue = _getInterestDue(idsA); + + // Grant approvals + _grantCallerApprovals( + walletA, + address(newClearinghouse), + address(coolerA), + address(coolerA), + idsA + ); + + // Consolidate loans + _consolidate( + walletA, + address(clearinghouse), + address(newClearinghouse), + address(coolerA), + address(coolerA), + idsA + ); + + _assertCoolerLoans(newCollateralRequired); + _assertApprovals(); + + // WalletA should have received the principal amount + assertEq(dai.balanceOf(walletA), initPrincipal - interestDue, "walletA: dai balance"); + // Balance of gOHM should be 0, as the new collateral amount was used + assertEq(gohm.balanceOf(walletA), 0, "walletA: gOHM balance"); + // Balance of gOHM in the cooler should be the new collateral required + assertEq(gohm.balanceOf(address(coolerA)), newCollateralRequired, "coolerA: gOHM balance"); + // Balance of gOHM on the LoanConsolidator should be 0 + assertEq(gohm.balanceOf(address(utils)), 0, "policy: gOHM balance"); + } + + // consolidateWithNewOwner + // given the contract has not been activated as a policy + // [X] it reverts + // given the contract has been disabled + // [X] it reverts + // given clearinghouseFrom is not registered with CHREG + // [X] it reverts + // given clearinghouseTo is not registered with CHREG + // [X] it reverts + // given coolerFrom was not created by clearinghouseFrom's CoolerFactory + // [X] it reverts + // given coolerTo was not created by clearinghouseTo's CoolerFactory + // [X] it reverts + // given the caller is not the owner of coolerFrom + // [X] it reverts + // given the owner of coolerFrom is the same as the owner of coolerTo + // [X] it reverts + // given clearinghouseTo is disabled + // [X] it reverts + // given coolerFrom is equal to coolerTo + // [X] it reverts + // given coolerFrom is not equal to coolerTo + // given coolerFrom has no loans specified + // [X] it reverts + // given coolerFrom has 1 loan specified + // [X] it migrates the loan to coolerTo + // given reserveTo is DAI + // given DAI spending approval has not been given to LoanConsolidator + // [X] it reverts + // given reserveTo is USDS + // given USDS spending approval has not been given to LoanConsolidator + // [X] it reverts + // given gOHM spending approval has not been given to LoanConsolidator + // [X] it reverts + // given the protocol fee is non-zero + // [X] it transfers the protocol fee to the collector + // given the lender fee is non-zero + // [X] it transfers the lender fee to the lender + // given the protocol fee is zero + // [X] it succeeds, but does not transfer additional reserveTo for the protocol fee + // given the lender fee is zero + // [X] it succeeds, but does not transfer additional reserveTo for the lender fee + // when clearinghouseFrom is DAI and clearinghouseTo is USDS + // [X] the loans on coolerFrom are migrated to coolerTo + // [X] the Cooler owner receives USDS from the new loan + // when clearinghouseFrom is USDS and clearinghouseTo is DAI + // [X] the loans on coolerFrom are migrated to coolerTo + // [X] the Cooler owner receives DAI from the new loan + // when clearinghouseFrom is USDS and clearinghouseTo is USDS + // [X] the loans on coolerFrom are migrated to coolerTo + // [X] the Cooler owner receives USDS from the new loan + // when clearinghouseFrom is DAI and clearinghouseTo is DAI + // [X] the loans on coolerFrom are migrated to coolerTo + // [X] the Cooler owner receives DAI from the new loan + + // --- consolidateWithNewOwner -------------------------------------------- + + function test_consolidateWithNewOwner_policyNotActive_reverts() public givenCoolerB(dai) { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(LoanConsolidator.OnlyPolicyActive.selector)); + + // Consolidate loans for coolerA + uint256[] memory idsA = _idsA(); + _consolidateWithNewOwner( + walletA, + address(clearinghouse), + address(clearinghouse), + address(coolerA), + address(coolerB), + idsA + ); + } + + function test_consolidateWithNewOwner_deactivated_reverts() + public + givenPolicyActive + givenActivated + givenDeactivated + givenAdminHasRole + givenCoolerB(dai) + { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(LoanConsolidator.OnlyConsolidatorActive.selector)); + + // Consolidate loans for coolerA + uint256[] memory idsA = _idsA(); + _consolidateWithNewOwner( + walletA, + address(clearinghouse), + address(clearinghouse), + address(coolerA), + address(coolerB), + idsA + ); + } + + function test_consolidateWithNewOwner_thirdPartyClearinghouseFrom_reverts() + public + givenPolicyActive + givenActivated + givenCoolerB(dai) + { + // Create a new Clearinghouse + // It is not registered with CHREG, so should be rejected + Clearinghouse newClearinghouse = new Clearinghouse( + address(ohm), + address(gohm), + staking, + address(sdai), + address(coolerFactory), + address(kernel) + ); + + // Expect revert + vm.expectRevert( + abi.encodeWithSelector(LoanConsolidator.Params_InvalidClearinghouse.selector) + ); + + // Consolidate loans + uint256[] memory idsA = _idsA(); + _consolidateWithNewOwner( + walletA, + address(newClearinghouse), + address(clearinghouse), + address(coolerA), + address(coolerB), + idsA + ); + } + + function test_consolidateWithNewOwner_thirdPartyClearinghouseTo_reverts() + public + givenPolicyActive + givenActivated + givenCoolerB(dai) + { + // Create a new Clearinghouse + // It is not registered with CHREG, so should be rejected + Clearinghouse newClearinghouse = new Clearinghouse( + address(ohm), + address(gohm), + staking, + address(sdai), + address(coolerFactory), + address(kernel) + ); + + // Expect revert + vm.expectRevert( + abi.encodeWithSelector(LoanConsolidator.Params_InvalidClearinghouse.selector) + ); + + // Consolidate loans + uint256[] memory idsA = _idsA(); + _consolidateWithNewOwner( + walletA, + address(clearinghouse), + address(newClearinghouse), + address(coolerA), + address(coolerB), + idsA + ); + } + + function test_consolidateWithNewOwner_thirdPartyCoolerFrom_reverts() + public + givenPolicyActive + givenActivated + givenCoolerB(dai) + { + // Create a new Cooler + // It was not created by the Clearinghouse's CoolerFactory, so should be rejected + Cooler newCooler = _cloneCooler( + walletA, + address(gohm), + address(dai), + address(coolerFactory) + ); + + // Expect revert + vm.expectRevert(abi.encodeWithSelector(LoanConsolidator.Params_InvalidCooler.selector)); + + // Consolidate loans for coolerA into newCooler + uint256[] memory idsA = _idsA(); + _consolidateWithNewOwner( + walletA, + address(clearinghouse), + address(clearinghouse), + address(newCooler), + address(coolerB), + idsA + ); + } + + function test_consolidateWithNewOwner_thirdPartyCoolerTo_reverts() + public + givenPolicyActive + givenActivated + { + // Create a new Cooler + // It was not created by the Clearinghouse's CoolerFactory, so should be rejected + Cooler newCooler = _cloneCooler( + walletA, + address(gohm), + address(dai), + address(coolerFactory) + ); + + // Expect revert + vm.expectRevert(abi.encodeWithSelector(LoanConsolidator.Params_InvalidCooler.selector)); + + // Consolidate loans for coolerA into newCooler + uint256[] memory idsA = _idsA(); + _consolidateWithNewOwner( + walletA, + address(clearinghouse), + address(clearinghouse), + address(coolerA), + address(newCooler), + idsA + ); + } + + function test_consolidateWithNewOwner_sameCooler_reverts() + public + givenPolicyActive + givenActivated + { + uint256[] memory idsA = _idsA(); + + // Grant approvals + _grantCallerApprovals( + walletA, + address(clearinghouse), + address(coolerA), + address(coolerA), + idsA + ); + + // Expect revert since the cooler is the same + vm.expectRevert(abi.encodeWithSelector(LoanConsolidator.Params_InvalidCooler.selector)); + + // Consolidate loans + _consolidateWithNewOwner( + walletA, + address(clearinghouse), + address(clearinghouse), + address(coolerA), + address(coolerA), + idsA + ); + } + + function test_consolidateWithNewOwner_sameOwner_reverts() + public + givenPolicyActive + givenActivated + { + uint256[] memory idsA = _idsA(); + + // Create a new Cooler on the USDS Clearinghouse + vm.startPrank(walletA); + address coolerUsds_ = coolerFactory.generateCooler(gohm, usds); + Cooler coolerUsds = Cooler(coolerUsds_); + vm.stopPrank(); + + // Grant approvals + _grantCallerApprovals( + walletA, + address(clearinghouse), + address(coolerA), + address(coolerA), + idsA + ); + + // Expect revert since the owner is the same + vm.expectRevert(abi.encodeWithSelector(LoanConsolidator.Params_InvalidCooler.selector)); + + // Consolidate loans + _consolidateWithNewOwner( + walletA, + address(clearinghouse), + address(clearinghouseUsds), + address(coolerA), + address(coolerUsds), + idsA + ); + } + + function test_consolidateWithNewOwner_noLoans_reverts() + public + givenPolicyActive + givenActivated + givenCoolerB(dai) + { + uint256[] memory idsA = new uint256[](0); + + (, uint256 interest, , uint256 protocolFee) = utils.fundsRequired( + address(clearinghouse), + address(coolerA), + idsA + ); + + // Grant approvals + _grantCallerApprovals( + walletA, + address(clearinghouse), + address(coolerA), + address(coolerB), + idsA + ); + + // Deal fees in DAI to the wallet + deal(address(dai), walletA, interest + protocolFee); + + // Expect revert since no loan ids are given + vm.expectRevert( + abi.encodeWithSelector(LoanConsolidator.Params_InsufficientCoolerCount.selector) + ); + + // Consolidate loans for coolerA + _consolidateWithNewOwner( + walletA, + address(clearinghouse), + address(clearinghouse), + address(coolerA), + address(coolerB), + idsA + ); + } + + function test_consolidateWithNewOwner_oneLoan() + public + givenPolicyActive + givenActivated + givenCoolerB(dai) + { + uint256[] memory idsA = new uint256[](1); + idsA[0] = 0; + + (, uint256 interest, , uint256 protocolFee) = utils.fundsRequired( + address(clearinghouse), + address(coolerA), + idsA + ); + + // Grant approvals + _grantCallerApprovals( + walletA, + address(clearinghouse), + address(coolerA), + address(coolerB), + idsA + ); + + // Deal fees in DAI to the wallet + deal(address(dai), walletA, interest + protocolFee); + + // Get the loan principal before consolidation + Cooler.Loan memory loanZero = coolerA.getLoan(0); + Cooler.Loan memory loanOne = coolerA.getLoan(1); + Cooler.Loan memory loanTwo = coolerA.getLoan(2); + + // Consolidate loans for coolerA + _consolidateWithNewOwner( + walletA, + address(clearinghouse), + address(clearinghouse), + address(coolerA), + address(coolerB), + idsA + ); + + // Assert that only loan 0 has been repaid + assertEq(coolerA.getLoan(0).principal, 0, "cooler A, loan 0: principal"); + assertEq(coolerA.getLoan(1).principal, loanOne.principal, "cooler A, loan 1: principal"); + assertEq(coolerA.getLoan(2).principal, loanTwo.principal, "cooler A, loan 2: principal"); + // Assert that loan 0 has been migrated to coolerB + assertEq(coolerB.getLoan(0).principal, loanZero.principal, "cooler B, loan 0: principal"); + // Assert that coolerB has no other loans + vm.expectRevert(); + coolerB.getLoan(1); + } + + function test_consolidateWithNewOwner_callerNotOwner_coolerFrom_reverts() + public + givenPolicyActive + givenActivated + givenCoolerB(dai) + { + uint256[] memory idsA = _idsA(); + + // Grant approvals + _grantCallerApprovals( + walletB, + address(clearinghouse), + address(coolerA), + address(coolerB), + idsA + ); + + // Expect revert + vm.expectRevert(abi.encodeWithSelector(LoanConsolidator.OnlyCoolerOwner.selector)); + + // Consolidate loans for coolerA + // Do not perform as the cooler owner + _consolidateWithNewOwner( + walletB, + address(clearinghouse), + address(clearinghouse), + address(coolerA), + address(coolerB), + idsA + ); + } + + function test_consolidateWithNewOwner_insufficientGOhmApproval_reverts() + public + givenPolicyActive + givenActivated + givenCoolerB(dai) + { + uint256[] memory idsA = _idsA(); + + // Grant approvals + (, uint256 gohmApproval, , uint256 ownerReserveTo, uint256 callerReserveTwo) = utils + .requiredApprovals(address(clearinghouse), address(coolerA), idsA); + + _grantCallerApprovals(gohmApproval - 1, ownerReserveTo + callerReserveTwo, 0); + + // Expect revert + vm.expectRevert("ERC20: transfer amount exceeds allowance"); + + // Consolidate loans for coolerA + _consolidateWithNewOwner( + walletA, + address(clearinghouse), + address(clearinghouse), + address(coolerA), + address(coolerB), + idsA + ); + } + + function test_consolidateWithNewOwner_insufficientDaiApproval_reverts() + public + givenPolicyActive + givenActivated + givenCoolerB(dai) + { + uint256[] memory idsA = _idsA(); + + // Grant approvals + (, uint256 gohmApproval, , , ) = utils.requiredApprovals( + address(clearinghouse), + address(coolerA), + idsA + ); + + _grantCallerApprovals(gohmApproval, 1, 0); + + // Expect revert + vm.expectRevert("Dai/insufficient-allowance"); + + // Consolidate loans for coolerA + _consolidateWithNewOwner( + walletA, + address(clearinghouse), + address(clearinghouse), + address(coolerA), + address(coolerB), + idsA + ); + } + + function test_consolidateWithNewOwner_insufficientUsdsApproval_reverts() + public + givenPolicyActive + givenActivated + givenCoolerB(usds) + { + uint256[] memory idsA = _idsA(); + + // Grant approvals + (, uint256 gohmApproval, , uint256 ownerReserveTo, uint256 callerReserveTo) = utils + .requiredApprovals(address(clearinghouseUsds), address(coolerA), idsA); + + _grantCallerApprovals(gohmApproval, 0, 1); + + // Deal fees in USDS to the wallet + deal(address(usds), walletA, ownerReserveTo + callerReserveTo); + + // Expect revert + vm.expectRevert("Usds/insufficient-allowance"); + + // Consolidate loans for coolerA + _consolidateWithNewOwner( + walletA, + address(clearinghouse), + address(clearinghouseUsds), + address(coolerA), + address(coolerB), + idsA + ); + } + + function test_consolidateWithNewOwner_noProtocolFee() + public + givenPolicyActive + givenActivated + givenCoolerB(dai) + { + uint256[] memory idsA = _idsA(); + + // Record the amount of DAI in the wallet + uint256 initPrincipal = dai.balanceOf(walletA); + uint256 interestDue = _getInterestDue(idsA); + + // Grant approvals + _grantCallerApprovals( + walletA, + address(clearinghouse), + address(coolerA), + address(coolerB), + idsA + ); + + // Consolidate loans for coolerA + _consolidateWithNewOwner( + walletA, + address(clearinghouse), + address(clearinghouse), + address(coolerA), + address(coolerB), + idsA + ); + + _assertCoolerLoansCrossClearinghouse(address(coolerA), address(coolerB), _GOHM_AMOUNT); + _assertTokenBalances( + address(dai), + address(coolerA), + address(coolerB), + initPrincipal - interestDue, + 0, + 0, + _GOHM_AMOUNT + ); + _assertApprovals(address(coolerA), address(coolerB)); + } + + function test_consolidateWithNewOwner_lenderFee() + public + givenPolicyActive + givenActivated + givenCoolerB(dai) + givenAdminHasRole + givenMockFlashloanLender + givenMockFlashloanLenderFee(100) // 1% + givenMockFlashloanLenderHasBalance(20_000_000e18) + { + uint256[] memory idsA = _idsA(); + + // Record the initial debt balance + (uint256 totalPrincipal, ) = clearinghouse.getLoanForCollateral(_GOHM_AMOUNT); + + // Record the amount of DAI in the wallet + uint256 initPrincipal = dai.balanceOf(walletA); + (, uint256 interest, , uint256 protocolFee) = utils.fundsRequired( + address(clearinghouse), + address(coolerA), + idsA + ); + + // Grant approvals + _grantCallerApprovals( + walletA, + address(clearinghouse), + address(coolerA), + address(coolerB), + idsA + ); + + // Calculate the expected lender fee + uint256 lenderFee = MockFlashloanLender(lender).flashFee(address(dai), totalPrincipal); + uint256 expectedLenderBalance = 20_000_000e18 + lenderFee; + + // Consolidate loans + _consolidateWithNewOwner( + walletA, + address(clearinghouse), + address(clearinghouse), + address(coolerA), + address(coolerB), + idsA + ); + + _assertCoolerLoansCrossClearinghouse(address(coolerA), address(coolerB), _GOHM_AMOUNT); + _assertTokenBalances( + address(dai), + address(coolerA), + address(coolerB), + initPrincipal - interest - protocolFee - lenderFee, + expectedLenderBalance, + protocolFee, + _GOHM_AMOUNT + ); + _assertApprovals(address(coolerA), address(coolerB)); + } + + function test_consolidateWithNewOwner_protocolFee() + public + givenPolicyActive + givenActivated + givenCoolerB(dai) + givenAdminHasRole + givenProtocolFee(1000) // 1% + { + uint256[] memory idsA = _idsA(); + + // Record the amount of DAI in the wallet + uint256 initPrincipal = dai.balanceOf(walletA); + (, uint256 interest, , uint256 protocolFee) = utils.fundsRequired( + address(clearinghouse), + address(coolerA), + idsA + ); + + // Grant approvals + _grantCallerApprovals( + walletA, + address(clearinghouse), + address(coolerA), + address(coolerB), + idsA + ); + + // Consolidate loans + _consolidateWithNewOwner( + walletA, + address(clearinghouse), + address(clearinghouse), + address(coolerA), + address(coolerB), + idsA + ); + + _assertCoolerLoansCrossClearinghouse(address(coolerA), address(coolerB), _GOHM_AMOUNT); + _assertTokenBalances( + address(dai), + address(coolerA), + address(coolerB), + initPrincipal - interest - protocolFee, + 0, + protocolFee, + _GOHM_AMOUNT + ); + _assertApprovals(address(coolerA), address(coolerB)); + } + + function test_consolidateWithNewOwner_disabledClearinghouse_reverts() + public + givenPolicyActive + givenActivated + givenCoolerB(dai) + { + // Disable the Clearinghouse + vm.prank(emergency); + clearinghouse.emergencyShutdown(); + + uint256[] memory idsA = _idsA(); + + // Grant approvals + _grantCallerApprovals( + walletA, + address(clearinghouse), + address(coolerA), + address(coolerB), + idsA + ); + + // Expect revert + vm.expectRevert("SavingsDai/insufficient-balance"); + + // Consolidate loans + _consolidateWithNewOwner( + walletA, + address(clearinghouse), + address(clearinghouse), + address(coolerA), + address(coolerB), + idsA + ); + } + + function test_consolidateWithNewOwner_protocolFee_daiToUsds() + public + givenPolicyActive + givenActivated + givenCoolerB(usds) + givenAdminHasRole + givenProtocolFee(1000) // 1% + { + uint256[] memory idsA = _idsA(); + + (, uint256 interest, , uint256 protocolFee) = utils.fundsRequired( + address(clearinghouseUsds), + address(coolerA), + idsA + ); + + // Grant approvals + _grantCallerApprovals( + walletA, + address(clearinghouseUsds), + address(coolerA), + address(coolerB), + idsA + ); + + // Deal fees in USDS to the wallet + deal(address(usds), walletA, interest + protocolFee); + // Make sure the wallet has no DAI + deal(address(dai), walletA, 0); + + // Record the amount of USDS in the wallet + uint256 initPrincipal = usds.balanceOf(walletA); + + // Consolidate loans + _consolidateWithNewOwner( + walletA, + address(clearinghouse), + address(clearinghouseUsds), + address(coolerA), + address(coolerB), + idsA + ); + + _assertCoolerLoansCrossClearinghouse(address(coolerA), address(coolerB), _GOHM_AMOUNT); + _assertTokenBalances( + address(usds), + address(coolerA), + address(coolerB), + initPrincipal - interest - protocolFee, + 0, + protocolFee, + _GOHM_AMOUNT + ); + _assertApprovals(address(coolerA), address(coolerB)); + } + + function test_consolidateWithNewOwner_protocolFee_usdsToDai() + public + givenPolicyActive + givenActivated + givenCoolerB(dai) + givenAdminHasRole + givenProtocolFee(1000) // 1% + { + uint256[] memory idsA = _idsA(); + + // Create loans for walletA on the USDS Clearinghouse + deal(address(gohm), walletA, _GOHM_AMOUNT); + address coolerUsds = _createCooler(coolerFactory, walletA, usds); + _createLoans(clearinghouseUsds, Cooler(coolerUsds), walletA); + (, uint256 interest, , uint256 protocolFee) = utils.fundsRequired( + address(clearinghouse), + coolerUsds, + idsA + ); + + // Grant approvals + _grantCallerApprovals( + walletA, + address(clearinghouse), + address(coolerUsds), + address(coolerB), + idsA + ); + + // Deal fees in DAI to the wallet + deal(address(dai), walletA, interest + protocolFee); + // Make sure the wallet has no USDS + deal(address(usds), walletA, 0); + + // Record the amount of DAI in the wallet + uint256 initPrincipal = dai.balanceOf(walletA); + + // Consolidate loans + _consolidateWithNewOwner( + walletA, + address(clearinghouseUsds), + address(clearinghouse), + coolerUsds, + address(coolerB), + idsA + ); + + // Check that coolerUsds has no loans + _assertCoolerLoansCrossClearinghouse(address(coolerUsds), address(coolerB), _GOHM_AMOUNT); + _assertTokenBalances( + address(dai), + address(coolerUsds), + address(coolerB), + initPrincipal - interest - protocolFee, + 0, + protocolFee, + _GOHM_AMOUNT + ); + _assertApprovals(address(coolerUsds), address(coolerB)); + } + + function test_consolidateWithNewOwner_protocolFee_usdsToUsds() + public + givenPolicyActive + givenActivated + givenCoolerB(usds) + givenAdminHasRole + givenProtocolFee(1000) // 1% + { + uint256[] memory idsA = _idsA(); + + // Create loans for walletA on the USDS Clearinghouse + deal(address(gohm), walletA, _GOHM_AMOUNT); + address coolerUsds = _createCooler(coolerFactory, walletA, usds); + _createLoans(clearinghouseUsds, Cooler(coolerUsds), walletA); + (, uint256 interest, , uint256 protocolFee) = utils.fundsRequired( + address(clearinghouseUsds), + coolerUsds, + idsA + ); + + // Grant approvals + _grantCallerApprovals( + walletA, + address(clearinghouseUsds), + address(coolerUsds), + address(coolerB), + idsA + ); + + // Deal fees in USDS to the wallet + deal(address(usds), walletA, interest + protocolFee); + // Make sure the wallet has no DAI + deal(address(dai), walletA, 0); + + // Record the amount of USDS in the wallet + uint256 initPrincipal = usds.balanceOf(walletA); + + // Consolidate loans + _consolidateWithNewOwner( + walletA, + address(clearinghouseUsds), + address(clearinghouseUsds), + coolerUsds, + address(coolerB), + idsA + ); + + _assertCoolerLoansCrossClearinghouse(address(coolerUsds), address(coolerB), _GOHM_AMOUNT); + _assertTokenBalances( + address(usds), + address(coolerUsds), + address(coolerB), + initPrincipal - interest - protocolFee, + 0, + protocolFee, + _GOHM_AMOUNT + ); + _assertApprovals(address(coolerUsds), address(coolerB)); + } + + // setFeePercentage + // when the policy is not active + // [X] it reverts + // when the caller is not the admin + // [X] it reverts + // when the fee is > 100% + // [X] it reverts + // [X] it sets the fee percentage + + function test_setFeePercentage_whenPolicyNotActive_reverts() public givenAdminHasRole { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(LoanConsolidator.OnlyPolicyActive.selector)); + + vm.prank(admin); + utils.setFeePercentage(1000); + } + + function test_setFeePercentage_notAdmin_reverts() public givenAdminHasRole givenPolicyActive { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(ROLESv1.ROLES_RequireRole.selector, ROLE_ADMIN)); + + // Set the fee percentage as a non-admin + utils.setFeePercentage(1000); + } + + function test_setFeePercentage_aboveMax_reverts() public givenAdminHasRole givenPolicyActive { + // Expect revert + vm.expectRevert( + abi.encodeWithSelector(LoanConsolidator.Params_FeePercentageOutOfRange.selector) + ); + + vm.prank(admin); + utils.setFeePercentage(_ONE_HUNDRED_PERCENT + 1); + } + + function test_setFeePercentage( + uint256 feePercentage_ + ) public givenAdminHasRole givenPolicyActive { + uint256 feePercentage = bound(feePercentage_, 0, _ONE_HUNDRED_PERCENT); + + vm.prank(admin); + utils.setFeePercentage(feePercentage); + + assertEq(utils.feePercentage(), feePercentage, "fee percentage"); + } + + // requiredApprovals + // when the policy is not active + // [X] it reverts + // when the caller has no loans + // [X] it returns the correct values + // when the caller has 1 loan + // [X] it returns the correct values + // when the protocol fee is zero + // [X] it returns the correct values + // when the protocol fee is non-zero + // [X] it returns the correct values + // when the lender fee is non-zero + // [X] it returns the correct values + // when clearinghouseFrom is DAI and clearinghouseTo is USDS + // [X] it provides the correct values + // when clearinghouseFrom is USDS and clearinghouseTo is DAI + // [X] it provides the correct values + // when clearinghouseFrom is USDS and clearinghouseTo is USDS + // [X] it provides the correct values + // when clearinghouseFrom is DAI and clearinghouseTo is DAI + // [X] it provides the correct values + // given clearinghouseFrom has a lower LTC than clearinghouseTo + // [X] gOHM approval is higher than the collateral amount for the existing loans + // given clearinghouseFrom has a higher LTC than clearinghouseTo + // [X] gOHM approval is lower than the collateral amount for the existing loans + + function test_requiredApprovals_policyNotActive_reverts() public { + uint256[] memory ids = _idsA(); + + // Expect revert + vm.expectRevert(abi.encodeWithSelector(LoanConsolidator.OnlyPolicyActive.selector)); + + utils.requiredApprovals(address(clearinghouse), address(coolerA), ids); + } + + function test_requiredApprovals_noLoans() public givenPolicyActive { + uint256[] memory ids = new uint256[](0); + + ( + address owner, + uint256 gOhmApproval, + address reserveTo, + uint256 ownerReserveTo, + uint256 callerReserveTo + ) = utils.requiredApprovals(address(clearinghouse), address(coolerA), ids); + + assertEq(owner, walletA, "owner"); + assertEq(gOhmApproval, 0, "gOHM approval"); + assertEq(reserveTo, address(dai), "reserveTo"); + assertEq(ownerReserveTo, 0, "ownerReserveTo"); + assertEq(callerReserveTo, 0, "callerReserveTo"); + } + + function test_requiredApprovals_oneLoan() public givenPolicyActive { + uint256[] memory ids = new uint256[](1); + ids[0] = 0; + + ( + address owner, + uint256 gOhmApproval, + address reserveTo, + uint256 ownerReserveTo, + uint256 callerReserveTo + ) = utils.requiredApprovals(address(clearinghouse), address(coolerA), ids); + + uint256 expectedCollateral; + uint256 expectedPrincipal; + uint256 expectedInterest; + for (uint256 i = 0; i < ids.length; i++) { + Cooler.Loan memory loan = coolerA.getLoan(ids[i]); + + expectedCollateral += loan.collateral; + expectedPrincipal += loan.principal; + expectedInterest += loan.interestDue; + } + + uint256 expectedProtocolFee = 0; + uint256 expectedLenderFee = 0; + + assertEq(owner, walletA, "owner"); + assertEq(gOhmApproval, expectedCollateral, "gOHM approval"); + assertEq(reserveTo, address(dai), "reserveTo"); + assertEq(ownerReserveTo, expectedPrincipal, "ownerReserveTo"); + assertEq( + callerReserveTo, + expectedInterest + expectedProtocolFee + expectedLenderFee, + "callerReserveTo" + ); + } + + function test_requiredApprovals_noProtocolFee() public givenPolicyActive { + uint256[] memory ids = _idsA(); + + ( + address owner_, + uint256 gohmApproval, + address reserveTo, + uint256 ownerReserveTo, + uint256 callerReserveTo + ) = utils.requiredApprovals(address(clearinghouse), address(coolerA), ids); + + uint256 expectedPrincipal; + uint256 expectedInterest; + for (uint256 i = 0; i < ids.length; i++) { + Cooler.Loan memory loan = coolerA.getLoan(ids[i]); + + expectedPrincipal += loan.principal; + expectedInterest += loan.interestDue; + } + + uint256 expectedProtocolFee = 0; + uint256 expectedLenderFee = 0; + + assertEq(owner_, walletA, "owner"); + assertEq(gohmApproval, _GOHM_AMOUNT, "gOHM approval"); + assertEq(reserveTo, address(dai), "reserveTo"); + assertEq(ownerReserveTo, expectedPrincipal, "ownerReserveTo"); + assertEq( + callerReserveTo, + expectedInterest + expectedProtocolFee + expectedLenderFee, + "callerReserveTo" + ); + } + + function test_requiredApprovals_protocolFee() + public + givenPolicyActive + givenAdminHasRole + givenProtocolFee(1000) // 1% + { + uint256[] memory ids = _idsA(); + + ( + address owner_, + uint256 gohmApproval, + address reserveTo, + uint256 ownerReserveTo, + uint256 callerReserveTo + ) = utils.requiredApprovals(address(clearinghouse), address(coolerA), ids); + + uint256 expectedPrincipal; + uint256 expectedInterest; + for (uint256 i = 0; i < ids.length; i++) { + Cooler.Loan memory loan = coolerA.getLoan(ids[i]); + + expectedPrincipal += loan.principal; + expectedInterest += loan.interestDue; + } + + uint256 expectedProtocolFee = ((expectedPrincipal + expectedInterest) * 1000) / + _ONE_HUNDRED_PERCENT; + uint256 expectedLenderFee = 0; + + assertEq(owner_, walletA, "owner"); + assertEq(gohmApproval, _GOHM_AMOUNT, "gOHM approval"); + assertEq(reserveTo, address(dai), "reserveTo"); + assertEq(ownerReserveTo, expectedPrincipal, "ownerReserveTo"); + assertEq( + callerReserveTo, + expectedInterest + expectedProtocolFee + expectedLenderFee, + "callerReserveTo" + ); + } + + function test_requiredApprovals_lenderFee() + public + givenPolicyActive + givenAdminHasRole + givenMockFlashloanLender + givenMockFlashloanLenderFee(1000) // 10% + givenMockFlashloanLenderHasBalance(20_000_000e18) + { + uint256[] memory ids = _idsA(); + + ( + address owner_, + uint256 gohmApproval, + address reserveTo, + uint256 ownerReserveTo, + uint256 callerReserveTo + ) = utils.requiredApprovals(address(clearinghouse), address(coolerA), ids); + + uint256 expectedPrincipal; + uint256 expectedInterest; + for (uint256 i = 0; i < ids.length; i++) { + Cooler.Loan memory loan = coolerA.getLoan(ids[i]); + + expectedPrincipal += loan.principal; + expectedInterest += loan.interestDue; + } + + uint256 expectedProtocolFee = 0; + uint256 expectedLenderFee = (expectedPrincipal * 1000) / _ONE_HUNDRED_PERCENT; + + assertEq(owner_, walletA, "owner"); + assertEq(gohmApproval, _GOHM_AMOUNT, "gOHM approval"); + assertEq(reserveTo, address(dai), "reserveTo"); + assertEq(ownerReserveTo, expectedPrincipal, "ownerReserveTo"); + assertEq( + callerReserveTo, + expectedInterest + expectedProtocolFee + expectedLenderFee, + "callerReserveTo" + ); + } + + function test_requiredApprovals_protocolFee_daiToUsds() + public + givenPolicyActive + givenAdminHasRole + givenProtocolFee(1000) // 1% + { + uint256[] memory ids = _idsA(); + + ( + address owner_, + uint256 gohmApproval, + address reserveTo, + uint256 ownerReserveTo, + uint256 callerReserveTo + ) = utils.requiredApprovals(address(clearinghouseUsds), address(coolerA), ids); + + uint256 expectedPrincipal; + uint256 expectedInterest; + for (uint256 i = 0; i < ids.length; i++) { + Cooler.Loan memory loan = coolerA.getLoan(ids[i]); + + expectedPrincipal += loan.principal; + expectedInterest += loan.interestDue; + } + + uint256 expectedProtocolFee = ((expectedPrincipal + expectedInterest) * 1000) / + _ONE_HUNDRED_PERCENT; + uint256 expectedLenderFee = 0; + + assertEq(owner_, walletA, "owner"); + assertEq(gohmApproval, _GOHM_AMOUNT, "gOHM approval"); + assertEq(reserveTo, address(usds), "reserveTo"); + assertEq(ownerReserveTo, expectedPrincipal, "ownerReserveTo"); + assertEq( + callerReserveTo, + expectedInterest + expectedProtocolFee + expectedLenderFee, + "callerReserveTo" + ); + } + + function test_requiredApprovals_protocolFee_usdsToDai() + public + givenPolicyActive + givenAdminHasRole + givenProtocolFee(1000) // 1% + { + uint256[] memory ids = _idsA(); + + // Create Cooler loans on the USDS Clearinghouse + deal(address(gohm), walletA, _GOHM_AMOUNT); + _createCoolerAndLoans(clearinghouseUsds, coolerFactory, walletA, usds); + + ( + address owner_, + uint256 gohmApproval, + address reserveTo, + uint256 ownerReserveTo, + uint256 callerReserveTo + ) = utils.requiredApprovals(address(clearinghouse), address(coolerA), ids); + + uint256 expectedPrincipal; + uint256 expectedInterest; + for (uint256 i = 0; i < ids.length; i++) { + Cooler.Loan memory loan = coolerA.getLoan(ids[i]); + + expectedPrincipal += loan.principal; + expectedInterest += loan.interestDue; + } + + uint256 expectedProtocolFee = ((expectedPrincipal + expectedInterest) * 1000) / + _ONE_HUNDRED_PERCENT; + uint256 expectedLenderFee = 0; + + assertEq(owner_, walletA, "owner"); + assertEq(gohmApproval, _GOHM_AMOUNT, "gOHM approval"); + assertEq(reserveTo, address(dai), "reserveTo"); + assertEq(ownerReserveTo, expectedPrincipal, "ownerReserveTo"); + assertEq( + callerReserveTo, + expectedInterest + expectedProtocolFee + expectedLenderFee, + "callerReserveTo" + ); + } + + function test_requiredApprovals_protocolFee_usdsToUsds() + public + givenPolicyActive + givenAdminHasRole + givenProtocolFee(1000) // 1% + { + uint256[] memory ids = _idsA(); + + // Create Cooler loans on the USDS Clearinghouse + deal(address(gohm), walletA, _GOHM_AMOUNT); + _createCoolerAndLoans(clearinghouseUsds, coolerFactory, walletA, usds); + + ( + address owner_, + uint256 gohmApproval, + address reserveTo, + uint256 ownerReserveTo, + uint256 callerReserveTo + ) = utils.requiredApprovals(address(clearinghouseUsds), address(coolerA), ids); + + uint256 expectedPrincipal; + uint256 expectedInterest; + for (uint256 i = 0; i < ids.length; i++) { + Cooler.Loan memory loan = coolerA.getLoan(ids[i]); + + expectedPrincipal += loan.principal; + expectedInterest += loan.interestDue; + } + + uint256 expectedProtocolFee = ((expectedPrincipal + expectedInterest) * 1000) / + _ONE_HUNDRED_PERCENT; + uint256 expectedLenderFee = 0; + + assertEq(owner_, walletA, "owner"); + assertEq(gohmApproval, _GOHM_AMOUNT, "gOHM approval"); + assertEq(reserveTo, address(usds), "reserveTo"); + assertEq(ownerReserveTo, expectedPrincipal, "ownerReserveTo"); + assertEq( + callerReserveTo, + expectedInterest + expectedProtocolFee + expectedLenderFee, + "callerReserveTo" + ); + } + + function test_requiredApprovals_fuzz( + uint256 loanOneCollateral_, + uint256 loanTwoCollateral_ + ) public givenPolicyActive givenCoolerB(dai) { + // Bound the collateral values + loanOneCollateral_ = bound(loanOneCollateral_, 1, 1e18); + loanTwoCollateral_ = bound(loanTwoCollateral_, 1, 1e18); + + // Fund the wallet with gOHM + deal(address(gohm), walletB, loanOneCollateral_ + loanTwoCollateral_); + + // Approve clearinghouse to spend gOHM + vm.prank(walletB); + gohm.approve(address(clearinghouse), loanOneCollateral_ + loanTwoCollateral_); + + // Take loans + uint256 totalPrincipal; + { + vm.startPrank(walletB); + // Loan 0 for coolerB + (uint256 loanOnePrincipal, ) = clearinghouse.getLoanForCollateral(loanOneCollateral_); + totalPrincipal += loanOnePrincipal; + clearinghouse.lendToCooler(coolerB, loanOnePrincipal); + + // Loan 1 for coolerB + (uint256 loanTwoPrincipal, ) = clearinghouse.getLoanForCollateral(loanTwoCollateral_); + totalPrincipal += loanTwoPrincipal; + clearinghouse.lendToCooler(coolerB, loanTwoPrincipal); + vm.stopPrank(); + } + + uint256[] memory loanIds = new uint256[](2); + loanIds[0] = 0; + loanIds[1] = 1; + + // Grant approvals + (, uint256 gohmApproval, , , ) = utils.requiredApprovals( + address(clearinghouse), + address(coolerB), + loanIds + ); + + // Assertions + // The gOHM approval should be the amount of collateral required for the total principal + // At small values, this may be slightly different due to rounding + assertEq(gohmApproval, clearinghouse.getCollateralForLoan(totalPrincipal), "gOHM approval"); + } + + function test_requiredApprovals_clearinghouseFromLowerLTC() public givenPolicyActive { + // Create a new Clearinghouse with a higher LTC + Clearinghouse newClearinghouse = _createClearinghouseWithHigherLTC(); + + // Calculate the collateral required for the existing loans + (uint256 existingPrincipal, ) = clearinghouse.getLoanForCollateral(_GOHM_AMOUNT); + uint256 newCollateralRequired = newClearinghouse.getCollateralForLoan(existingPrincipal); + + uint256[] memory loanIds = _idsA(); + (, uint256 gohmApproval, , , ) = utils.requiredApprovals( + address(newClearinghouse), + address(coolerA), + loanIds + ); + + assertEq(gohmApproval, newCollateralRequired, "gOHM approval"); + } + + function test_requiredApprovals_clearinghouseFromHigherLTC() public givenPolicyActive { + // Create a new Clearinghouse with a lower LTC + Clearinghouse newClearinghouse = _createClearinghouseWithLowerLTC(); + + // Calculate the collateral required for the existing loans + (uint256 existingPrincipal, ) = clearinghouse.getLoanForCollateral(_GOHM_AMOUNT); + uint256 newCollateralRequired = newClearinghouse.getCollateralForLoan(existingPrincipal); + + uint256[] memory loanIds = _idsA(); + (, uint256 gohmApproval, , , ) = utils.requiredApprovals( + address(newClearinghouse), + address(coolerA), + loanIds + ); + + assertEq(gohmApproval, newCollateralRequired, "gOHM approval"); + } + + // collateralRequired + // given clearinghouseFrom has the same LTC as clearinghouseTo + // [X] it returns the correct values + // given clearinghouseFrom has a lower LTC than clearinghouseTo + // [X] additional collateral is 0 + // given clearinghouseFrom has a higher LTC than clearinghouseTo + // [X] additional collateral is required + + function test_collateralRequired_fuzz( + uint256 loanOneCollateral_, + uint256 loanTwoCollateral_ + ) public givenPolicyActive givenCoolerB(dai) { + // Bound the collateral values + loanOneCollateral_ = bound(loanOneCollateral_, 1, 1e18); + loanTwoCollateral_ = bound(loanTwoCollateral_, 1, 1e18); + + // Fund the wallet with gOHM + deal(address(gohm), walletB, loanOneCollateral_ + loanTwoCollateral_); + + // Approve clearinghouse to spend gOHM + vm.prank(walletB); + gohm.approve(address(clearinghouse), loanOneCollateral_ + loanTwoCollateral_); + + // Take loans + uint256 totalPrincipal; + { + vm.startPrank(walletB); + // Loan 0 for coolerB + (uint256 loanOnePrincipal, ) = clearinghouse.getLoanForCollateral(loanOneCollateral_); + clearinghouse.lendToCooler(coolerB, loanOnePrincipal); + + // Loan 1 for coolerB + (uint256 loanTwoPrincipal, ) = clearinghouse.getLoanForCollateral(loanTwoCollateral_); + clearinghouse.lendToCooler(coolerB, loanTwoPrincipal); + vm.stopPrank(); + + totalPrincipal = loanOnePrincipal + loanTwoPrincipal; + } + + // Get the amount of collateral for the loans + uint256 existingLoanCollateralExpected = coolerB.getLoan(0).collateral + + coolerB.getLoan(1).collateral; + + // Get the amount of collateral required for the consolidated loan + uint256 consolidatedLoanCollateralExpected = Clearinghouse(clearinghouse) + .getCollateralForLoan(totalPrincipal); + + // Get the amount of additional collateral required + uint256 additionalCollateralExpected; + if (consolidatedLoanCollateralExpected > existingLoanCollateralExpected) { + additionalCollateralExpected = + consolidatedLoanCollateralExpected - + existingLoanCollateralExpected; + } + + // Call collateralRequired + uint256[] memory loanIds = new uint256[](2); + loanIds[0] = 0; + loanIds[1] = 1; + + ( + uint256 consolidatedLoanCollateral, + uint256 existingLoanCollateral, + uint256 additionalCollateral + ) = utils.collateralRequired(address(clearinghouse), address(coolerB), loanIds); + + // Assertions + assertEq( + consolidatedLoanCollateral, + consolidatedLoanCollateralExpected, + "consolidated loan collateral" + ); + assertEq( + existingLoanCollateral, + existingLoanCollateralExpected, + "existing loan collateral" + ); + assertEq(additionalCollateral, additionalCollateralExpected, "additional collateral"); + } + + function test_collateralRequired_clearinghouseFromLowerLTC() public givenPolicyActive { + // Create a new Clearinghouse with a higher LTC + Clearinghouse newClearinghouse = _createClearinghouseWithHigherLTC(); + + // Calculate the collateral required for the existing loans + (uint256 existingPrincipal, ) = clearinghouse.getLoanForCollateral(_GOHM_AMOUNT); + uint256 newCollateralRequired = newClearinghouse.getCollateralForLoan(existingPrincipal); + + uint256[] memory loanIds = _idsA(); + + ( + uint256 consolidatedLoanCollateral, + uint256 existingLoanCollateral, + uint256 additionalCollateral + ) = utils.collateralRequired(address(newClearinghouse), address(coolerA), loanIds); + + // Assert values + // Consolidated loan collateral is the same as what the new Clearinghouse requires + assertEq(consolidatedLoanCollateral, newCollateralRequired, "consolidated loan collateral"); + + // Existing loan collateral is the same as what has been deposited already + assertEq(existingLoanCollateral, _GOHM_AMOUNT, "existing loan collateral"); + + // Additional collateral is 0, as less collateral is required + assertEq(additionalCollateral, 0, "additional collateral"); + } + + function test_collateralRequired_clearinghouseFromHigherLTC() public givenPolicyActive { + // Create a new Clearinghouse with a lower LTC + Clearinghouse newClearinghouse = _createClearinghouseWithLowerLTC(); + + // Calculate the collateral required for the existing loans + (uint256 existingPrincipal, ) = clearinghouse.getLoanForCollateral(_GOHM_AMOUNT); + uint256 newCollateralRequired = newClearinghouse.getCollateralForLoan(existingPrincipal); + + uint256[] memory loanIds = _idsA(); + + ( + uint256 consolidatedLoanCollateral, + uint256 existingLoanCollateral, + uint256 additionalCollateral + ) = utils.collateralRequired(address(newClearinghouse), address(coolerA), loanIds); + + // Assert values + // Consolidated loan collateral is the same as what the new Clearinghouse requires + assertEq(consolidatedLoanCollateral, newCollateralRequired, "consolidated loan collateral"); + + // Existing loan collateral is the same as what has been deposited already + assertEq(existingLoanCollateral, _GOHM_AMOUNT, "existing loan collateral"); + + // Additional collateral is the difference between the existing loan collateral and what the new Clearinghouse requires + assertEq( + additionalCollateral, + newCollateralRequired - _GOHM_AMOUNT, + "additional collateral" + ); + } + + // fundsRequired + // given there is no protocol fee + // [X] it returns the correct values + // given there is a lender fee + // [X] it returns the correct values + // given the loan has interest due + // [X] it returns the correct values + // given clearinghouseTo is DAI + // [X] it returns the correct values + // given clearinghouseTo is USDS + // [X] it returns the correct values + + function test_fundsRequired_noProtocolFee() public givenPolicyActive { + uint256[] memory ids = _idsA(); + + // Calculate the interest due + uint256 expectedPrincipal; + uint256 expectedInterest; + for (uint256 i = 0; i < ids.length; i++) { + Cooler.Loan memory loan = coolerA.getLoan(ids[i]); + + expectedPrincipal += loan.principal; + expectedInterest += loan.interestDue; + } + + uint256 expectedProtocolFee = 0; + uint256 expectedLenderFee = 0; + + (address reserveTo, uint256 interest, uint256 lenderFee, uint256 protocolFee) = utils + .fundsRequired(address(clearinghouse), address(coolerA), ids); + + assertEq(reserveTo, address(dai), "reserveTo"); + assertEq(interest, expectedInterest, "interest"); + assertEq(lenderFee, expectedLenderFee, "lenderFee"); + assertEq(protocolFee, expectedProtocolFee, "protocolFee"); + } + + function test_fundsRequired_lenderFee() + public + givenPolicyActive + givenAdminHasRole + givenMockFlashloanLender + givenMockFlashloanLenderFee(10000) // 10% + givenMockFlashloanLenderHasBalance(20_000_000e18) + { + uint256[] memory ids = _idsA(); + + // Calculate the interest due + uint256 expectedPrincipal; + uint256 expectedInterest; + for (uint256 i = 0; i < ids.length; i++) { + Cooler.Loan memory loan = coolerA.getLoan(ids[i]); + + expectedPrincipal += loan.principal; + expectedInterest += loan.interestDue; + } + + uint256 expectedProtocolFee = 0; + uint256 expectedLenderFee = (expectedPrincipal * 10000) / _ONE_HUNDRED_PERCENT; + + (address reserveTo, uint256 interest, uint256 lenderFee, uint256 protocolFee) = utils + .fundsRequired(address(clearinghouse), address(coolerA), ids); + + assertEq(reserveTo, address(dai), "reserveTo"); + assertEq(interest, expectedInterest, "interest"); + assertEq(lenderFee, expectedLenderFee, "lenderFee"); + assertEq(protocolFee, expectedProtocolFee, "protocolFee"); + } + + function test_fundsRequired_interestDue() + public + givenPolicyActive + givenAdminHasRole + givenProtocolFee(1000) + { + // Warp to the future, so that there is interest due + vm.warp(block.timestamp + 1 days); + + uint256[] memory ids = _idsA(); + + // Calculate the interest due + uint256 expectedPrincipal; + uint256 expectedInterest; + for (uint256 i = 0; i < ids.length; i++) { + Cooler.Loan memory loan = coolerA.getLoan(ids[i]); + + expectedPrincipal += loan.principal; + expectedInterest += loan.interestDue; + } + + uint256 expectedProtocolFee = ((expectedPrincipal + expectedInterest) * 1000) / + _ONE_HUNDRED_PERCENT; + uint256 expectedLenderFee = 0; + + (address reserveTo, uint256 interest, uint256 lenderFee, uint256 protocolFee) = utils + .fundsRequired(address(clearinghouse), address(coolerA), ids); + + assertEq(reserveTo, address(dai), "reserveTo"); + assertEq(interest, expectedInterest, "interest"); + assertEq(lenderFee, expectedLenderFee, "lenderFee"); + assertEq(protocolFee, expectedProtocolFee, "protocolFee"); + } + + function test_fundsRequired_toUsds() + public + givenPolicyActive + givenAdminHasRole + givenProtocolFee(1000) + { + uint256[] memory ids = _idsA(); + + // Calculate the interest due + uint256 expectedPrincipal; + uint256 expectedInterest; + for (uint256 i = 0; i < ids.length; i++) { + Cooler.Loan memory loan = coolerA.getLoan(ids[i]); + + expectedPrincipal += loan.principal; + expectedInterest += loan.interestDue; + } + + uint256 expectedProtocolFee = ((expectedPrincipal + expectedInterest) * 1000) / + _ONE_HUNDRED_PERCENT; + uint256 expectedLenderFee = 0; + + (address reserveTo, uint256 interest, uint256 lenderFee, uint256 protocolFee) = utils + .fundsRequired(address(clearinghouseUsds), address(coolerA), ids); + + assertEq(reserveTo, address(usds), "reserveTo"); + assertEq(interest, expectedInterest, "interest"); + assertEq(lenderFee, expectedLenderFee, "lenderFee"); + assertEq(protocolFee, expectedProtocolFee, "protocolFee"); + } + + function test_fundsRequired_toDai() public givenPolicyActive givenAdminHasRole { + uint256[] memory ids = _idsA(); + + // Calculate the interest due + uint256 expectedPrincipal; + uint256 expectedInterest; + for (uint256 i = 0; i < ids.length; i++) { + Cooler.Loan memory loan = coolerA.getLoan(ids[i]); + + expectedPrincipal += loan.principal; + expectedInterest += loan.interestDue; + } + + uint256 expectedProtocolFee = 0; + uint256 expectedLenderFee = 0; + + (address reserveTo, uint256 interest, uint256 lenderFee, uint256 protocolFee) = utils + .fundsRequired(address(clearinghouse), address(coolerA), ids); + + assertEq(reserveTo, address(dai), "reserveTo"); + assertEq(interest, expectedInterest, "interest"); + assertEq(lenderFee, expectedLenderFee, "lenderFee"); + assertEq(protocolFee, expectedProtocolFee, "protocolFee"); + } + + // constructor + // when the kernel address is the zero address + // [X] it reverts + // when the fee percentage is > 100e2 + // [X] it reverts + // [X] it sets the values + + function test_constructor_zeroKernel_reverts() public { + // Expect revert + bytes memory err = abi.encodeWithSelector(LoanConsolidator.Params_InvalidAddress.selector); + vm.expectRevert(err); + + new LoanConsolidator(address(0), 0); + } + + function test_constructor_feePercentageAboveMax_reverts() public { + // Expect revert + bytes memory err = abi.encodeWithSelector( + LoanConsolidator.Params_FeePercentageOutOfRange.selector + ); + vm.expectRevert(err); + + new LoanConsolidator(address(kernel), _ONE_HUNDRED_PERCENT + 1); + } + + function test_constructor(uint256 feePercentage_) public { + uint256 feePercentage = bound(feePercentage_, 0, _ONE_HUNDRED_PERCENT); + + utils = new LoanConsolidator(address(kernel), feePercentage); + + assertEq(address(utils.kernel()), address(kernel), "kernel"); + assertEq(utils.feePercentage(), feePercentage, "fee percentage"); + assertEq(utils.consolidatorActive(), false, "consolidator should be inactive"); + } + + // activate + // when the policy is not active + // [X] it reverts + // when the caller is not an admin or emergency shutdown + // [X] it reverts + // when the caller is the admin role + // [X] it reverts + // when the caller is the emergency shutdown role + // when the contract is already active + // [X] it does nothing + // [X] it sets the active flag to true + + function test_activate_policyNotActive_reverts() public givenAdminHasRole { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(LoanConsolidator.OnlyPolicyActive.selector)); + + vm.prank(emergency); + utils.activate(); + } + + function test_activate_notAdminOrEmergency_reverts() + public + givenPolicyActive + givenDeactivated + givenAdminHasRole + { + // Expect revert + bytes memory err = abi.encodeWithSelector( + ROLESv1.ROLES_RequireRole.selector, + ROLE_EMERGENCY_SHUTDOWN + ); + vm.expectRevert(err); + + utils.activate(); + } + + function test_activate_asAdmin_reverts() + public + givenPolicyActive + givenDeactivated + givenAdminHasRole + { + // Expect revert + bytes memory err = abi.encodeWithSelector( + ROLESv1.ROLES_RequireRole.selector, + ROLE_EMERGENCY_SHUTDOWN + ); + vm.expectRevert(err); + + vm.prank(admin); + utils.activate(); + } + + function test_activate_asEmergency() + public + givenPolicyActive + givenDeactivated + givenAdminHasRole + { + vm.prank(emergency); + utils.activate(); + + assertTrue(utils.consolidatorActive(), "consolidator active"); + } + + function test_activate_asEmergency_alreadyActive() public givenPolicyActive givenAdminHasRole { + vm.prank(emergency); + utils.activate(); + + assertTrue(utils.consolidatorActive(), "consolidator active"); + } + + // deactivate + // when the policy is not active + // [X] it reverts + // when the caller is not an admin or emergency shutdown + // [X] it reverts + // when the caller has the admin role + // [X] it reverts + // when the caller has the emergency shutdown role + // when the contract is already deactivated + // [X] it does nothing + // [X] it sets the active flag to false + + function test_deactivate_policyNotActive_reverts() public givenAdminHasRole { + // Expect revert + vm.expectRevert(abi.encodeWithSelector(LoanConsolidator.OnlyPolicyActive.selector)); + + vm.prank(emergency); + utils.deactivate(); + } + + function test_deactivate_notAdminOrEmergency_reverts() + public + givenPolicyActive + givenAdminHasRole + { + // Expect revert + vm.expectRevert( + abi.encodeWithSelector(ROLESv1.ROLES_RequireRole.selector, ROLE_EMERGENCY_SHUTDOWN) + ); + + utils.deactivate(); + } + + function test_deactivate_asAdmin_reverts() public givenPolicyActive givenAdminHasRole { + // Expect revert + vm.expectRevert( + abi.encodeWithSelector(ROLESv1.ROLES_RequireRole.selector, ROLE_EMERGENCY_SHUTDOWN) + ); + + vm.prank(admin); + utils.deactivate(); + } + + function test_deactivate_asEmergency() public givenPolicyActive givenAdminHasRole { + vm.prank(emergency); + utils.deactivate(); + + assertFalse(utils.consolidatorActive(), "consolidator active"); + } + + function test_deactivate_asEmergency_alreadyDeactivated() + public + givenPolicyActive + givenDeactivated + givenAdminHasRole + { + vm.prank(emergency); + utils.deactivate(); + + assertFalse(utils.consolidatorActive(), "consolidator active"); + } + + // --- AUX FUNCTIONS ----------------------------------------------------------- + + function _idsA() internal pure returns (uint256[] memory) { + uint256[] memory ids = new uint256[](3); + ids[0] = 0; + ids[1] = 1; + ids[2] = 2; + return ids; + } +} diff --git a/src/test/proposals/ContractRegistryProposal.t.sol b/src/test/proposals/ContractRegistryProposal.t.sol new file mode 100644 index 000000000..ce6fc68e1 --- /dev/null +++ b/src/test/proposals/ContractRegistryProposal.t.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import {ProposalTest} from "./ProposalTest.sol"; +import {TestSuite} from "proposal-sim/test/TestSuite.t.sol"; +import {console2} from "forge-std/console2.sol"; + +import {Kernel, Actions, toKeycode} from "src/Kernel.sol"; +import {ContractRegistryAdmin} from "src/policies/ContractRegistryAdmin.sol"; + +import {ContractRegistryProposal} from "src/proposals/ContractRegistryProposal.sol"; + +contract ContractRegistryProposalTest is ProposalTest { + Kernel public kernel; + + function setUp() public virtual { + // Mainnet Fork at a fixed block + // Prior to the proposal deployment (otherwise it will fail) + // 21371770 is the deployment block for ContractRegistryAdmin + vm.createSelectFork(RPC_URL, 21371770); + + /// @dev Deploy your proposal + ContractRegistryProposal proposal = new ContractRegistryProposal(); + + /// @dev Set `hasBeenSubmitted` to `true` once the proposal has been submitted on-chain. + hasBeenSubmitted = true; + + // Populate addresses array + { + // Populate addresses array + address[] memory proposalsAddresses = new address[](1); + proposalsAddresses[0] = address(proposal); + + // Deploy TestSuite contract + suite = new TestSuite(ADDRESSES_PATH, proposalsAddresses); + + // Set addresses object + addresses = suite.addresses(); + + kernel = Kernel(addresses.getAddress("olympus-kernel")); + } + + // Simulate the ContractRegistryInstall batch script having been run + // The simulation will revert otherwise + // Install RGSTY + { + address rgsty = addresses.getAddress("olympus-module-rgsty"); + + if (address(kernel.getModuleForKeycode(toKeycode("RGSTY"))) == address(0)) { + console2.log("Installing RGSTY"); + + vm.prank(addresses.getAddress("olympus-multisig-dao")); + kernel.executeAction(Actions.InstallModule, rgsty); + } + } + + // Install ContractRegistryAdmin + { + address contractRegistryAdmin = addresses.getAddress( + "olympus-policy-contract-registry-admin" + ); + + if (!ContractRegistryAdmin(contractRegistryAdmin).isActive()) { + console2.log("Activating ContractRegistryAdmin"); + + vm.prank(addresses.getAddress("olympus-multisig-dao")); + kernel.executeAction(Actions.ActivatePolicy, contractRegistryAdmin); + } + } + + // Simulate the proposal + _simulateProposal(address(proposal)); + } +} diff --git a/src/test/proposals/EmissionManagerProposal.t.sol b/src/test/proposals/EmissionManagerProposal.t.sol index c66083dc1..c6b1c08de 100644 --- a/src/test/proposals/EmissionManagerProposal.t.sol +++ b/src/test/proposals/EmissionManagerProposal.t.sol @@ -1,84 +1,24 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.0; -// Proposal test-suite imports -import "forge-std/Test.sol"; -import {TestSuite} from "proposal-sim/test/TestSuite.t.sol"; -import {Addresses} from "proposal-sim/addresses/Addresses.sol"; -import {Kernel, Actions, toKeycode} from "src/Kernel.sol"; -import {RolesAdmin} from "policies/RolesAdmin.sol"; -import {GovernorBravoDelegator} from "src/external/governance/GovernorBravoDelegator.sol"; -import {GovernorBravoDelegate} from "src/external/governance/GovernorBravoDelegate.sol"; -import {Timelock} from "src/external/governance/Timelock.sol"; +import {ProposalTest} from "./ProposalTest.sol"; // EmissionManagerProposal imports import {EmissionManagerProposal} from "src/proposals/EmissionManagerProposal.sol"; -/// @notice Creates a sandboxed environment from a mainnet fork, to simulate the proposal. -/// @dev Update the `setUp` function to deploy your proposal and set the submission -/// flag to `true` once the proposal has been submitted on-chain. -/// Note: this will fail if the OCGPermissions script has not been run yet. -contract EmissionManagerProposalTest is Test { - string public constant ADDRESSES_PATH = "./src/proposals/addresses.json"; - TestSuite public suite; - Addresses public addresses; - - // Wether the proposal has been submitted or not. - // If true, the framework will check that calldatas match. - bool public hasBeenSubmitted; - - string RPC_URL = vm.envString("FORK_TEST_RPC_URL"); - - /// @notice Creates a sandboxed environment from a mainnet fork. +contract EmissionManagerProposalTest is ProposalTest { function setUp() public virtual { // Mainnet Fork at a fixed block - // Prior to actual deployment of the proposal (otherwise it will fail) - 21071000 - // TODO: Update the block number once the proposal has been submitted on-chain. - vm.createSelectFork(RPC_URL, 21071000); + // Prior to actual deployment of the proposal (otherwise it will fail) - 21224026 + vm.createSelectFork(RPC_URL, 21224026 - 1); /// @dev Deploy your proposal EmissionManagerProposal proposal = new EmissionManagerProposal(); /// @dev Set `hasBeenSubmitted` to `true` once the proposal has been submitted on-chain. - hasBeenSubmitted = false; - - /// [DO NOT DELETE] - /// @notice This section is used to simulate the proposal on the mainnet fork. - { - // Populate addresses array - address[] memory proposalsAddresses = new address[](1); - proposalsAddresses[0] = address(proposal); - - // Deploy TestSuite contract - suite = new TestSuite(ADDRESSES_PATH, proposalsAddresses); - - // Set addresses object - addresses = suite.addresses(); - - // Set debug mode - suite.setDebug(true); - // Execute proposals - suite.testProposals(); - - // Proposals execution may change addresses, so we need to update the addresses object. - addresses = suite.addresses(); - - // Check if simulated calldatas match the ones from mainnet. - if (hasBeenSubmitted) { - address governor = addresses.getAddress("olympus-governor"); - bool[] memory matches = suite.checkProposalCalldatas(governor); - for (uint256 i; i < matches.length; i++) { - assertTrue(matches[i]); - } - } else { - console.log("\n\n------- Calldata check (simulation vs mainnet) -------\n"); - console.log("Proposal has NOT been submitted on-chain yet.\n"); - } - } - } + hasBeenSubmitted = true; - // [DO NOT DELETE] Dummy test to ensure `setUp` is executed and the proposal simulated. - function testProposal_simulate() public { - assertTrue(true); + // Simulate the proposal + _simulateProposal(address(proposal)); } } diff --git a/src/test/proposals/LoanConsolidatorProposal.t.sol b/src/test/proposals/LoanConsolidatorProposal.t.sol new file mode 100644 index 000000000..2823e8341 --- /dev/null +++ b/src/test/proposals/LoanConsolidatorProposal.t.sol @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +import {ProposalTest} from "./ProposalTest.sol"; +import {TestSuite} from "proposal-sim/test/TestSuite.t.sol"; +import {console2} from "forge-std/console2.sol"; + +import {Kernel, Actions} from "src/Kernel.sol"; +import {LoanConsolidator} from "src/policies/LoanConsolidator.sol"; + +// Proposal imports +import {LoanConsolidatorProposal} from "src/proposals/LoanConsolidatorProposal.sol"; + +contract LoanConsolidatorProposalTest is ProposalTest { + Kernel public kernel; + + function setUp() public virtual { + // Mainnet Fork at a fixed block + // Prior to the proposal deployment (otherwise it will fail) + // 21531758 is when the LoanConsolidator was installed in the kernel + vm.createSelectFork(RPC_URL, 21531758 + 1); + + /// @dev Deploy your proposal + LoanConsolidatorProposal proposal = new LoanConsolidatorProposal(); + + /// @dev Set `hasBeenSubmitted` to `true` once the proposal has been submitted on-chain. + hasBeenSubmitted = true; + + // Populate addresses array + { + // Populate addresses array + address[] memory proposalsAddresses = new address[](1); + proposalsAddresses[0] = address(proposal); + + // Deploy TestSuite contract + suite = new TestSuite(ADDRESSES_PATH, proposalsAddresses); + + // Set addresses object + addresses = suite.addresses(); + + kernel = Kernel(addresses.getAddress("olympus-kernel")); + } + + // Simulate the LoanConsolidatorInstall batch script having been run + // The simulation will revert otherwise + // This proposal will also fail until the RGSTY proposal has been executed + // Install LoanConsolidator + if (!hasBeenSubmitted) { + address loanConsolidator = addresses.getAddress("olympus-policy-loan-consolidator"); + + if (!LoanConsolidator(loanConsolidator).isActive()) { + console2.log("Activating LoanConsolidator"); + + vm.prank(addresses.getAddress("olympus-multisig-dao")); + kernel.executeAction(Actions.ActivatePolicy, loanConsolidator); + } + } + + // Simulate the proposal + _simulateProposal(address(proposal)); + } +} diff --git a/src/test/proposals/OIP_166.t.sol b/src/test/proposals/OIP_166.t.sol index 414999fa3..d679f1695 100644 --- a/src/test/proposals/OIP_166.t.sol +++ b/src/test/proposals/OIP_166.t.sol @@ -1,48 +1,28 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.0; -// Proposal test-suite imports -import "forge-std/Test.sol"; +import {ProposalTest} from "./ProposalTest.sol"; import {TestSuite} from "proposal-sim/test/TestSuite.t.sol"; -import {Addresses} from "proposal-sim/addresses/Addresses.sol"; -import {Kernel, Actions, toKeycode} from "src/Kernel.sol"; import {RolesAdmin} from "policies/RolesAdmin.sol"; -import {GovernorBravoDelegator} from "src/external/governance/GovernorBravoDelegator.sol"; -import {GovernorBravoDelegate} from "src/external/governance/GovernorBravoDelegate.sol"; -import {Timelock} from "src/external/governance/Timelock.sol"; // OIP_166 imports import {OIP_166} from "src/proposals/OIP_166.sol"; -/// @notice Creates a sandboxed environment from a mainnet fork, to simulate the proposal. -/// @dev Update the `setUp` function to deploy your proposal and set the submission -/// flag to `true` once the proposal has been submitted on-chain. -/// Note: this will fail if the OCGPermissions script has not been run yet. -contract OIP_166_OCGProposalTest is Test { - string public constant ADDRESSES_PATH = "./src/proposals/addresses.json"; - TestSuite public suite; - Addresses public addresses; - - // Wether the proposal has been submitted or not. - // If true, the framework will check that calldatas match. - bool public hasBeenSubmitted; - - string RPC_URL = vm.envString("FORK_TEST_RPC_URL"); - - /// @notice Creates a sandboxed environment from a mainnet fork. +contract OIP166Test is ProposalTest { function setUp() public virtual { // Mainnet Fork at a fixed block // Prior to actual deployment of the proposal (otherwise it will fail) - 20872023 - vm.createSelectFork(RPC_URL, 20872022); + vm.createSelectFork(RPC_URL, 20872023 - 1); /// @dev Deploy your proposal OIP_166 proposal = new OIP_166(); /// @dev Set `hasBeenSubmitted` to `true` once the proposal has been submitted on-chain. - hasBeenSubmitted = false; + hasBeenSubmitted = true; - /// [DO NOT DELETE] - /// @notice This section is used to simulate the proposal on the mainnet fork. + // NOTE: unique to OIP 166 + // In prepation for this particular proposal, we need to: + // Push the roles admin "admin" permission to the Timelock from the DAO MS (multisig) { // Populate addresses array address[] memory proposalsAddresses = new address[](1); @@ -54,40 +34,15 @@ contract OIP_166_OCGProposalTest is Test { // Set addresses object addresses = suite.addresses(); - // NOTE: unique to OIP 166 - // In prepation for this particular proposal, we need to: - // Push the roles admin "admin" permission to the Timelock from the DAO MS (multisig) address daoMS = addresses.getAddress("olympus-multisig-dao"); address timelock = addresses.getAddress("olympus-timelock"); RolesAdmin rolesAdmin = RolesAdmin(addresses.getAddress("olympus-policy-roles-admin")); vm.prank(daoMS); rolesAdmin.pushNewAdmin(timelock); - - // Set debug mode - suite.setDebug(true); - // Execute proposals - suite.testProposals(); - - // Proposals execution may change addresses, so we need to update the addresses object. - addresses = suite.addresses(); - - // Check if simulated calldatas match the ones from mainnet. - if (hasBeenSubmitted) { - address governor = addresses.getAddress("olympus-governor"); - bool[] memory matches = suite.checkProposalCalldatas(governor); - for (uint256 i; i < matches.length; i++) { - assertTrue(matches[i]); - } - } else { - console.log("\n\n------- Calldata check (simulation vs mainnet) -------\n"); - console.log("Proposal has NOT been submitted on-chain yet.\n"); - } } - } - // [DO NOT DELETE] Dummy test to ensure `setUp` is executed and the proposal simulated. - function testProposal_simulate() public { - assertTrue(true); + // Simulate the proposal + _simulateProposal(address(proposal)); } } diff --git a/src/test/proposals/OIP_168.t.sol b/src/test/proposals/OIP_168.t.sol index 7241819e8..69322edc5 100644 --- a/src/test/proposals/OIP_168.t.sol +++ b/src/test/proposals/OIP_168.t.sol @@ -1,39 +1,16 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.0; -// Proposal test-suite imports -import "forge-std/Test.sol"; -import {TestSuite} from "proposal-sim/test/TestSuite.t.sol"; -import {Addresses} from "proposal-sim/addresses/Addresses.sol"; -import {Kernel, Actions, toKeycode} from "src/Kernel.sol"; -import {RolesAdmin} from "policies/RolesAdmin.sol"; -import {GovernorBravoDelegator} from "src/external/governance/GovernorBravoDelegator.sol"; -import {GovernorBravoDelegate} from "src/external/governance/GovernorBravoDelegate.sol"; -import {Timelock} from "src/external/governance/Timelock.sol"; +import {ProposalTest} from "./ProposalTest.sol"; // OIP_168 imports import {OIP_168} from "src/proposals/OIP_168.sol"; -/// @notice Creates a sandboxed environment from a mainnet fork, to simulate the proposal. -/// @dev Update the `setUp` function to deploy your proposal and set the submission -/// flag to `true` once the proposal has been submitted on-chain. -/// Note: this will fail if the OCGPermissions script has not been run yet. -contract OIP_168_OCGProposalTest is Test { - string public constant ADDRESSES_PATH = "./src/proposals/addresses.json"; - TestSuite public suite; - Addresses public addresses; - - // Wether the proposal has been submitted or not. - // If true, the framework will check that calldatas match. - bool public hasBeenSubmitted; - - string RPC_URL = vm.envString("FORK_TEST_RPC_URL"); - - /// @notice Creates a sandboxed environment from a mainnet fork. +contract OIP168Test is ProposalTest { function setUp() public virtual { // Mainnet Fork at a fixed block - // Prior to actual deployment of the proposal (otherwise it will fail) - 21071000 - vm.createSelectFork(RPC_URL, 21071000); + // Prior to actual deployment of the proposal (otherwise it will fail) - 21218711 + vm.createSelectFork(RPC_URL, 21218711 - 1); /// @dev Deploy your proposal OIP_168 proposal = new OIP_168(); @@ -41,43 +18,7 @@ contract OIP_168_OCGProposalTest is Test { /// @dev Set `hasBeenSubmitted` to `true` once the proposal has been submitted on-chain. hasBeenSubmitted = true; - /// [DO NOT DELETE] - /// @notice This section is used to simulate the proposal on the mainnet fork. - { - // Populate addresses array - address[] memory proposalsAddresses = new address[](1); - proposalsAddresses[0] = address(proposal); - - // Deploy TestSuite contract - suite = new TestSuite(ADDRESSES_PATH, proposalsAddresses); - - // Set addresses object - addresses = suite.addresses(); - - // Set debug mode - suite.setDebug(true); - // Execute proposals - suite.testProposals(); - - // Proposals execution may change addresses, so we need to update the addresses object. - addresses = suite.addresses(); - - // Check if simulated calldatas match the ones from mainnet. - if (hasBeenSubmitted) { - address governor = addresses.getAddress("olympus-governor"); - bool[] memory matches = suite.checkProposalCalldatas(governor); - for (uint256 i; i < matches.length; i++) { - assertTrue(matches[i]); - } - } else { - console.log("\n\n------- Calldata check (simulation vs mainnet) -------\n"); - console.log("Proposal has NOT been submitted on-chain yet.\n"); - } - } - } - - // [DO NOT DELETE] Dummy test to ensure `setUp` is executed and the proposal simulated. - function testProposal_simulate() public { - assertTrue(true); + // Simulate the proposal + _simulateProposal(address(proposal)); } } diff --git a/src/test/proposals/OIP_XXX.t.sol b/src/test/proposals/OIP_XXX.t.sol index 80ba6b3d8..7780ef0da 100644 --- a/src/test/proposals/OIP_XXX.t.sol +++ b/src/test/proposals/OIP_XXX.t.sol @@ -1,86 +1,36 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.0; -// Proposal test-suite imports -import "forge-std/Test.sol"; -import {TestSuite} from "proposal-sim/test/TestSuite.t.sol"; -import {Addresses} from "proposal-sim/addresses/Addresses.sol"; +import {ProposalTest} from "./ProposalTest.sol"; import {Kernel, Actions, toKeycode} from "src/Kernel.sol"; -import {RolesAdmin} from "policies/RolesAdmin.sol"; -import {GovernorBravoDelegator} from "src/external/governance/GovernorBravoDelegator.sol"; -import {GovernorBravoDelegate} from "src/external/governance/GovernorBravoDelegate.sol"; -import {Timelock} from "src/external/governance/Timelock.sol"; // OIP_XXX imports import {OIP_XXX, Clearinghouse, CHREGv1, IERC20, IERC4626} from "src/proposals/OIP_XXX.sol"; -/// @notice Creates a sandboxed environment from a mainnet fork, to simulate the proposal. -/// @dev Update the `setUp` function to deploy your proposal and set the submission -/// flag to `true` once the proposal has been submitted on-chain. -contract OCGProposalTest is Test { - string public constant ADDRESSES_PATH = "./src/proposals/addresses.json"; - TestSuite public suite; - Addresses public addresses; - +contract OIPXXXTest is ProposalTest { // Data struct to cache initial balances. struct Cache { uint256 daiBalance; uint256 sdaiBalance; } - // Wether the proposal has been submitted or not. - // If true, the framework will check that calldatas match. - bool public hasBeenSubmitted; - // Clearinghouse Expected events event Defund(address token, uint256 amount); event Deactivate(); - /// @notice Creates a sandboxed environment from a mainnet fork. function setUp() public virtual { + // Mainnet Fork at a fixed block + // Prior to actual deployment of the proposal (otherwise it will fail) and Clearinghouse v2 - 21216656 + vm.createSelectFork(RPC_URL, 21216656 - 1); + /// @dev Deploy your proposal OIP_XXX proposal = new OIP_XXX(); /// @dev Set `hasBeenSubmitted` to `true` once the proposal has been submitted on-chain. hasBeenSubmitted = false; - /// [DO NOT DELETE] - /// @notice This section is used to simulate the proposal on the mainnet fork. - { - // Populate addresses array - address[] memory proposalsAddresses = new address[](1); - proposalsAddresses[0] = address(proposal); - - // Deploy TestSuite contract - suite = new TestSuite(ADDRESSES_PATH, proposalsAddresses); - - // Set addresses object - addresses = suite.addresses(); - - suite.setDebug(true); - // Execute proposals - suite.testProposals(); - - // Proposals execution may change addresses, so we need to update the addresses object. - addresses = suite.addresses(); - - // Check if simulated calldatas match the ones from mainnet. - if (hasBeenSubmitted) { - address governor = addresses.getAddress("olympus-governor"); - bool[] memory matches = suite.checkProposalCalldatas(governor); - for (uint256 i; i < matches.length; i++) { - assertTrue(matches[i]); - } - } else { - console.log("\n\n------- Calldata check (simulation vs mainnet) -------\n"); - console.log("Proposal has NOT been submitted on-chain yet.\n"); - } - } - } - - // [DO NOT DELETE] Dummy test to ensure `setUp` is executed and the proposal simulated. - function testProposal_simulate() public { - assertTrue(true); + // Simulate the proposal + _simulateProposal(address(proposal)); } /// -- OPTIONAL INTEGRATION TESTS ---------------------------------------------------- @@ -122,12 +72,28 @@ contract OCGProposalTest is Test { clearinghouseV1.emergencyShutdown(); // Check that the system is shutdown and logged in the CHREG - assertFalse(clearinghouseV1.active()); - // assertEq(CHREGv1(CHREG).activeCount(), 0); + assertFalse(clearinghouseV1.active(), "Clearinghouse should be shutdown"); + assertEq(CHREGv1(CHREG).activeCount(), 1, "CHREG should have 1 active policy"); // Check the token balances - assertEq(dai.balanceOf(address(clearinghouseV1)), 0); - assertEq(sdai.balanceOf(address(clearinghouseV1)), 0); - assertEq(dai.balanceOf(TRSRY), cacheCH.daiBalance + cacheTRSRY.daiBalance); - assertEq(sdai.balanceOf(TRSRY), cacheCH.sdaiBalance + cacheTRSRY.sdaiBalance); + assertEq( + dai.balanceOf(address(clearinghouseV1)), + 0, + "DAI balance of clearinghouse should be 0" + ); + assertEq( + sdai.balanceOf(address(clearinghouseV1)), + 0, + "sDAI balance of clearinghouse should be 0" + ); + assertEq( + dai.balanceOf(TRSRY), + cacheCH.daiBalance + cacheTRSRY.daiBalance, + "DAI balance of treasury should be correct" + ); + assertEq( + sdai.balanceOf(TRSRY), + cacheCH.sdaiBalance + cacheTRSRY.sdaiBalance, + "sDAI balance of treasury should be correct" + ); } } diff --git a/src/test/proposals/ProposalTest.sol b/src/test/proposals/ProposalTest.sol new file mode 100644 index 000000000..e72e33e58 --- /dev/null +++ b/src/test/proposals/ProposalTest.sol @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +// Proposal test-suite imports +import {Test} from "forge-std/Test.sol"; +import {console2} from "forge-std/console2.sol"; +import {TestSuite} from "proposal-sim/test/TestSuite.t.sol"; +import {Addresses} from "proposal-sim/addresses/Addresses.sol"; +import {Kernel, Actions, toKeycode} from "src/Kernel.sol"; +import {RolesAdmin} from "policies/RolesAdmin.sol"; +import {GovernorBravoDelegator} from "src/external/governance/GovernorBravoDelegator.sol"; +import {GovernorBravoDelegate} from "src/external/governance/GovernorBravoDelegate.sol"; +import {Timelock} from "src/external/governance/Timelock.sol"; + +/// @notice Creates a sandboxed environment from a mainnet fork, to simulate the proposal. +/// @dev Update the `setUp` function to deploy your proposal and set the submission +/// flag to `true` once the proposal has been submitted on-chain. +/// Note: this will fail if the OCGPermissions script has not been run yet. +abstract contract ProposalTest is Test { + string public constant ADDRESSES_PATH = "./src/proposals/addresses.json"; + TestSuite public suite; + Addresses public addresses; + + // Wether the proposal has been submitted or not. + // If true, the framework will check that calldatas match. + bool public hasBeenSubmitted; + + string RPC_URL = vm.envString("FORK_TEST_RPC_URL"); + + /// @notice This function simulates the proposal with the given address. + /// @dev This function assumes the following: + /// - A mainnet fork has been created using `vm.createSelectFork` with a block number prior to the proposal deployment. + /// - If the proposal has been submitted on-chain, the `hasBeenSubmitted` flag has been set to `true`. + /// - The proposal contract has been deployed within the test contract and passed as an argument. + /// + /// @param proposal_ The address of the proposal contract. + function _simulateProposal(address proposal_) internal virtual { + /// @notice This section is used to simulate the proposal on the mainnet fork. + { + // Populate addresses array + address[] memory proposalsAddresses = new address[](1); + proposalsAddresses[0] = address(proposal_); + + // Deploy TestSuite contract + suite = new TestSuite(ADDRESSES_PATH, proposalsAddresses); + + // Set addresses object + addresses = suite.addresses(); + + // Set debug mode + suite.setDebug(true); + // Execute proposals + suite.testProposals(); + + // Proposals execution may change addresses, so we need to update the addresses object. + addresses = suite.addresses(); + + // Check if simulated calldatas match the ones from mainnet. + if (hasBeenSubmitted) { + address governor = addresses.getAddress("olympus-governor"); + bool[] memory matches = suite.checkProposalCalldatas(governor); + for (uint256 i; i < matches.length; i++) { + assertTrue(matches[i], "Calldata should match"); + } + } else { + console2.log("\n\n------- Calldata check (simulation vs mainnet) -------\n"); + console2.log("Proposal has NOT been submitted on-chain yet.\n"); + } + } + } + + /// @dev Dummy test to ensure `setUp` is executed and the proposal simulated. + function testProposal_simulate() public { + assertTrue(true, "Proposal should be simulated"); + } +}