diff --git a/ROLES.md b/ROLES.md index 736b84b5..c9211e07 100644 --- a/ROLES.md +++ b/ROLES.md @@ -14,8 +14,12 @@ This document describes the roles that are used in the Olympus protocol. | custodian | TreasuryCustodian | Deposit/withdraw reserves and grant/revoke approvals | | distributor_admin | Distributor | Set reward rate, bounty, and other parameters | | emergency_restart | Emergency | Reactivates the TRSRY and/or MINTR modules | +| emergency_restart | EmissionManager | Reactivates the EmissionManager | | emergency_shutdown | Clearinghouse | Allows shutting down the protocol in an emergency | | emergency_shutdown | Emergency | Deactivates the TRSRY and/or MINTR modules | +| emergency_shutdown | EmissionManager | Deactivates the EmissionManager | +| emissions_admin | EmissionManager | Set configuration parameters | +| heart | EmissionManager | Calls the execute() function | | heart | Operator | Call the operate() function | | heart | ReserveMigrator | Allows migrating reserves from one reserve token to another | | heart | YieldRepurchaseFacility | Creates a new YRF market | @@ -25,6 +29,7 @@ This document describes the roles that are used in the Olympus protocol. | operator_policy | Operator | Set spreads, threshold factor, and cushion factor | | operator_reporter | Operator | Report bond purchases | | poly_admin | pOLY | Allows migrating pOLY terms to another contract | +| reserve_migrator_admin | ReserveMigrator | Activate/deactivate the functionality | ## Role Allocations @@ -62,9 +67,7 @@ This document describes the roles that are used in the Olympus protocol. "bridge_admin", "heart_admin", "operator_policy", - "loop_daddy", - "contract_registry_admin", - "loan_consolidator_admin" + "loop_daddy" ], "0xda9fEDBcAF319Ecf8AB11fe874Fb1AbFc2181766": [ // pOly MS "poly_admin" diff --git a/deployments/.mainnet-1731957551.json b/deployments/.mainnet-1731957551.json new file mode 100644 index 00000000..c842c2af --- /dev/null +++ b/deployments/.mainnet-1731957551.json @@ -0,0 +1,8 @@ +{ +"ReserveMigrator": "0x986b99579BEc7B990331474b66CcDB94Fa2419F5", +"YieldRepurchaseFacility": "0xcaA3d3E653A626e2656d2E799564fE952D39d855", +"EmissionManager": "0x50f441a3387625bDA8B8081cE3fd6C04CC48C0A2", +"Operator": "0x6417F206a0a6628Da136C0Faa39026d0134D2b52", +"Clearinghouse": "0x1e094fE00E13Fd06D64EeA4FB3cD912893606fE0", +"OlympusHeart": "0xf7602C0421c283A2fc113172EBDf64C30F21654D" +} diff --git a/src/policies/Clearinghouse.sol b/src/policies/Clearinghouse.sol index ef935395..72345a8e 100644 --- a/src/policies/Clearinghouse.sol +++ b/src/policies/Clearinghouse.sol @@ -42,13 +42,13 @@ contract Clearinghouse is Policy, RolesConsumer, CoolerCallback { event Deactivate(); /// @notice Logs whenever the treasury is defunded. event Defund(address token, uint256 amount); - /// @notice Logs the balance change (in DAI terms) whenever a rebalance occurs. - event Rebalance(bool defund, uint256 daiAmount); + /// @notice Logs the balance change (in reserve terms) whenever a rebalance occurs. + event Rebalance(bool defund, uint256 reserveAmount); // --- RELEVANT CONTRACTS ---------------------------------------- - ERC20 public immutable dai; // Debt token - ERC4626 public immutable sdai; // Idle DAI will be wrapped into sDAI + 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 @@ -62,7 +62,7 @@ contract Clearinghouse is Policy, RolesConsumer, CoolerCallback { // --- PARAMETER BOUNDS ------------------------------------------ uint256 public constant INTEREST_RATE = 5e15; // 0.5% anually - uint256 public constant LOAN_TO_COLLATERAL = 289292e16; // 2,892.92 DAI/gOHM + uint256 public constant LOAN_TO_COLLATERAL = 289292e16; // 2,892.92 reserve/gOHM 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 @@ -88,7 +88,7 @@ contract Clearinghouse is Policy, RolesConsumer, CoolerCallback { address ohm_, address gohm_, address staking_, - address sdai_, + address sReserve_, address coolerFactory_, address kernel_ ) Policy(Kernel(kernel_)) CoolerCallback(coolerFactory_) { @@ -96,8 +96,8 @@ contract Clearinghouse is Policy, RolesConsumer, CoolerCallback { ohm = ERC20(ohm_); gohm = ERC20(gohm_); staking = IStaking(staking_); - sdai = ERC4626(sdai_); - dai = ERC20(sdai.asset()); + sReserve = ERC4626(sReserve_); + reserve = ERC20(sReserve.asset()); } /// @notice Default framework setup. Configure dependencies for olympus-v3 modules. @@ -147,13 +147,21 @@ contract Clearinghouse is Policy, RolesConsumer, CoolerCallback { 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 DAI to lend. + /// @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. @@ -163,7 +171,7 @@ contract Clearinghouse is Policy, RolesConsumer, CoolerCallback { if (!factory.created(address(cooler_))) revert OnlyFromFactory(); // Validate cooler collateral and debt tokens. - if (cooler_.collateral() != gohm || cooler_.debt() != dai) revert BadEscrow(); + if (cooler_.collateral() != gohm || cooler_.debt() != reserve) revert BadEscrow(); // Transfer in collateral owed uint256 collateral = cooler_.collateralFor(amount_, LOAN_TO_COLLATERAL); @@ -178,9 +186,9 @@ contract Clearinghouse is Policy, RolesConsumer, CoolerCallback { gohm.approve(address(cooler_), collateral); uint256 reqID = cooler_.requestLoan(amount_, INTEREST_RATE, LOAN_TO_COLLATERAL, DURATION); - // Clear the created loan request by providing enough DAI. - sdai.withdraw(amount_, address(this), address(this)); - dai.approve(address(cooler_), amount_); + // 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; @@ -202,11 +210,11 @@ contract Clearinghouse is Policy, RolesConsumer, CoolerCallback { uint256 interestBase = interestForLoan(loan.principal, loan.request.duration); // Transfer in extension interest from the caller. - dai.transferFrom(msg.sender, address(this), interestBase * times_); + reserve.transferFrom(msg.sender, address(this), interestBase * times_); if (active) { - _sweepIntoDSR(interestBase * times_); + _sweepIntoSavingsVault(interestBase * times_); } else { - _defund(dai, interestBase * times_); + _defund(reserve, interestBase * times_); } // Signal to cooler that loan should be extended. @@ -266,12 +274,12 @@ contract Clearinghouse is Policy, RolesConsumer, CoolerCallback { : 0; // Update outstanding debt owed to the Treasury upon default. - uint256 outstandingDebt = TRSRY.reserveDebt(dai, address(this)); + uint256 outstandingDebt = TRSRY.reserveDebt(reserve, address(this)); // debt owed to TRSRY = user debt - user interest TRSRY.setDebt({ debtor_: address(this), - token_: dai, + token_: reserve, amount_: (outstandingDebt > totalPrincipal) ? outstandingDebt - totalPrincipal : 0 }); @@ -285,13 +293,13 @@ contract Clearinghouse is Policy, RolesConsumer, CoolerCallback { /// @notice Overridden callback to decrement loan receivables. /// @param *unused loadID_ of the load. - /// @param principalPaid_ in DAI. - /// @param interestPaid_ in DAI. + /// @param principalPaid_ in reserve. + /// @param interestPaid_ in reserve. function _onRepay(uint256, uint256 principalPaid_, uint256 interestPaid_) internal override { if (active) { - _sweepIntoDSR(principalPaid_ + interestPaid_); + _sweepIntoSavingsVault(principalPaid_ + interestPaid_); } else { - _defund(dai, principalPaid_ + interestPaid_); + _defund(reserve, principalPaid_ + interestPaid_); } // Decrement loan receivables. @@ -323,45 +331,45 @@ contract Clearinghouse is Policy, RolesConsumer, CoolerCallback { if (fundTime > block.timestamp) return false; fundTime += FUND_CADENCE; - // Sweep DAI into DSR if necessary. - uint256 idle = dai.balanceOf(address(this)); - if (idle != 0) _sweepIntoDSR(idle); + // Sweep reserve into DSR if necessary. + uint256 idle = reserve.balanceOf(address(this)); + if (idle != 0) _sweepIntoSavingsVault(idle); - uint256 daiBalance = sdai.maxWithdraw(address(this)); - uint256 outstandingDebt = TRSRY.reserveDebt(dai, address(this)); + uint256 reserveBalance = sReserve.maxWithdraw(address(this)); + uint256 outstandingDebt = TRSRY.reserveDebt(reserve, address(this)); // Rebalance funds on hand with treasury's reserves. - if (daiBalance < maxFundAmount) { - // Since users loans are denominated in DAI, the clearinghouse - // debt is set in DAI terms. It must be adjusted when funding. - uint256 fundAmount = maxFundAmount - daiBalance; + 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_: dai, + token_: reserve, amount_: outstandingDebt + fundAmount }); - // Since TRSRY holds sDAI, a conversion must be done before + // Since TRSRY holds sReserve, a conversion must be done before // funding the clearinghouse. - uint256 sdaiAmount = sdai.previewWithdraw(fundAmount); - TRSRY.increaseWithdrawApproval(address(this), sdai, sdaiAmount); - TRSRY.withdrawReserves(address(this), sdai, sdaiAmount); + 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 (daiBalance > maxFundAmount) { - // Since users loans are denominated in DAI, the clearinghouse - // debt is set in DAI terms. It must be adjusted when defunding. - uint256 defundAmount = daiBalance - maxFundAmount; + } 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_: dai, + token_: reserve, amount_: (outstandingDebt > defundAmount) ? outstandingDebt - defundAmount : 0 }); - // Since TRSRY holds sDAI, a conversion must be done before - // sending sDAI back. - uint256 sdaiAmount = sdai.previewWithdraw(defundAmount); - sdai.transfer(address(TRSRY), sdaiAmount); + // 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); @@ -370,16 +378,16 @@ contract Clearinghouse is Policy, RolesConsumer, CoolerCallback { return true; } - /// @notice Sweep excess DAI into vault. - function sweepIntoDSR() public { - uint256 daiBalance = dai.balanceOf(address(this)); - _sweepIntoDSR(daiBalance); + /// @notice Sweep excess reserve into savings vault. + function sweepIntoSavingsVault() public { + uint256 reserveBalance = reserve.balanceOf(address(this)); + _sweepIntoSavingsVault(reserveBalance); } - /// @notice Sweep excess DAI into vault. - function _sweepIntoDSR(uint256 amount_) internal { - dai.approve(address(sdai), amount_); - sdai.deposit(amount_, address(this)); + /// @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. @@ -408,13 +416,13 @@ contract Clearinghouse is Policy, RolesConsumer, CoolerCallback { function emergencyShutdown() external onlyRole("emergency_shutdown") { active = false; - // If necessary, defund sDAI. - uint256 sdaiBalance = sdai.balanceOf(address(this)); - if (sdaiBalance != 0) _defund(sdai, sdaiBalance); + // If necessary, defund sReserve. + uint256 sReserveBalance = sReserve.balanceOf(address(this)); + if (sReserveBalance != 0) _defund(sReserve, sReserveBalance); - // If necessary, defund DAI. - uint256 daiBalance = dai.balanceOf(address(this)); - if (daiBalance != 0) _defund(dai, daiBalance); + // 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)); @@ -434,16 +442,18 @@ contract Clearinghouse is Policy, RolesConsumer, CoolerCallback { /// @param token_ to transfer. /// @param amount_ to transfer. function _defund(ERC20 token_, uint256 amount_) internal { - if (token_ == sdai || token_ == dai) { - // Since users loans are denominated in DAI, the clearinghouse - // debt is set in DAI terms. It must be adjusted when defunding. - uint256 outstandingDebt = TRSRY.reserveDebt(dai, address(this)); - uint256 daiAmount = (token_ == sdai) ? sdai.previewRedeem(amount_) : amount_; + 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_: dai, - amount_: (outstandingDebt > daiAmount) ? outstandingDebt - daiAmount : 0 + token_: reserve, + amount_: (outstandingDebt > reserveAmount) ? outstandingDebt - reserveAmount : 0 }); } @@ -469,14 +479,14 @@ contract Clearinghouse is Policy, RolesConsumer, CoolerCallback { } /// @notice view function to compute the interest for given principal amount. - /// @param principal_ amount of DAI being lent. + /// @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 DAI for the treasury. + /// @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/policies/EmissionManager.sol b/src/policies/EmissionManager.sol new file mode 100644 index 00000000..2afa9493 --- /dev/null +++ b/src/policies/EmissionManager.sol @@ -0,0 +1,479 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity 0.8.15; + +import "src/Kernel.sol"; + +import {ERC20} from "solmate/tokens/ERC20.sol"; +import {ERC4626} from "solmate/mixins/ERC4626.sol"; +import {TransferHelper} from "libraries/TransferHelper.sol"; + +import {FullMath} from "libraries/FullMath.sol"; + +import {IBondSDA} from "interfaces/IBondSDA.sol"; +import {IgOHM} from "interfaces/IgOHM.sol"; + +import {RolesConsumer, ROLESv1} from "modules/ROLES/OlympusRoles.sol"; +import {TRSRYv1} from "modules/TRSRY/TRSRY.v1.sol"; +import {PRICEv1} from "modules/PRICE/PRICE.v1.sol"; +import {MINTRv1} from "modules/MINTR/MINTR.v1.sol"; +import {CHREGv1} from "modules/CHREG/CHREG.v1.sol"; + +import {IEmissionManager} from "policies/interfaces/IEmissionManager.sol"; + +interface BurnableERC20 { + function burn(uint256 amount) external; +} + +interface Clearinghouse { + function principalReceivables() external view returns (uint256); +} + +// solhint-disable max-states-count +contract EmissionManager is IEmissionManager, Policy, RolesConsumer { + using FullMath for uint256; + using TransferHelper for ERC20; + + // ========== STATE VARIABLES ========== // + + /// @notice active base emissions rate change information + /// @dev active until daysLeft is 0 + BaseRateChange public rateChange; + + // Modules + TRSRYv1 public TRSRY; + PRICEv1 public PRICE; + MINTRv1 public MINTR; + CHREGv1 public CHREG; + + // Tokens + // solhint-disable const-name-snakecase + ERC20 public immutable ohm; + IgOHM public immutable gohm; + ERC20 public immutable reserve; + ERC4626 public immutable sReserve; + + // External contracts + IBondSDA public auctioneer; + address public teller; + + // Manager variables + uint256 public baseEmissionRate; + uint256 public minimumPremium; + uint48 public vestingPeriod; // initialized at 0 + uint256 public backing; + uint8 public beatCounter; + bool public locallyActive; + uint256 public activeMarketId; + + uint8 internal _oracleDecimals; + uint8 internal immutable _ohmDecimals; + uint8 internal immutable _gohmDecimals; + uint8 internal immutable _reserveDecimals; + + /// @notice timestamp of last shutdown + uint48 public shutdownTimestamp; + /// @notice time in seconds that the manager needs to be restarted after a shutdown, otherwise it must be re-initialized + uint48 public restartTimeframe; + + uint256 internal constant ONE_HUNDRED_PERCENT = 1e18; + + // ========== SETUP ========== // + + constructor( + Kernel kernel_, + address ohm_, + address gohm_, + address reserve_, + address sReserve_, + address auctioneer_, + address teller_ + ) Policy(kernel_) { + // Set immutable variables + if (ohm_ == address(0)) revert("OHM address cannot be 0"); + if (gohm_ == address(0)) revert("gOHM address cannot be 0"); + if (reserve_ == address(0)) revert("DAI address cannot be 0"); + if (sReserve_ == address(0)) revert("sDAI address cannot be 0"); + if (auctioneer_ == address(0)) revert("Auctioneer address cannot be 0"); + + ohm = ERC20(ohm_); + gohm = IgOHM(gohm_); + reserve = ERC20(reserve_); + sReserve = ERC4626(sReserve_); + auctioneer = IBondSDA(auctioneer_); + teller = teller_; + + _ohmDecimals = ohm.decimals(); + _gohmDecimals = ERC20(gohm_).decimals(); + _reserveDecimals = reserve.decimals(); + + // Max approve sReserve contract for reserve for deposits + reserve.approve(address(sReserve), type(uint256).max); + } + + function configureDependencies() external override returns (Keycode[] memory dependencies) { + dependencies = new Keycode[](5); + dependencies[0] = toKeycode("TRSRY"); + dependencies[1] = toKeycode("PRICE"); + dependencies[2] = toKeycode("MINTR"); + dependencies[3] = toKeycode("CHREG"); + dependencies[4] = toKeycode("ROLES"); + + TRSRY = TRSRYv1(getModuleAddress(dependencies[0])); + PRICE = PRICEv1(getModuleAddress(dependencies[1])); + MINTR = MINTRv1(getModuleAddress(dependencies[2])); + CHREG = CHREGv1(getModuleAddress(dependencies[3])); + ROLES = ROLESv1(getModuleAddress(dependencies[4])); + + _oracleDecimals = PRICE.decimals(); + } + + function requestPermissions() + external + view + override + returns (Permissions[] memory permissions) + { + Keycode mintrKeycode = toKeycode("MINTR"); + + permissions = new Permissions[](2); + permissions[0] = Permissions(mintrKeycode, MINTR.increaseMintApproval.selector); + permissions[1] = Permissions(mintrKeycode, MINTR.mintOhm.selector); + } + + // ========== HEARTBEAT ========== // + + /// @inheritdoc IEmissionManager + function execute() external onlyRole("heart") { + if (!locallyActive) return; + + beatCounter = ++beatCounter % 3; + if (beatCounter != 0) return; + + if (rateChange.daysLeft != 0) { + --rateChange.daysLeft; + if (rateChange.addition) baseEmissionRate += rateChange.changeBy; + else baseEmissionRate -= rateChange.changeBy; + } + + // It then calculates the amount to sell for the coming day + (, , uint256 sell) = getNextSale(); + + // And then opens a market if applicable + if (sell != 0) { + MINTR.increaseMintApproval(address(this), sell); + _createMarket(sell); + } + } + + // ========== INITIALIZE ========== // + + /// @notice allow governance to initialize the emission manager + /// @param baseEmissionsRate_ percent of OHM supply to issue per day at the minimum premium, in OHM scale, i.e. 1e9 = 100% + /// @param minimumPremium_ minimum premium at which to issue OHM, a percentage where 1e18 is 100% + /// @param backing_ backing price of OHM in reserve token, in reserve scale + /// @param restartTimeframe_ time in seconds that the manager needs to be restarted after a shutdown, otherwise it must be re-initialized + function initialize( + uint256 baseEmissionsRate_, + uint256 minimumPremium_, + uint256 backing_, + uint48 restartTimeframe_ + ) external onlyRole("emissions_admin") { + // Cannot initialize if currently active + if (locallyActive) revert AlreadyActive(); + + // Cannot initialize if the restart timeframe hasn't passed since the shutdown timestamp + // This is specific to re-initializing after a shutdown + // It will not revert on the first initialization since both values will be zero + if (shutdownTimestamp + restartTimeframe > uint48(block.timestamp)) + revert CannotRestartYet(shutdownTimestamp + restartTimeframe); + + // Validate inputs + if (baseEmissionsRate_ == 0) revert InvalidParam("baseEmissionRate"); + if (minimumPremium_ == 0) revert InvalidParam("minimumPremium"); + if (backing_ == 0) revert InvalidParam("backing"); + if (restartTimeframe_ == 0) revert InvalidParam("restartTimeframe"); + + // Assign + baseEmissionRate = baseEmissionsRate_; + minimumPremium = minimumPremium_; + backing = backing_; + restartTimeframe = restartTimeframe_; + + // Activate + locallyActive = true; + + emit Activated(); + emit MinimumPremiumChanged(minimumPremium_); + emit BackingChanged(backing_); + emit RestartTimeframeChanged(restartTimeframe_); + } + + // ========== BOND CALLBACK ========== // + + /// @notice callback function for bond market, only callable by the teller + function callback(uint256 id_, uint256 inputAmount_, uint256 outputAmount_) external { + // Only callable by the bond teller + if (msg.sender != teller) revert OnlyTeller(); + + // Market ID must match the active market ID stored locally, otherwise revert + if (id_ != activeMarketId) revert InvalidMarket(); + + // Reserve balance should have increased by atleast the input amount + uint256 reserveBalance = reserve.balanceOf(address(this)); + if (reserveBalance < inputAmount_) revert InvalidCallback(); + + // Update backing value with the new reserves added and supply added + // We do this before depositing the received reserves and minting the output amount of OHM + // so that the getReserves and getSupply values equal the "previous" values + // This also conforms to the CEI pattern + _updateBacking(outputAmount_, inputAmount_); + + // Deposit the reserve balance into the sReserve contract with the TRSRY as the recipient + // This will sweep any excess reserves into the TRSRY as well + sReserve.deposit(reserveBalance, address(TRSRY)); + + // Mint the output amount of OHM to the Teller + MINTR.mintOhm(teller, outputAmount_); + } + + // ========== INTERNAL FUNCTIONS ========== // + + /// @notice create bond protocol market with given budget + /// @param saleAmount amount of DAI to fund bond market with + function _createMarket(uint256 saleAmount) internal { + // Calculate scaleAdjustment for bond market + // Price decimals are returned from the perspective of the quote token + // so the operations assume payoutPriceDecimal is zero and quotePriceDecimals + // is the priceDecimal value + uint256 minPrice = ((ONE_HUNDRED_PERCENT + minimumPremium) * backing) / + 10 ** _reserveDecimals; + int8 priceDecimals = _getPriceDecimals(minPrice); + int8 scaleAdjustment = int8(_ohmDecimals) - int8(_reserveDecimals) + (priceDecimals / 2); + + // Calculate oracle scale and bond scale with scale adjustment and format prices for bond market + uint256 oracleScale = 10 ** uint8(int8(_oracleDecimals) - priceDecimals); + uint256 bondScale = 10 ** + uint8( + 36 + scaleAdjustment + int8(_reserveDecimals) - int8(_ohmDecimals) - priceDecimals + ); + + // Create new bond market to buy the reserve with OHM + activeMarketId = auctioneer.createMarket( + abi.encode( + IBondSDA.MarketParams({ + payoutToken: ohm, + quoteToken: reserve, + callbackAddr: address(this), + capacityInQuote: false, + capacity: saleAmount, + formattedInitialPrice: PRICE.getLastPrice().mulDiv(bondScale, oracleScale), + formattedMinimumPrice: minPrice.mulDiv(bondScale, oracleScale), + debtBuffer: 100_000, // 100% + vesting: vestingPeriod, + conclusion: uint48(block.timestamp + 1 days), // 1 day from now + depositInterval: uint32(4 hours), // 4 hours + scaleAdjustment: scaleAdjustment + }) + ) + ); + + emit SaleCreated(activeMarketId, saleAmount); + } + + /// @notice allow emission manager to update backing price based on new supply and reserves added + /// @param supplyAdded number of new OHM minted + /// @param reservesAdded number of new DAI added + function _updateBacking(uint256 supplyAdded, uint256 reservesAdded) internal { + uint256 previousReserves = getReserves(); + uint256 previousSupply = getSupply(); + + uint256 percentIncreaseReserves = ((previousReserves + reservesAdded) * + 10 ** _reserveDecimals) / previousReserves; + uint256 percentIncreaseSupply = ((previousSupply + supplyAdded) * 10 ** _reserveDecimals) / + previousSupply; // scaled to reserve decimals to match + + backing = + (backing * percentIncreaseReserves) / // price multiplied by percent increase reserves in reserve scale + percentIncreaseSupply; // divided by percent increase supply in reserve scale + + // Emit event to track backing changes and results of sales offchain + emit BackingUpdated(backing, supplyAdded, reservesAdded); + } + + /// @notice Helper function to calculate number of price decimals based on the value returned from the price feed. + /// @param price_ The price to calculate the number of decimals for + /// @return The number of decimals + function _getPriceDecimals(uint256 price_) internal view returns (int8) { + int8 decimals; + while (price_ >= 10) { + price_ = price_ / 10; + decimals++; + } + + // Subtract the stated decimals from the calculated decimals to get the relative price decimals. + // Required to do it this way vs. normalizing at the beginning since price decimals can be negative. + return decimals - int8(_oracleDecimals); + } + + // ========== ADMIN FUNCTIONS ========== // + + /// @notice shutdown the emission manager locally and close the active bond market + function shutdown() external onlyRole("emergency_shutdown") { + locallyActive = false; + shutdownTimestamp = uint48(block.timestamp); + + // Shutdown the bond market, if it is active + if (auctioneer.isLive(activeMarketId)) { + auctioneer.closeMarket(activeMarketId); + } + + emit Deactivated(); + } + + /// @notice restart the emission manager locally + function restart() external onlyRole("emergency_restart") { + // Restart can be activated only within the specified timeframe since shutdown + // Outside of this span of time, emissions_admin must reinitialize + if (uint48(block.timestamp) >= shutdownTimestamp + restartTimeframe) + revert RestartTimeframePassed(); + + locallyActive = true; + + emit Activated(); + } + + /// @notice Rescue any ERC20 token sent to this contract and send it to the TRSRY + /// @dev This function is restricted to the emissions_admin role + /// @param token_ The address of the ERC20 token to rescue + function rescue(address token_) external onlyRole("emissions_admin") { + ERC20 token = ERC20(token_); + token.safeTransfer(address(TRSRY), token.balanceOf(address(this))); + } + + /// @notice set the base emissions rate + /// @param changeBy_ uint256 added or subtracted from baseEmissionRate + /// @param forNumBeats_ uint256 number of times to change baseEmissionRate by changeBy_ + /// @param add bool determining addition or subtraction to baseEmissionRate + function changeBaseRate( + uint256 changeBy_, + uint48 forNumBeats_, + bool add + ) external onlyRole("emissions_admin") { + // Prevent underflow on negative adjustments + if (!add && (changeBy_ * forNumBeats_ > baseEmissionRate)) + revert InvalidParam("changeBy * forNumBeats"); + + // Prevent overflow on positive adjustments + if (add && (type(uint256).max - changeBy_ * forNumBeats_ < baseEmissionRate)) + revert InvalidParam("changeBy * forNumBeats"); + + rateChange = BaseRateChange(changeBy_, forNumBeats_, add); + + emit BaseRateChanged(changeBy_, forNumBeats_, add); + } + + /// @notice set the minimum premium for emissions + /// @param newMinimumPremium_ uint256 + function setMinimumPremium(uint256 newMinimumPremium_) external onlyRole("emissions_admin") { + if (newMinimumPremium_ == 0) revert InvalidParam("newMinimumPremium"); + + minimumPremium = newMinimumPremium_; + + emit MinimumPremiumChanged(newMinimumPremium_); + } + + /// @notice set the new vesting period in seconds + /// @param newVestingPeriod_ uint48 + function setVestingPeriod(uint48 newVestingPeriod_) external onlyRole("emissions_admin") { + // Verify that the vesting period isn't more than a year + // This check helps ensure a timestamp isn't input instead of a duration + if (newVestingPeriod_ > uint48(31536000)) revert InvalidParam("newVestingPeriod"); + vestingPeriod = newVestingPeriod_; + + emit VestingPeriodChanged(newVestingPeriod_); + } + + /// @notice allow governance to adjust backing price if deviated from reality + /// @dev note if adjustment is more than 33% down, contract should be redeployed + /// @param newBacking to adjust to + /// TODO maybe put in a timespan arg so it can be smoothed over time if desirable + function setBacking(uint256 newBacking) external onlyRole("emissions_admin") { + // Backing cannot be reduced by more than 10% at a time + if (newBacking == 0 || newBacking < (backing * 9) / 10) revert InvalidParam("newBacking"); + backing = newBacking; + + emit BackingChanged(newBacking); + } + + /// @notice allow governance to adjust the timeframe for restart after shutdown + /// @param newTimeframe to adjust it to + function setRestartTimeframe(uint48 newTimeframe) external onlyRole("emissions_admin") { + // Restart timeframe must be greater than 0 + if (newTimeframe == 0) revert InvalidParam("newRestartTimeframe"); + + restartTimeframe = newTimeframe; + + emit RestartTimeframeChanged(newTimeframe); + } + + /// @notice allow governance to set the bond contracts used by the emission manager + /// @param auctioneer_ address of the bond auctioneer contract + /// @param teller_ address of the bond teller contract + function setBondContracts( + address auctioneer_, + address teller_ + ) external onlyRole("emissions_admin") { + // Bond contracts cannot be set to the zero address + if (auctioneer_ == address(0)) revert InvalidParam("auctioneer"); + if (teller_ == address(0)) revert InvalidParam("teller"); + + auctioneer = IBondSDA(auctioneer_); + teller = teller_; + + emit BondContractsSet(auctioneer_, teller_); + } + + // =========- VIEW FUNCTIONS ========== // + + /// @notice return reserves, measured as clearinghouse receivables and sReserve balances, in reserve denomination + function getReserves() public view returns (uint256 reserves) { + uint256 chCount = CHREG.registryCount(); + for (uint256 i; i < chCount; i++) { + reserves += Clearinghouse(CHREG.registry(i)).principalReceivables(); + uint256 bal = sReserve.balanceOf(CHREG.registry(i)); + if (bal > 0) reserves += sReserve.previewRedeem(bal); + } + + reserves += sReserve.previewRedeem(sReserve.balanceOf(address(TRSRY))); + } + + /// @notice return supply, measured as supply of gOHM in OHM denomination + function getSupply() public view returns (uint256 supply) { + return (gohm.totalSupply() * gohm.index()) / 10 ** _gohmDecimals; + } + + /// @notice return the current premium as a percentage where 1e18 is 100% + function getPremium() public view returns (uint256) { + uint256 price = PRICE.getLastPrice(); + uint256 pbr = (price * 10 ** _reserveDecimals) / backing; + return pbr > ONE_HUNDRED_PERCENT ? pbr - ONE_HUNDRED_PERCENT : 0; + } + + /// @notice return the next sale amount, premium, emission rate, and emissions based on the current premium + function getNextSale() + public + view + returns (uint256 premium, uint256 emissionRate, uint256 emission) + { + // To calculate the sale, it first computes premium (market price / backing price) - 100% + premium = getPremium(); + + // If the premium is greater than the minimum premium, it computes the emission rate and nominal emissions + if (premium >= minimumPremium) { + emissionRate = + (baseEmissionRate * (ONE_HUNDRED_PERCENT + premium)) / + (ONE_HUNDRED_PERCENT + minimumPremium); // in OHM scale + emission = (getSupply() * emissionRate) / 10 ** _ohmDecimals; // OHM Scale * OHM Scale / OHM Scale = OHM Scale + } + } +} diff --git a/src/policies/Heart.sol b/src/policies/Heart.sol index 86ff0213..439de0eb 100644 --- a/src/policies/Heart.sol +++ b/src/policies/Heart.sol @@ -11,6 +11,8 @@ import {IOperator} from "policies/interfaces/IOperator.sol"; import {IYieldRepo} from "policies/interfaces/IYieldRepo.sol"; import {IHeart} from "policies/interfaces/IHeart.sol"; import {IStaking} from "interfaces/IStaking.sol"; +import {IReserveMigrator} from "policies/interfaces/IReserveMigrator.sol"; +import {IEmissionManager} from "policies/interfaces/IEmissionManager.sol"; import {RolesConsumer, ROLESv1} from "modules/ROLES/OlympusRoles.sol"; import {PRICEv1} from "modules/PRICE/PRICE.v1.sol"; @@ -50,6 +52,8 @@ contract OlympusHeart is IHeart, Policy, RolesConsumer, ReentrancyGuard { IOperator public operator; IDistributor public distributor; IYieldRepo public yieldRepo; + IReserveMigrator public reserveMigrator; + IEmissionManager public emissionManager; //============================================================================================// // POLICY SETUP // @@ -62,12 +66,16 @@ contract OlympusHeart is IHeart, Policy, RolesConsumer, ReentrancyGuard { IOperator operator_, IDistributor distributor_, IYieldRepo yieldRepo_, + IReserveMigrator reserveMigrator_, + IEmissionManager emissionManager_, uint256 maxReward_, uint48 auctionDuration_ ) Policy(kernel_) { operator = operator_; distributor = distributor_; yieldRepo = yieldRepo_; + reserveMigrator = reserveMigrator_; + emissionManager = emissionManager_; active = true; auctionDuration = auctionDuration_; @@ -118,6 +126,14 @@ contract OlympusHeart is IHeart, Policy, RolesConsumer, ReentrancyGuard { permissions[2] = Permissions(MINTR_KEYCODE, MINTR.increaseMintApproval.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, 6); + } + //============================================================================================// // CORE FUNCTIONS // //============================================================================================// @@ -131,6 +147,9 @@ contract OlympusHeart is IHeart, Policy, RolesConsumer, ReentrancyGuard { // Update the moving average on the Price module PRICE.updateMovingAverage(); + // Migrate reserves, if necessary + reserveMigrator.migrate(); + // Trigger price range update and market operations operator.operate(); @@ -140,6 +159,9 @@ contract OlympusHeart is IHeart, Policy, RolesConsumer, ReentrancyGuard { // Trigger rebase distributor.triggerRebase(); + // Trigger emission manager + emissionManager.execute(); + // Calculate the reward (0 <= reward <= maxReward) for the keeper uint256 reward = currentReward(); @@ -203,6 +225,16 @@ contract OlympusHeart is IHeart, Policy, RolesConsumer, ReentrancyGuard { yieldRepo = IYieldRepo(yieldRepo_); } + /// @inheritdoc IHeart + function setReserveMigrator(address reserveMigrator_) external onlyRole("heart_admin") { + reserveMigrator = IReserveMigrator(reserveMigrator_); + } + + /// @inheritdoc IHeart + function setEmissionManager(address emissionManager_) external onlyRole("heart_admin") { + emissionManager = IEmissionManager(emissionManager_); + } + modifier notWhileBeatAvailable() { // Prevent calling if a beat is available to avoid front-running a keeper if (uint48(block.timestamp) >= lastBeat + frequency()) revert Heart_BeatAvailable(); diff --git a/src/policies/Operator.sol b/src/policies/Operator.sol index f25c4d81..26b8cf66 100644 --- a/src/policies/Operator.sol +++ b/src/policies/Operator.sol @@ -66,12 +66,17 @@ contract Operator is IOperator, Policy, RolesConsumer, ReentrancyGuard { ERC20 public immutable reserve; uint8 internal immutable _reserveDecimals; uint8 internal _oracleDecimals; - /// @dev _wrappedReserveDecimals == _reserveDecimals - ERC4626 public immutable wrappedReserve; + /// @dev _sReserveDecimals == _reserveDecimals + ERC4626 public immutable sReserve; + + // During the reserve migration period, we need to track the reserve balance of the old reserve token + // This is because there are debts issued in the old reserve which count towards the capacity of the Operator + ERC20 public immutable oldReserve; // Constants uint32 internal constant ONE_HUNDRED_PERCENT = 100e2; - uint32 internal constant ONE_PERCENT = 1e2; + bytes32 internal constant OPERATOR_POLICY_ROLE = "operator_policy"; + bytes32 internal constant OPERATOR_ADMIN_ROLE = "operator_admin"; //============================================================================================// // POLICY SETUP // @@ -81,7 +86,7 @@ contract Operator is IOperator, Policy, RolesConsumer, ReentrancyGuard { Kernel kernel_, IBondSDA auctioneer_, IBondCallback callback_, - address[3] memory tokens_, // [ohm, reserve, wrappedReserve] + address[4] memory tokens_, // [ohm, reserve, sReserve, oldReserve] uint32[8] memory configParams // [cushionFactor, cushionDuration, cushionDebtBuffer, cushionDepositInterval, reserveFactor, regenWait, regenThreshold, regenObserve] ensure the following holds: regenWait / PRICE.observationFrequency() >= regenObserve - regenThreshold ) Policy(kernel_) { // Check params are valid @@ -94,9 +99,9 @@ contract Operator is IOperator, Policy, RolesConsumer, ReentrancyGuard { configParams[3] < uint32(1 hours) || configParams[3] > configParams[1] || configParams[0] > ONE_HUNDRED_PERCENT || - configParams[0] < ONE_PERCENT || + configParams[0] == 0 || configParams[4] > ONE_HUNDRED_PERCENT || - configParams[4] < ONE_PERCENT || + configParams[4] == 0 || configParams[5] < 1 hours || configParams[6] > configParams[7] || configParams[7] == uint32(0) || @@ -109,10 +114,11 @@ contract Operator is IOperator, Policy, RolesConsumer, ReentrancyGuard { _ohmDecimals = ohm.decimals(); reserve = ERC20(tokens_[1]); _reserveDecimals = reserve.decimals(); - wrappedReserve = ERC4626(tokens_[2]); + sReserve = ERC4626(tokens_[2]); + oldReserve = ERC20(tokens_[3]); - // Ensure wrappedReserve decimals match reserve decimals - if (wrappedReserve.decimals() != _reserveDecimals) revert Operator_InvalidParams(); + // Ensure sReserve decimals match reserve decimals + if (sReserve.decimals() != _reserveDecimals) revert Operator_InvalidParams(); _config = Config({ cushionFactor: configParams[0], @@ -207,6 +213,14 @@ contract Operator is IOperator, Policy, RolesConsumer, ReentrancyGuard { requests[12] = Permissions(MINTR_KEYCODE, MINTR.decreaseMintApproval.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, 5); + // } + //============================================================================================// // CORE FUNCTIONS // //============================================================================================// @@ -215,12 +229,7 @@ contract Operator is IOperator, Policy, RolesConsumer, ReentrancyGuard { /// This check is different from the price feed staleness checks in the PRICE module. /// The PRICE module checks new price feed data for staleness when storing a new observations, /// whereas this check ensures that the range data is using a recent observation. - modifier onlyWhileActive() { - _onlyWhileActive(); - _; - } - - function _onlyWhileActive() internal { + function _onlyWhileActive() internal view { if ( !active || uint48(block.timestamp) > PRICE.lastObservationTime() + 3 * PRICE.observationFrequency() @@ -230,7 +239,15 @@ contract Operator is IOperator, Policy, RolesConsumer, ReentrancyGuard { // ========= HEART FUNCTIONS ========= // /// @inheritdoc IOperator - function operate() external override onlyWhileActive onlyRole("operator_operate") { + function operate() external override onlyRole("heart") { + // Fail silently if not active locally so Operator can be disabled + if (!active) return; + + // Check that the policy is active and price is not stale + // There is a redundant check on the active flag here + // but we leave it in because it requires fewer changes + _onlyWhileActive(); + // Revert if not initialized if (!initialized) revert Operator_NotInitialized(); @@ -310,7 +327,10 @@ contract Operator is IOperator, Policy, RolesConsumer, ReentrancyGuard { ERC20 tokenIn_, uint256 amountIn_, uint256 minAmountOut_ - ) external override nonReentrant onlyWhileActive returns (uint256 amountOut) { + ) external override nonReentrant returns (uint256 amountOut) { + // Check that the policy is active + _onlyWhileActive(); + if (tokenIn_ == ohm) { // Revert if lower wall is inactive if (!RANGE.active(false)) revert Operator_WallDown(); @@ -338,16 +358,12 @@ contract Operator is IOperator, Policy, RolesConsumer, ReentrancyGuard { // Burn OHM MINTR.burnOhm(address(this), amountIn_); - // Calculate amount of wrappedReserve equivalent to amountOut - // and withdraw wrapped reserves from TRSRY - TRSRY.withdrawReserves( - address(this), - wrappedReserve, - wrappedReserve.previewWithdraw(amountOut) - ); + // Calculate amount of sReserve equivalent to amountOut + // and withdraw from TRSRY + TRSRY.withdrawReserves(address(this), sReserve, sReserve.previewWithdraw(amountOut)); // Unwrap reserves and transfer to sender - wrappedReserve.withdraw(amountOut, msg.sender, address(this)); + sReserve.withdraw(amountOut, msg.sender, address(this)); emit Swap(ohm, reserve, amountIn_, amountOut); } else if (tokenIn_ == reserve) { @@ -375,8 +391,8 @@ contract Operator is IOperator, Policy, RolesConsumer, ReentrancyGuard { reserve.safeTransferFrom(msg.sender, address(this), amountIn_); // Wrap reserves and transfer to TRSRY - reserve.approve(address(wrappedReserve), amountIn_); - wrappedReserve.deposit(amountIn_, address(TRSRY)); + reserve.approve(address(sReserve), amountIn_); + sReserve.deposit(amountIn_, address(TRSRY)); // Mint OHM to sender MINTR.mintOhm(msg.sender, amountOut); @@ -393,10 +409,10 @@ contract Operator is IOperator, Policy, RolesConsumer, ReentrancyGuard { /// @notice Access restricted (BondCallback) /// @param id_ ID of the bond market /// @param amountOut_ Amount of capacity expended - function bondPurchase( - uint256 id_, - uint256 amountOut_ - ) external onlyWhileActive onlyRole("operator_reporter") { + function bondPurchase(uint256 id_, uint256 amountOut_) external onlyRole("operator_reporter") { + // Check that the policy is active + _onlyWhileActive(); + if (id_ == RANGE.market(true)) { _updateCapacity(true, amountOut_); _checkCushion(true); @@ -650,21 +666,21 @@ contract Operator is IOperator, Policy, RolesConsumer, ReentrancyGuard { // Get approval from the TRSRY to withdraw up to the capacity in reserves // If current approval is higher than the capacity, reduce it - uint256 currentApproval = wrappedReserve.previewRedeem( - TRSRY.withdrawApproval(address(this), wrappedReserve) + uint256 currentApproval = sReserve.previewRedeem( + TRSRY.withdrawApproval(address(this), sReserve) ); unchecked { if (currentApproval < capacity) { TRSRY.increaseWithdrawApproval( address(this), - wrappedReserve, - wrappedReserve.previewWithdraw(capacity - currentApproval) + sReserve, + sReserve.previewWithdraw(capacity - currentApproval) ); } else if (currentApproval > capacity) { TRSRY.decreaseWithdrawApproval( address(this), - wrappedReserve, - wrappedReserve.previewWithdraw(currentApproval - capacity) + sReserve, + sReserve.previewWithdraw(currentApproval - capacity) ); } } @@ -700,7 +716,7 @@ contract Operator is IOperator, Policy, RolesConsumer, ReentrancyGuard { bool high_, uint256 cushionSpread_, uint256 wallSpread_ - ) external onlyRole("operator_policy") { + ) external onlyRole(OPERATOR_POLICY_ROLE) { // Set spreads on the range module RANGE.setSpreads(high_, cushionSpread_, wallSpread_); @@ -709,13 +725,13 @@ contract Operator is IOperator, Policy, RolesConsumer, ReentrancyGuard { } /// @inheritdoc IOperator - function setThresholdFactor(uint256 thresholdFactor_) external onlyRole("operator_policy") { + function setThresholdFactor(uint256 thresholdFactor_) external onlyRole(OPERATOR_POLICY_ROLE) { // Set threshold factor on the range module RANGE.setThresholdFactor(thresholdFactor_); } /// @inheritdoc IOperator - function setCushionFactor(uint32 cushionFactor_) external onlyRole("operator_policy") { + function setCushionFactor(uint32 cushionFactor_) external onlyRole(OPERATOR_POLICY_ROLE) { // Confirm factor is within allowed values _checkFactor(cushionFactor_); @@ -730,7 +746,7 @@ contract Operator is IOperator, Policy, RolesConsumer, ReentrancyGuard { uint32 duration_, uint32 debtBuffer_, uint32 depositInterval_ - ) external onlyRole("operator_policy") { + ) external onlyRole(OPERATOR_POLICY_ROLE) { // Confirm values are valid if ( duration_ > uint256(7 days) || @@ -749,7 +765,7 @@ contract Operator is IOperator, Policy, RolesConsumer, ReentrancyGuard { } /// @inheritdoc IOperator - function setReserveFactor(uint32 reserveFactor_) external onlyRole("operator_policy") { + function setReserveFactor(uint32 reserveFactor_) external onlyRole(OPERATOR_POLICY_ROLE) { // Confirm factor is within allowed values _checkFactor(reserveFactor_); @@ -764,7 +780,7 @@ contract Operator is IOperator, Policy, RolesConsumer, ReentrancyGuard { uint32 wait_, uint32 threshold_, uint32 observe_ - ) external onlyRole("operator_policy") { + ) external onlyRole(OPERATOR_POLICY_ROLE) { // Confirm regen parameters are within allowed values if ( wait_ < 1 hours || @@ -795,7 +811,7 @@ contract Operator is IOperator, Policy, RolesConsumer, ReentrancyGuard { function setBondContracts( IBondSDA auctioneer_, IBondCallback callback_ - ) external onlyRole("operator_policy") { + ) external onlyRole(OPERATOR_POLICY_ROLE) { if (address(auctioneer_) == address(0) || address(callback_) == address(0)) revert Operator_InvalidParams(); // Set contracts @@ -804,7 +820,7 @@ contract Operator is IOperator, Policy, RolesConsumer, ReentrancyGuard { } /// @inheritdoc IOperator - function initialize() external onlyRole("operator_admin") { + function initialize() external onlyRole(OPERATOR_ADMIN_ROLE) { // Can only call once if (initialized) revert Operator_AlreadyInitialized(); @@ -821,18 +837,18 @@ contract Operator is IOperator, Policy, RolesConsumer, ReentrancyGuard { } /// @inheritdoc IOperator - function regenerate(bool high_) external onlyRole("operator_policy") { + function regenerate(bool high_) external onlyRole(OPERATOR_POLICY_ROLE) { // Regenerate side _regenerate(high_); } /// @inheritdoc IOperator - function activate() external onlyRole("operator_policy") { + function activate() external onlyRole(OPERATOR_POLICY_ROLE) { active = true; } /// @inheritdoc IOperator - function deactivate() external onlyRole("operator_policy") { + function deactivate() external onlyRole(OPERATOR_POLICY_ROLE) { active = false; // Deactivate cushions _deactivate(true); @@ -840,13 +856,13 @@ contract Operator is IOperator, Policy, RolesConsumer, ReentrancyGuard { } /// @inheritdoc IOperator - function deactivateCushion(bool high_) external onlyRole("operator_policy") { + function deactivateCushion(bool high_) external onlyRole(OPERATOR_POLICY_ROLE) { // Manually deactivate a cushion _deactivate(high_); } function _checkFactor(uint32 factor_) internal pure { - if (factor_ > ONE_HUNDRED_PERCENT || factor_ < ONE_PERCENT) revert Operator_InvalidParams(); + if (factor_ > ONE_HUNDRED_PERCENT || factor_ == 0) revert Operator_InvalidParams(); } //============================================================================================// @@ -884,10 +900,10 @@ contract Operator is IOperator, Policy, RolesConsumer, ReentrancyGuard { /// @inheritdoc IOperator function fullCapacity(bool high_) public view override returns (uint256) { - uint256 reservesInTreasury = wrappedReserve.previewRedeem( - TRSRY.getReserveBalance(wrappedReserve) - ) + TRSRY.getReserveBalance(reserve); - uint256 capacity = (reservesInTreasury * _config.reserveFactor) / ONE_HUNDRED_PERCENT; + // Reserves in treasury * reserve factor + uint256 capacity = ((sReserve.previewRedeem(TRSRY.getReserveBalance(sReserve)) + + TRSRY.getReserveBalance(reserve) + + TRSRY.getReserveBalance(oldReserve)) * _config.reserveFactor) / ONE_HUNDRED_PERCENT; if (high_) { capacity = (capacity.mulDiv( diff --git a/src/policies/ReserveMigrator.sol b/src/policies/ReserveMigrator.sol new file mode 100644 index 00000000..3e6324d3 --- /dev/null +++ b/src/policies/ReserveMigrator.sol @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity 0.8.15; + +import {ERC20} from "solmate/tokens/ERC20.sol"; +import {ERC4626} from "solmate/mixins/ERC4626.sol"; +import {TransferHelper} from "libraries/TransferHelper.sol"; + +import "src/Kernel.sol"; +import {RolesConsumer, ROLESv1} from "modules/ROLES/OlympusRoles.sol"; +import {TRSRYv1} from "modules/TRSRY/TRSRY.v1.sol"; +import {IReserveMigrator} from "policies/interfaces/IReserveMigrator.sol"; + +interface IDaiUsds { + function daiToUsds(address usr, uint256 wad) external; +} + +contract ReserveMigrator is IReserveMigrator, Policy, RolesConsumer { + using TransferHelper for ERC20; + + // ========== STATE VARIABLES ========== // + + // Modules + TRSRYv1 internal TRSRY; + + // Reserves to migrate + ERC20 public immutable from; + ERC4626 public immutable sFrom; + ERC20 public immutable to; + ERC4626 public immutable sTo; + + // Migration contract + IDaiUsds public migrator; + + bool public locallyActive; + + // ========== SETUP ========== // + + constructor(Kernel kernel_, address sFrom_, address sTo_, address migrator_) Policy(kernel_) { + // Confirm the addresses are not null + if (sFrom_ == address(0) || sTo_ == address(0) || migrator_ == address(0)) + revert ReserveMigrator_InvalidParams(); + + sFrom = ERC4626(sFrom_); + from = ERC20(sFrom.asset()); + sTo = ERC4626(sTo_); + to = ERC20(sTo.asset()); + migrator = IDaiUsds(migrator_); + + locallyActive = true; + } + + function configureDependencies() external override returns (Keycode[] memory dependencies) { + dependencies = new Keycode[](2); + dependencies[0] = toKeycode("TRSRY"); + dependencies[1] = toKeycode("ROLES"); + + TRSRY = TRSRYv1(getModuleAddress(dependencies[0])); + ROLES = ROLESv1(getModuleAddress(dependencies[1])); + + (uint8 TRSRY_MAJOR, ) = TRSRY.VERSION(); + (uint8 ROLES_MAJOR, ) = ROLES.VERSION(); + + // Ensure Modules are using the expected major version. + // Modules should be sorted in alphabetical order. + bytes memory expected = abi.encode([1, 1]); + if (ROLES_MAJOR != 1 || TRSRY_MAJOR != 1) revert Policy_WrongModuleVersion(expected); + } + + function requestPermissions() + external + view + override + returns (Permissions[] memory permissions) + { + Keycode TRSRY_KEYCODE = TRSRY.KEYCODE(); + + permissions = new Permissions[](2); + permissions[0] = Permissions(TRSRY_KEYCODE, TRSRY.withdrawReserves.selector); + permissions[1] = Permissions(TRSRY_KEYCODE, TRSRY.increaseWithdrawApproval.selector); + } + + // ========== MIGRATE RESERVES ========== // + + /// @inheritdoc IReserveMigrator + function migrate() external override onlyRole("heart") { + // Do nothing if the policy is not active + if (!locallyActive) return; + + // Get the from and sFrom balances from the TRSRY + // Note: we want actual token balances, not "reserveBalances" that include debt. + uint256 fromBalance = from.balanceOf(address(TRSRY)); + uint256 sFromBalance = sFrom.balanceOf(address(TRSRY)); + + // Withdraw the reserves from the TRSRY + if (fromBalance > 0) { + // Increase withdrawal approval and withdraw the reserves from the TRSRY + TRSRY.increaseWithdrawApproval(address(this), from, fromBalance); + TRSRY.withdrawReserves(address(this), from, fromBalance); + } + + if (sFromBalance > 0) { + // Increase withdrawal approval and withdraw the wrapped reserves from the TRSRY + TRSRY.increaseWithdrawApproval(address(this), sFrom, sFromBalance); + TRSRY.withdrawReserves(address(this), sFrom, sFromBalance); + } + + // Update the sFrom balance to include any existing tokens in this contract + // as well as the ones withdrawn from the TRSRY + sFromBalance = sFrom.balanceOf(address(this)); + if (sFromBalance > 0) { + sFrom.redeem(sFromBalance, address(this), address(this)); + } + + // Update the from balance based on any existing tokens, withdrawals, or redemptions + fromBalance = from.balanceOf(address(this)); + + // If the total is greater than 0, migrate the reserves + if (fromBalance > 0) { + // Approve the migrator for the total amount of from reserves + from.safeApprove(address(migrator), fromBalance); + + // Cache the balance of the to token + uint256 toBalance = to.balanceOf(address(this)); + + // Migrate the reserves + migrator.daiToUsds(address(this), fromBalance); + + uint256 newToBalance = to.balanceOf(address(this)); + + // Confirm that the to balance has increased by at least the previous from balance + if (newToBalance < toBalance + fromBalance) revert ReserveMigrator_BadMigration(); + + // Wrap the to reserves and deposit them into the TRSRY + to.safeApprove(address(sTo), newToBalance); + sTo.deposit(newToBalance, address(TRSRY)); + + // Emit event + emit MigratedReserves(address(from), address(to), fromBalance); + } + } + + /// @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, 0); + } + + // ========== ADMIN FUNCTIONS ========== // + + /// @notice Activate the policy locally, if it has been deactivated + /// @dev This function is restricted to the reserve_migrator admin role + function activate() external onlyRole("reserve_migrator_admin") { + locallyActive = true; + + emit Activated(); + } + + /// @notice Deactivate the policy locally, preventing it from migrating reserves + /// @dev This function is restricted to the reserve_migrator admin role + function deactivate() external onlyRole("reserve_migrator_admin") { + locallyActive = false; + + emit Deactivated(); + } + + /// @notice Rescue any ERC20 token sent to this contract and send it to the TRSRY + /// @dev This function is restricted to the reserve_migrator admin role + /// @param token_ The address of the ERC20 token to rescue + function rescue(address token_) external onlyRole("reserve_migrator_admin") { + ERC20 token = ERC20(token_); + token.safeTransfer(address(TRSRY), token.balanceOf(address(this))); + } +} diff --git a/src/policies/YieldRepurchaseFacility.sol b/src/policies/YieldRepurchaseFacility.sol index 1cba013a..ea60dc1b 100644 --- a/src/policies/YieldRepurchaseFacility.sol +++ b/src/policies/YieldRepurchaseFacility.sol @@ -17,6 +17,7 @@ import {RolesConsumer, ROLESv1} from "modules/ROLES/OlympusRoles.sol"; import {TRSRYv1} from "modules/TRSRY/TRSRY.v1.sol"; import {PRICEv1} from "modules/PRICE/PRICE.v1.sol"; import {RANGEv2} from "modules/RANGE/RANGE.v2.sol"; +import {CHREGv1} from "modules/CHREG/CHREG.v1.sol"; interface BurnableERC20 { function burn(uint256 amount) external; @@ -42,10 +43,10 @@ contract YieldRepurchaseFacility is IYieldRepo, Policy, RolesConsumer { ///////////////////////// STATE ///////////////////////// // Tokens - ERC4626 public immutable sdai; // = ERC4626(0x83F20F44975D03b1b09e64809B757c47f942BEeA); - ERC20 public immutable dai; // = ERC20(0x6B175474E89094C44Da98b954EedeAC495271d0F); - uint8 internal immutable _daiDecimals; // = 18; - ERC20 public immutable ohm; // = ERC20(0x64aa3364F17a4D01c6f1751Fd97C2BD3D7e7f1D5); + ERC4626 public immutable sReserve; + ERC20 public immutable reserve; + uint8 internal immutable _reserveDecimals; + ERC20 public immutable ohm; uint8 internal immutable _ohmDecimals; // = 9; uint8 internal _oracleDecimals; @@ -53,9 +54,7 @@ contract YieldRepurchaseFacility is IYieldRepo, Policy, RolesConsumer { TRSRYv1 public TRSRY; PRICEv1 public PRICE; RANGEv2 public RANGE; - - // Policies - Clearinghouse public immutable clearinghouse; // = Clearinghouse(0xE6343ad0675C9b8D3f32679ae6aDbA0766A2ab4c); + CHREGv1 public CHREG; // External contracts address public immutable teller; // = 0x007F7735baF391e207E3aA380bb53c4Bd9a5Fed6; @@ -63,9 +62,9 @@ contract YieldRepurchaseFacility is IYieldRepo, Policy, RolesConsumer { // System variables uint48 public epoch; // a running counter to keep time - uint256 public nextYield; // the amount of DAI to pull as yield at the start of the next week - uint256 public lastReserveBalance; // the SDAI reserve balance, in DAI, at the end of the last week - uint256 public lastConversionRate; // the SDAI conversion rate at the end of the last week + uint256 public nextYield; // the amount of reserve to pull as yield at the start of the next week + uint256 public lastReserveBalance; // the sReserve balance, in reserve units, at the end of the last week + uint256 public lastConversionRate; // the sReserve conversion rate at the end of the last week // we use this to compute yield accrued // yield = last reserve balance * ((current conversion rate / last conversion rate) - 1) // + current clearinghouse principal receivables * clearinghouse APR / 52 weeks @@ -80,22 +79,19 @@ contract YieldRepurchaseFacility is IYieldRepo, Policy, RolesConsumer { constructor( Kernel kernel_, address ohm_, - address dai_, - address sdai_, + address sReserve_, address teller_, - address auctioneer_, - address clearinghouse_ + address auctioneer_ ) Policy(kernel_) { // Set immutable variables ohm = ERC20(ohm_); - dai = ERC20(dai_); - sdai = ERC4626(sdai_); + sReserve = ERC4626(sReserve_); + reserve = ERC20(sReserve.asset()); teller = teller_; auctioneer = IBondSDA(auctioneer_); - clearinghouse = Clearinghouse(clearinghouse_); // Cache token decimals - _daiDecimals = dai.decimals(); + _reserveDecimals = reserve.decimals(); _ohmDecimals = ohm.decimals(); // Disable until initialization @@ -119,16 +115,18 @@ contract YieldRepurchaseFacility is IYieldRepo, Policy, RolesConsumer { } function configureDependencies() external override returns (Keycode[] memory dependencies) { - dependencies = new Keycode[](4); + dependencies = new Keycode[](5); dependencies[0] = toKeycode("TRSRY"); dependencies[1] = toKeycode("PRICE"); dependencies[2] = toKeycode("RANGE"); - dependencies[3] = toKeycode("ROLES"); + dependencies[3] = toKeycode("CHREG"); + dependencies[4] = toKeycode("ROLES"); TRSRY = TRSRYv1(getModuleAddress(dependencies[0])); PRICE = PRICEv1(getModuleAddress(dependencies[1])); RANGE = RANGEv2(getModuleAddress(dependencies[2])); - ROLES = ROLESv1(getModuleAddress(dependencies[3])); + CHREG = CHREGv1(getModuleAddress(dependencies[3])); + ROLES = ROLESv1(getModuleAddress(dependencies[4])); _oracleDecimals = PRICE.decimals(); } @@ -146,6 +144,14 @@ contract YieldRepurchaseFacility is IYieldRepo, Policy, RolesConsumer { permissions[1] = Permissions(TRSRY_KEYCODE, TRSRYv1.increaseWithdrawApproval.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, 1); + } + ///////////////////////// EXTERNAL ///////////////////////// /// @notice create a new bond market at the end of the day with some portion of remaining funds @@ -161,25 +167,30 @@ contract YieldRepurchaseFacility is IYieldRepo, Policy, RolesConsumer { nextYield = getNextYield(); emit NextYieldSet(nextYield); - lastConversionRate = sdai.previewRedeem(1e18); + lastConversionRate = sReserve.previewRedeem(1e18); lastReserveBalance = getReserveBalance(); } - _getBackingForPurchased(); // convert yesterdays ohm purchases into sdai + _getBackingForPurchased(); // convert yesterdays ohm purchases into sReserve - uint256 daiBalance = dai.balanceOf(address(this)); - uint256 totalBalanceInDAI = daiBalance + sdai.previewRedeem(sdai.balanceOf(address(this))); + uint256 reserveBalance = reserve.balanceOf(address(this)); + uint256 totalBalanceInReserve = reserveBalance + + sReserve.previewRedeem(sReserve.balanceOf(address(this))); - // use portion of dai balance based on day of the week + // use portion of reserve balance based on day of the week // i.e. day one, use 1/7th; day two, use 1/6th; 1/5th; 1/4th; ... - uint256 bidAmount = totalBalanceInDAI / (7 - (epoch / 3)); + uint256 bidAmount = totalBalanceInReserve / (7 - (epoch / 3)); - // contract holds funds in sDAI except for the day's inventory, so we need to redeem before opening a market - uint256 bidAmountFromSDAI = daiBalance < bidAmount - ? bidAmount - dai.balanceOf(address(this)) + // contract holds funds in sReserve except for the day's inventory, so we need to redeem before opening a market + uint256 bidAmountFromSReserve = reserveBalance < bidAmount + ? bidAmount - reserve.balanceOf(address(this)) : 0; - if (bidAmountFromSDAI != 0) - sdai.redeem(sdai.previewWithdraw(bidAmountFromSDAI), address(this), address(this)); + if (bidAmountFromSReserve != 0) + sReserve.redeem( + sReserve.previewWithdraw(bidAmountFromSReserve), + address(this), + address(this) + ); _createMarket(bidAmount); } @@ -195,7 +206,7 @@ contract YieldRepurchaseFacility is IYieldRepo, Policy, RolesConsumer { } /// @notice retire contract by burning ohm balance and transferring tokens to treasury - /// @param tokensToTransfer list of tokens to transfer back to treasury (i.e. DAI) + /// @param tokensToTransfer list of tokens to transfer back to treasury (i.e. reserves) function shutdown(ERC20[] memory tokensToTransfer) external onlyRole("loop_daddy") { isShutdown = true; emit Shutdown(); @@ -213,7 +224,7 @@ contract YieldRepurchaseFacility is IYieldRepo, Policy, RolesConsumer { ///////////////////////// INTERNAL ///////////////////////// /// @notice create bond protocol market with given budget - /// @param bidAmount amount of DAI to fund bond market with + /// @param bidAmount amount of reserve to fund bond market with function _createMarket(uint256 bidAmount) internal { // Calculate inverse prices from the oracle feed // The start price is the current market price, which is also the last price since this is called on a heartbeat @@ -229,21 +240,23 @@ contract YieldRepurchaseFacility is IYieldRepo, Policy, RolesConsumer { // so the operations assume payoutPriceDecimal is zero and quotePriceDecimals // is the priceDecimal value int8 priceDecimals = _getPriceDecimals(initialPrice); - int8 scaleAdjustment = int8(_daiDecimals) - int8(_ohmDecimals) + (priceDecimals / 2); + int8 scaleAdjustment = int8(_reserveDecimals) - int8(_ohmDecimals) + (priceDecimals / 2); // Calculate oracle scale and bond scale with scale adjustment and format prices for bond market uint256 oracleScale = 10 ** uint8(int8(_oracleDecimals) - priceDecimals); uint256 bondScale = 10 ** - uint8(36 + scaleAdjustment + int8(_ohmDecimals) - int8(_daiDecimals) - priceDecimals); + uint8( + 36 + scaleAdjustment + int8(_ohmDecimals) - int8(_reserveDecimals) - priceDecimals + ); - // Approve DAI on the bond teller - dai.safeApprove(address(teller), bidAmount); + // Approve reserve on the bond teller + reserve.safeApprove(address(teller), bidAmount); // Create new bond market to buy OHM with the reserve uint256 marketId = auctioneer.createMarket( abi.encode( IBondSDA.MarketParams({ - payoutToken: dai, + payoutToken: reserve, quoteToken: ohm, callbackAddr: address(0), capacityInQuote: false, @@ -274,16 +287,16 @@ contract YieldRepurchaseFacility is IYieldRepo, Policy, RolesConsumer { _withdraw(backing); } - /// @notice internal function to withdraw sDAI from treasury - /// @dev note amount given is in DAI, not sDAI - /// @param amount an amount to withdraw, in DAI + /// @notice internal function to withdraw sReserve from treasury + /// @dev note amount given is in reserve, not sReserve + /// @param amount an amount to withdraw, in reserve function _withdraw(uint256 amount) internal { - // Get the amount of sDAI to withdraw - uint256 amountInSDAI = sdai.previewWithdraw(amount); + // Get the amount of sReserve to withdraw + uint256 amountInSReserve = sReserve.previewWithdraw(amount); - // Approve and withdraw sDAI from TRSRY - TRSRY.increaseWithdrawApproval(address(this), ERC20(address(sdai)), amountInSDAI); - TRSRY.withdrawReserves(address(this), ERC20(address(sdai)), amountInSDAI); + // Approve and withdraw sReserve from TRSRY + TRSRY.increaseWithdrawApproval(address(this), ERC20(address(sReserve)), amountInSReserve); + TRSRY.withdrawReserves(address(this), ERC20(address(sReserve)), amountInSReserve); } /// @notice Helper function to calculate number of price decimals based on the value returned from the price feed. @@ -303,22 +316,32 @@ contract YieldRepurchaseFacility is IYieldRepo, Policy, RolesConsumer { ///////////////////////// VIEW ///////////////////////// - /// @notice fetch combined sdai balance of clearinghouse and treasury, in DAI + /// @notice fetch combined sReserve balance of active clearinghouses and treasury, in reserve function getReserveBalance() public view override returns (uint256 balance) { - uint256 sBalance = sdai.balanceOf(address(clearinghouse)); - sBalance += sdai.balanceOf(address(TRSRY)); + uint256 sBalance = sReserve.balanceOf(address(TRSRY)); + uint256 len = CHREG.activeCount(); + for (uint256 i; i < len; i++) { + sBalance += sReserve.balanceOf(CHREG.active(i)); + } - balance = sdai.previewRedeem(sBalance); + balance = sReserve.previewRedeem(sBalance); } /// @notice compute yield for the next week function getNextYield() public view override returns (uint256 yield) { - // add sDAI rewards accrued for week + // add sReserve rewards accrued for week yield += - ((lastReserveBalance * sdai.previewRedeem(1e18)) / lastConversionRate) - + ((lastReserveBalance * sReserve.previewRedeem(1e18)) / lastConversionRate) - lastReserveBalance; // add clearinghouse interest accrued for week (0.5% divided by 52 weeks) - yield += (clearinghouse.principalReceivables() * 5) / 1000 / 52; + // iterate through clearinghouses in the CHREG and get the outstanding principal receivables + uint256 receivables; + uint256 len = CHREG.registryCount(); + for (uint256 i; i < len; i++) { + receivables += Clearinghouse(CHREG.registry(i)).principalReceivables(); + } + + yield += (receivables * 5) / 1000 / 52; } /// @notice compute backing for ohm balance @@ -328,7 +351,7 @@ contract YieldRepurchaseFacility is IYieldRepo, Policy, RolesConsumer { override returns (uint256 balance, uint256 backing) { - // balance and backingPerToken are 9 decimals, dai amount is 18 decimals + // balance and backingPerToken are 9 decimals, reserve amount is 18 decimals balance = ohm.balanceOf(address(this)); backing = balance * backingPerToken; } diff --git a/src/policies/interfaces/IEmissionManager.sol b/src/policies/interfaces/IEmissionManager.sol new file mode 100644 index 00000000..a2656dcf --- /dev/null +++ b/src/policies/interfaces/IEmissionManager.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity >=0.8.0; + +interface IEmissionManager { + // ========== ERRORS ========== // + + error OnlyTeller(); + error InvalidMarket(); + error InvalidCallback(); + error InvalidParam(string parameter); + error CannotRestartYet(uint48 availableAt); + error RestartTimeframePassed(); + error NotActive(); + error AlreadyActive(); + + // ========== EVENTS ========== // + + event SaleCreated(uint256 marketID, uint256 saleAmount); + event BackingUpdated(uint256 newBacking, uint256 supplyAdded, uint256 reservesAdded); + + /// @notice Emitted when the base emission rate is changed + event BaseRateChanged(uint256 changeBy, uint48 forNumBeats, bool add); + + /// @notice Emitted when the minimum premium is changed + event MinimumPremiumChanged(uint256 newMinimumPremium); + + /// @notice Emitted when the vesting period is changed + event VestingPeriodChanged(uint48 newVestingPeriod); + + /// @notice Emitted when the backing is changed + /// This differs from `BackingUpdated` in that it is emitted when the backing is changed directly by governance + event BackingChanged(uint256 newBacking); + + /// @notice Emitted when the restart timeframe is changed + event RestartTimeframeChanged(uint48 newRestartTimeframe); + + /// @notice Emitted when the bond contracts are set + event BondContractsSet(address auctioneer, address teller); + + /// @notice Emitted when the contract is activated + event Activated(); + + /// @notice Emitted when the contract is deactivated + event Deactivated(); + + // ========== DATA STRUCTURES ========== // + + struct BaseRateChange { + uint256 changeBy; + uint48 daysLeft; + bool addition; + } + + // ========== EXECUTE ========== // + + /// @notice calculate and execute sale, if applicable, once per day (every 3 beats) + /// @dev this function is restricted to the heart role and is called on each heart beat + /// @dev if the contract is not active, the function does nothing + function execute() external; +} diff --git a/src/policies/interfaces/IHeart.sol b/src/policies/interfaces/IHeart.sol index cfeb63e0..cb9b4e84 100644 --- a/src/policies/interfaces/IHeart.sol +++ b/src/policies/interfaces/IHeart.sol @@ -57,6 +57,16 @@ interface IHeart { /// @param yieldRepo_ The address of the new YieldRepo contract function setYieldRepo(address yieldRepo_) external; + /// @notice Updates the ReserveMigrator contract address that the Heart calls on a beat + /// @notice Access restricted + /// @param reserveMigrator_ The address of the new ReserveMigrator contract + function setReserveMigrator(address reserveMigrator_) external; + + /// @notice Updates the EmissionManager contract address that the Heart calls on a beat + /// @notice Access restricted + /// @param emissionManager_ The address of the new EmissionManager contract + function setEmissionManager(address emissionManager_) external; + /// @notice Sets the max reward amount, and auction duration for the beat function /// @notice Access restricted /// @param maxReward_ - New max reward amount, in units of the reward token diff --git a/src/policies/interfaces/IReserveMigrator.sol b/src/policies/interfaces/IReserveMigrator.sol new file mode 100644 index 00000000..4a2f0950 --- /dev/null +++ b/src/policies/interfaces/IReserveMigrator.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity >=0.8.0; + +interface IReserveMigrator { + // ========== ERRORS ========== // + + error ReserveMigrator_InvalidParams(); + error ReserveMigrator_BadMigration(); + + // ========== EVENTS ========== // + + event MigratedReserves(address indexed from, address indexed to, uint256 amount); + event Activated(); + event Deactivated(); + + // ========== MIGRATE ========== // + + /// @notice migrate reserves and wrapped reserves in the treasury to the new reserve token + /// @dev this function is restricted to the heart role to avoid complications with opportunistic conversions + /// @dev if no migration is required or it is deactivated, the function does nothing + function migrate() external; +} diff --git a/src/proposals/EmissionManagerProposal.sol b/src/proposals/EmissionManagerProposal.sol new file mode 100644 index 00000000..97f2c622 --- /dev/null +++ b/src/proposals/EmissionManagerProposal.sol @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; +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"; +// 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 {BondCallback} from "src/policies/BondCallback.sol"; +import {Operator} from "src/policies/Operator.sol"; +import {Clearinghouse} from "src/policies/Clearinghouse.sol"; +import {YieldRepurchaseFacility} from "src/policies/YieldRepurchaseFacility.sol"; +import {EmissionManager} from "src/policies/EmissionManager.sol"; + +/// @notice Initializes the EmissionManager policy +contract EmissionManagerProposal is GovernorBravoProposal { + Kernel internal _kernel; + + // /// @notice The base emission rate, in OHM scale. Set to 0.02%. + // uint256 public constant BASE_EMISSIONS_RATE = 2e5; + // /// @notice The minimum premium, where 100% = 1e18. Set to 100%. + // uint256 public constant MINIMUM_PREMIUM = 1e18; + // // TODO fill in values + // uint256 public constant BACKING = 0; + // uint48 public constant RESTART_TIMEFRAME = 0; + + // Returns the id of the proposal. + function id() public pure override returns (uint256) { + // 3: ReserveMigrator/OIP-168 + return 4; + } + + // Returns the name of the proposal. + function name() public pure override returns (string memory) { + return "Initialize Emissions Manager"; + } + + // Provides a brief description of the proposal. + function description() public pure override returns (string memory) { + return + string.concat( + "# OIP-171 - Activation of Emissions Manager Policy\n\n", + "## Summary\n\nThe primary supply emission structure of Olympus has the protocol offer new tokens when the market for OHM is at a premium. This vote will grant permission to activate and properly role the new emissions contract - 0x50f441a3387625bDA8B8081cE3fd6C04CC48C0A2\n\n", + "## Justification\n", + "The Emissions Manager allows the protocol to grow treasury, backing, and supply upon an increase in demand for OHM sufficient to push higher premiums.\n", + "\n", + "## Description\n", + "\n", + "This proposal will result in the following:\n", + "- Activation of the new EmissionManager policy\n", + "\n", + "## Resources\n", + "\n", + "- [Read the forum proposal](https://forum.olympusdao.finance/d/4656-install-emissions-manager) for more context.\n", + "- The EmissionManager policy has been audited. [Read the audit report](https://storage.googleapis.com/olympusdao-landing-page-reports/audits/2024_11_EmissionManager_ReserveMigrator.pdf)\n", + "- The code changes and proposal can be found in pull request [#18](https://github.com/OlympusDAO/olympus-v3/pull/18)\n", + "\n", + "## Roles to Assign\n", + "\n", + "1. `emissions_admin` to the Timelock\n", + "2. `emissions_admin` to the DAO MS\n", + "\n", + "## Follow-on MS Actions (due to time-sensitive valeus)\n", + "\n", + "1. Initialize the new EmissionManager policy" + ); + } + + // 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 {} + + // NOTE: In its current form, OCG is limited to admin roles when it refers to interactions with + // exsting policies and modules. Nevertheless, the DAO MS is still the Kernel executor. + // Because of that, OCG can't interact (un/install policies/modules) with the Kernel, yet. + + // Sets up actions for the proposal + function _build(Addresses addresses) internal override { + // Load the roles admin contract + address rolesAdmin = addresses.getAddress("olympus-policy-roles-admin"); + + // Load variables + address timelock = addresses.getAddress("olympus-timelock"); + address daoMS = addresses.getAddress("olympus-multisig-dao"); + // address emissionManager = addresses.getAddress("olympus-policy-emissionmanager"); + + // STEP 1: Assign roles + // 1a. Grant "emissions_admin" to the Timelock + _pushAction( + rolesAdmin, + abi.encodeWithSelector( + RolesAdmin.grantRole.selector, + bytes32("emissions_admin"), + timelock + ), + "Grant emissions_admin to Timelock" + ); + // 1b. Grant "emissions_admin" to the DAO MS + _pushAction( + rolesAdmin, + abi.encodeWithSelector( + RolesAdmin.grantRole.selector, + bytes32("emissions_admin"), + daoMS + ), + "Grant emissions_admin to DAO MS" + ); + + // // STEP 2: Policy initialization steps + // // 2a. Initialize the new EmissionManager policy + // _pushAction( + // emissionManager, + // abi.encodeWithSelector( + // EmissionManager.initialize.selector, + // BASE_EMISSIONS_RATE, + // MINIMUM_PREMIUM, + // BACKING, + // RESTART_TIMEFRAME + // ), + // "Initialize the new EmissionManager policy" + // ); + } + + // 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 daoMS = addresses.getAddress("olympus-multisig-dao"); + + // Validate the Timelock has the "emissions_admin" role + require( + roles.hasRole(timelock, bytes32("emissions_admin")), + "Timelock does not have the emissions_admin role" + ); + + // Validate the DAO MS has the "emissions_admin" role + require( + roles.hasRole(daoMS, bytes32("emissions_admin")), + "DAO MS does not have the emissions_admin role" + ); + } +} + +// solhint-disable-next-line contract-name-camelcase +contract EmissionManagerProposalScript is ProposalScript { + constructor() ProposalScript(new EmissionManagerProposal()) {} +} diff --git a/src/proposals/OIP_168.sol b/src/proposals/OIP_168.sol new file mode 100644 index 00000000..73c28e1b --- /dev/null +++ b/src/proposals/OIP_168.sol @@ -0,0 +1,242 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; +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"; +// 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 {BondCallback} from "src/policies/BondCallback.sol"; +import {Operator} from "src/policies/Operator.sol"; +import {Clearinghouse} from "src/policies/Clearinghouse.sol"; +import {YieldRepurchaseFacility} from "src/policies/YieldRepurchaseFacility.sol"; + +/// @notice OIP-168 migrates the reserve used in the Olympus protocol from DAI to USDS. +// solhint-disable-next-line contract-name-camelcase +contract OIP_168 is GovernorBravoProposal { + Kernel internal _kernel; + + // Returns the id of the proposal. + function id() public pure override returns (uint256) { + return 3; + } + + // Returns the name of the proposal. + function name() public pure override returns (string memory) { + return "OIP-168: Migration of Reserves from DAI to USDS"; + } + + // Provides a brief description of the proposal. + function description() public pure override returns (string memory) { + return + string.concat( + "# OIP-168: Migrate the reserve token from DAI to USDS\n", + "\n", + "## Summary\n", + "\n", + "As Maker continues to reduce the DSR in favor of USDS, there is a tactical need to migrate a majority of Treasury Reserves from sDAI to sUSDS. Doing so will immediately create an additional 1% APY (sUSDS is currently 6.5% to sDAI's 5.5%) and failing to do so creates substantial missed opportunity cost.\n", + "\n", + "This OCG proposal will result in the following:\n", + "- Activation of a new ReserveMigrator policy that will periodically migrate any DAI/sDAI in the Treasury to USDS/sUSDS\n", + "- Activation of updated Clearinghouse, Heart, Operator and YieldRepurchaseFacility policies to support the new reserve token\n", + "\n", + "## Resources\n", + "\n", + "- Read the [forum proposal](https://forum.olympusdao.finance/d/4633-oip-168-olympus-treasury-migration-from-daisdai-to-usdssusds) for more context.\n", + "- The new ReserveMigrator policy has also been audited. [Read the audit report](https://storage.googleapis.com/olympusdao-landing-page-reports/audits/2024_11_EmissionManager_ReserveMigrator.pdf)\n", + "- The code changes can be found in pull request [#18](https://github.com/OlympusDAO/olympus-v3/pull/18)\n", + "\n", + "## Roles to Assign\n", + "\n", + "1. `heart` to the new Heart policy (renamed from `operator_operate`)\n", + "2. `reserve_migrator_admin` to the Timelock and DAO MS\n", + "3. `callback_whitelist` to the new Operator policy\n", + "4. `emergency_shutdown` to the DAO MS\n", + "\n", + "## Roles to Revoke\n", + "\n", + "1. `heart` from the old Heart policy\n", + "2. `operator_operate` from the old Heart policy\n", + "3. `callback_whitelist` from the old Operator policy\n", + "\n", + "## Follow-on Actions by DAO MS\n", + "\n", + "1. Deactivate old Operator, Clearinghouse, YRF, and Heart locally (i.e. on the contracts themselves)\n", + "2. Deactivate old Operator, Clearinghouse, YRF, and Heart on the Kernel\n", + "3. Activate new Operator, Clearinghouse, YRF, and Heart on the Kernel\n", + "4. Configure BondCallback with new Operator and USDS/sUSDS\n", + "5. Initialize new Operator, Clearinghouse, and YRF" + ); + } + + // 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 {} + + // NOTE: In its current form, OCG is limited to admin roles when it refers to interactions with + // exsting policies and modules. Nevertheless, the DAO MS is still the Kernel executor. + // Because of that, OCG can't interact (un/install policies/modules) with the Kernel, yet. + + // Sets up actions for the proposal + function _build(Addresses addresses) internal override { + // Load the roles admin contract + address rolesAdmin = addresses.getAddress("olympus-policy-roles-admin"); + + // Load variables + address operator_1_4 = addresses.getAddress("olympus-policy-operator-1_4"); + 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 timelock = addresses.getAddress("olympus-timelock"); + address daoMS = addresses.getAddress("olympus-multisig-dao"); + + // STEP 1: Assign roles + // 1a. Grant "heart" to the new Heart policy + _pushAction( + rolesAdmin, + abi.encodeWithSelector(RolesAdmin.grantRole.selector, bytes32("heart"), heart_1_6), + "Grant heart to new Heart policy" + ); + + // 1b. Grant "reserve_migrator_admin" to the Timelock and DAO MS + _pushAction( + rolesAdmin, + abi.encodeWithSelector( + RolesAdmin.grantRole.selector, + bytes32("reserve_migrator_admin"), + timelock + ), + "Grant reserve_migrator_admin to Timelock" + ); + _pushAction( + rolesAdmin, + abi.encodeWithSelector( + RolesAdmin.grantRole.selector, + bytes32("reserve_migrator_admin"), + daoMS + ), + "Grant reserve_migrator_admin to DAO MS" + ); + + // 1c. Grant "callback_whitelist" to the new Operator policy + _pushAction( + rolesAdmin, + abi.encodeWithSelector( + RolesAdmin.grantRole.selector, + bytes32("callback_whitelist"), + operator_1_5 + ), + "Grant callback_whitelist to new Operator policy" + ); + + // 1d. Grant "emergency_shutdown" to the DAO MS + // Missing from its permissions and needed to sunset existing Clearinghouse + _pushAction( + rolesAdmin, + abi.encodeWithSelector( + RolesAdmin.grantRole.selector, + bytes32("emergency_shutdown"), + daoMS + ), + "Grant emergency_shutdown to DAO MS" + ); + + // STEP 2: Revoke roles + // 2a. Revoke "heart" from the old Heart policy + _pushAction( + rolesAdmin, + abi.encodeWithSelector(RolesAdmin.revokeRole.selector, bytes32("heart"), heart_1_5), + "Revoke heart from old Heart policy" + ); + + // 2b. Revoke "operator_operate" from the old Heart policy + _pushAction( + rolesAdmin, + abi.encodeWithSelector( + RolesAdmin.revokeRole.selector, + bytes32("operator_operate"), + heart_1_5 + ), + "Revoke operator_operate from old Heart policy" + ); + + // 2c. Revoke "callback_whitelist" from the old Operator policy + _pushAction( + rolesAdmin, + abi.encodeWithSelector( + RolesAdmin.revokeRole.selector, + bytes32("callback_whitelist"), + operator_1_4 + ), + "Revoke callback_whitelist from old Operator policy" + ); + } + + // 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 operator_1_4 = addresses.getAddress("olympus-policy-operator-1_4"); + 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"); + + // Validate the new Heart policy has the "heart" role + require( + roles.hasRole(heart_1_6, bytes32("heart")), + "New Heart policy does not have the heart role" + ); + + // Validate the new Operator policy has the "callback_whitelist" role + require( + roles.hasRole(operator_1_5, bytes32("callback_whitelist")), + "New Operator policy does not have the callback_whitelist role" + ); + + // Validate the old Heart policy does not have the "heart" role + require( + !roles.hasRole(heart_1_5, bytes32("heart")), + "Old Heart policy still has the heart role" + ); + + // Validate the old Heart policy does not have the "operator_operate" role + require( + !roles.hasRole(heart_1_5, bytes32("operator_operate")), + "Old Heart policy still has the operator_operate role" + ); + + // Validate the old Operator policy does not have the "callback_whitelist" role + require( + !roles.hasRole(operator_1_4, bytes32("callback_whitelist")), + "Old Operator policy still has the callback_whitelist role" + ); + } +} + +// solhint-disable-next-line contract-name-camelcase +contract OIP_168ProposalScript is ProposalScript { + constructor() ProposalScript(new OIP_168()) {} +} diff --git a/src/proposals/OIP_XXX.sol b/src/proposals/OIP_XXX.sol index bb176f82..ddda9d05 100644 --- a/src/proposals/OIP_XXX.sol +++ b/src/proposals/OIP_XXX.sol @@ -54,7 +54,7 @@ contract OIP_XXX is GovernorBravoProposal { ohm_: addresses.getAddress("olympus-legacy-ohm"), gohm_: addresses.getAddress("olympus-legacy-gohm"), staking_: addresses.getAddress("olympus-legacy-staking"), - sdai_: addresses.getAddress("external-tokens-sdai"), + sReserve_: addresses.getAddress("external-tokens-sdai"), coolerFactory_: addresses.getAddress("external-coolers-factory"), kernel_: address(_kernel) }); diff --git a/src/proposals/ProposalScript.sol b/src/proposals/ProposalScript.sol index 41ba5c1f..366df159 100644 --- a/src/proposals/ProposalScript.sol +++ b/src/proposals/ProposalScript.sol @@ -42,6 +42,68 @@ abstract contract ProposalScript is ScriptSuite { console2.log("Proposal ID:", proposalId); } + function printProposalInputs() public { + // 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)); + + (address[] memory targets, uint256[] memory values, bytes[] memory calldatas) = proposal + .getProposalActions(); + uint256 len = targets.length; + // print the targets + console2.log("Targets:"); + string memory t_str = "["; + for (uint256 i = 0; i < len; i++) { + if (i == len - 1) { + t_str = string.concat(t_str, vm.toString(targets[i]), "]"); + } else { + t_str = string.concat(t_str, vm.toString(targets[i]), ", "); + } + } + console2.log(t_str); + + // print the values + console2.log("Values:"); + string memory v_str = "["; + for (uint256 i = 0; i < len; i++) { + if (i == len - 1) { + v_str = string.concat(v_str, vm.toString(values[i]), "]"); + } else { + v_str = string.concat(v_str, vm.toString(values[i]), ", "); + } + } + console2.log(v_str); + + // print the calldatas + console2.log("Calldatas:"); + string memory c_str = "["; + for (uint256 i = 0; i < len; i++) { + if (i == len - 1) { + c_str = string.concat(c_str, vm.toString(calldatas[i]), "]"); + } else { + c_str = string.concat(c_str, vm.toString(calldatas[i]), ", "); + } + } + console2.log(c_str); + + // print the signatures list of empty strings + console2.log("Signatures:"); + string memory s_str = "["; + for (uint256 i = 0; i < len; i++) { + if (i == len - 1) { + s_str = string.concat(s_str, '""', "]"); + } else { + s_str = string.concat(s_str, '""', ", "); + } + } + + // print the description + console2.log("Description:"); + console2.log(proposal.description()); + } + function executeOnTestnet() public { console2.log("Building proposal..."); // set debug mode to true and run it to build the actions list diff --git a/src/proposals/addresses.json b/src/proposals/addresses.json index 8901b86d..56af6358 100644 --- a/src/proposals/addresses.json +++ b/src/proposals/addresses.json @@ -78,5 +78,55 @@ "addr": "0x6CAfd730Dc199Df73C16420C4fCAb18E3afbfA59", "name": "olympus-module-roles", "chainId": 1 + }, + { + "addr": "0xdC035D45d973E3EC169d2276DDab16f1e407384F", + "name": "external-tokens-usds", + "chainId": 1 + }, + { + "addr": "0xa3931d71877C0E7a3148CB7Eb4463524FEc27fbD", + "name": "external-tokens-susds", + "chainId": 1 + }, + { + "addr": "0x73df08CE9dcC8d74d22F23282c4d49F13b4c795E", + "name": "olympus-policy-bondcallback", + "chainId": 1 + }, + { + "addr": "0x39F6AA3d445e6Dd8eC232c6Bd589889A88E3034d", + "name": "olympus-policy-heart-1_5", + "chainId": 1 + }, + { + "addr": "0x0AE561226896dA978EaDA0Bec4a7d3CfAE04f506", + "name": "olympus-policy-operator-1_4", + "chainId": 1 + }, + { + "addr": "0xf7602C0421c283A2fc113172EBDf64C30F21654D", + "name": "olympus-policy-heart-1_6", + "chainId": 1 + }, + { + "addr": "0x6417F206a0a6628Da136C0Faa39026d0134D2b52", + "name": "olympus-policy-operator-1_5", + "chainId": 1 + }, + { + "addr": "0x1e094fE00E13Fd06D64EeA4FB3cD912893606fE0", + "name": "olympus-policy-clearinghouse-1_2", + "chainId": 1 + }, + { + "addr": "0xcaA3d3E653A626e2656d2E799564fE952D39d855", + "name": "olympus-policy-yieldrepurchasefacility", + "chainId": 1 + }, + { + "addr": "0x50f441a3387625bDA8B8081cE3fd6C04CC48C0A2", + "name": "olympus-policy-emissionmanager", + "chainId": 1 } ] diff --git a/src/scripts/deploy/DeployV2.sol b/src/scripts/deploy/DeployV2.sol index 913bdbe0..5184fdfe 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 {ReserveMigrator} from "policies/ReserveMigrator.sol"; +import {EmissionManager} from "policies/EmissionManager.sol"; import {MockPriceFeed} from "src/test/mocks/MockPriceFeed.sol"; import {MockAuraBooster, MockAuraRewardPool, MockAuraMiningLib, MockAuraVirtualRewardPool, MockAuraStashToken} from "src/test/mocks/AuraMocks.sol"; @@ -106,6 +108,9 @@ contract OlympusDeploy is Script { BLVaultLusd public lusdVault; CrossChainBridge public bridge; LegacyBurner public legacyBurner; + YieldRepurchaseFacility public yieldRepo; + ReserveMigrator public reserveMigrator; + EmissionManager public emissionManager; /// Other Olympus contracts OlympusAuthority public burnerReplacementAuthority; @@ -115,7 +120,6 @@ contract OlympusDeploy is Script { pOLY public poly; Clearinghouse public clearinghouse; CoolerUtils public coolerUtils; - YieldRepurchaseFacility public yieldRepo; // Governance Timelock public timelock; @@ -127,8 +131,10 @@ contract OlympusDeploy is Script { /// Token addresses ERC20 public ohm; ERC20 public gohm; + ERC20 public oldReserve; + ERC4626 public oldSReserve; ERC20 public reserve; - ERC4626 public wrappedReserve; + ERC4626 public sReserve; ERC20 public wsteth; ERC20 public lusd; ERC20 public aura; @@ -154,6 +160,7 @@ contract OlympusDeploy is Script { address public previousPoly; address public previousGenesis; ClaimTransfer public claimTransfer; + address public externalMigrator; /// Balancer Contracts IVault public balancerVault; @@ -214,6 +221,8 @@ contract OlympusDeploy is Script { selectorMap["Clearinghouse"] = this._deployClearinghouse.selector; selectorMap["CoolerUtils"] = this._deployCoolerUtils.selector; selectorMap["YieldRepurchaseFacility"] = this._deployYieldRepurchaseFacility.selector; + selectorMap["ReserveMigrator"] = this._deployReserveMigrator.selector; + selectorMap["EmissionManager"] = this._deployEmissionManager.selector; // Governance selectorMap["Timelock"] = this._deployTimelock.selector; @@ -226,12 +235,13 @@ contract OlympusDeploy is Script { // Non-bophades contracts ohm = ERC20(envAddress("olympus.legacy.OHM")); gohm = ERC20(envAddress("olympus.legacy.gOHM")); - reserve = ERC20(envAddress("external.tokens.DAI")); - wrappedReserve = ERC4626(envAddress("external.tokens.sDAI")); + reserve = ERC20(envAddress("external.tokens.USDS")); + sReserve = ERC4626(envAddress("external.tokens.sUSDS")); + oldReserve = ERC20(envAddress("external.tokens.DAI")); + oldSReserve = ERC4626(envAddress("external.tokens.sDAI")); wsteth = ERC20(envAddress("external.tokens.WSTETH")); aura = ERC20(envAddress("external.tokens.AURA")); bal = ERC20(envAddress("external.tokens.BAL")); - wrappedReserve = ERC4626(envAddress("external.tokens.sDAI")); bondAuctioneer = IBondSDA(envAddress("external.bond-protocol.BondFixedTermAuctioneer")); bondFixedExpiryAuctioneer = IBondSDA( envAddress("external.bond-protocol.BondFixedExpiryAuctioneer") @@ -266,6 +276,7 @@ contract OlympusDeploy is Script { envAddress("olympus.legacy.LegacyBurnerReplacementAuthority") ); coolerFactory = CoolerFactory(envAddress("external.cooler.CoolerFactory")); + externalMigrator = envAddress("external.maker.daiUsdsMigrator"); // Bophades contracts kernel = Kernel(envAddress("olympus.Kernel")); @@ -299,6 +310,8 @@ contract OlympusDeploy is Script { claimTransfer = ClaimTransfer(envAddress("olympus.claim.ClaimTransfer")); clearinghouse = Clearinghouse(envAddress("olympus.policies.Clearinghouse")); yieldRepo = YieldRepurchaseFacility(envAddress("olympus.policies.YieldRepurchaseFacility")); + reserveMigrator = ReserveMigrator(envAddress("olympus.policies.ReserveMigrator")); + emissionManager = EmissionManager(envAddress("olympus.policies.EmissionManager")); // Governance timelock = Timelock(payable(envAddress("olympus.governance.Timelock"))); @@ -533,7 +546,8 @@ contract OlympusDeploy is Script { console2.log(" callback", address(callback)); console2.log(" ohm", address(ohm)); console2.log(" reserve", address(reserve)); - console2.log(" wrappedReserve", address(wrappedReserve)); + console2.log(" sReserve", address(sReserve)); + console2.log(" oldReserve", address(oldReserve)); console2.log(" cushionDebtBuffer", cushionDebtBuffer); console2.log(" cushionDepositInterval", cushionDepositInterval); console2.log(" cushionDuration", cushionDuration); @@ -549,7 +563,7 @@ contract OlympusDeploy is Script { kernel, bondAuctioneer, callback, - [address(ohm), address(reserve), address(wrappedReserve)], + [address(ohm), address(reserve), address(sReserve), address(oldReserve)], configParams ); console2.log("Operator deployed at:", address(operator)); @@ -578,6 +592,8 @@ contract OlympusDeploy is Script { console2.log(" operator", address(operator)); console2.log(" zeroDistributor", address(zeroDistributor)); console2.log(" yieldRepo", address(yieldRepo)); + console2.log(" reserveMigrator", address(reserveMigrator)); + console2.log(" emissionManager", address(emissionManager)); console2.log(" maxReward", maxReward); console2.log(" auctionDuration", auctionDuration); @@ -588,6 +604,8 @@ contract OlympusDeploy is Script { operator, zeroDistributor, yieldRepo, + reserveMigrator, + emissionManager, maxReward, auctionDuration ); @@ -1006,7 +1024,7 @@ contract OlympusDeploy is Script { ohm_: address(ohm), gohm_: address(gohm), staking_: address(staking), - sdai_: address(wrappedReserve), + sReserve_: address(sReserve), coolerFactory_: address(coolerFactory), kernel_: address(kernel) }); @@ -1036,7 +1054,7 @@ contract OlympusDeploy is Script { // Print the arguments console2.log(" gOHM:", address(gohm)); - console2.log(" SDAI:", address(wrappedReserve)); + console2.log(" SDAI:", address(sReserve)); console2.log(" DAI:", address(reserve)); console2.log(" Collector:", collector); console2.log(" Fee Percentage:", feePercentage); @@ -1047,7 +1065,7 @@ contract OlympusDeploy is Script { vm.broadcast(); coolerUtils = new CoolerUtils( address(gohm), - address(wrappedReserve), + address(sReserve), address(reserve), owner, lender, @@ -1121,29 +1139,25 @@ contract OlympusDeploy is Script { // ========== YIELD REPURCHASE FACILITY ========== // - function _deployYieldRepurchaseFacility(bytes calldata args) public returns (address) { + function _deployYieldRepurchaseFacility(bytes calldata) public returns (address) { // No additional arguments for YieldRepurchaseFacility // Log dependencies console2.log("YieldRepurchaseFacility parameters:"); console2.log(" kernel", address(kernel)); console2.log(" ohm", address(ohm)); - console2.log(" reserve", address(reserve)); - console2.log(" wrappedReserve", address(wrappedReserve)); + console2.log(" sReserve", address(sReserve)); console2.log(" teller", address(bondFixedTermTeller)); console2.log(" auctioneer", address(bondAuctioneer)); - console2.log(" clearinghouse", address(clearinghouse)); // Deploy YieldRepurchaseFacility vm.broadcast(); yieldRepo = new YieldRepurchaseFacility( kernel, address(ohm), - address(reserve), - address(wrappedReserve), + address(sReserve), address(bondFixedTermTeller), - address(bondAuctioneer), - address(clearinghouse) + address(bondAuctioneer) ); console2.log("YieldRepurchaseFacility deployed at:", address(yieldRepo)); @@ -1151,6 +1165,64 @@ contract OlympusDeploy is Script { return address(yieldRepo); } + // ========== RESERVE MIGRATION ========== // + + function _deployReserveMigrator(bytes calldata) public returns (address) { + // No additional arguments for ReserveMigrator + + // Log dependencies + console2.log("ReserveMigrator parameters:"); + console2.log(" kernel", address(kernel)); + console2.log(" sFrom", address(oldSReserve)); + console2.log(" sTo", address(sReserve)); + console2.log(" migrator", address(externalMigrator)); + + // Deploy ReserveMigrator + vm.broadcast(); + reserveMigrator = new ReserveMigrator( + kernel, + address(oldSReserve), + address(sReserve), + address(externalMigrator) + ); + + console2.log("ReserveMigrator deployed at:", address(reserveMigrator)); + + return address(reserveMigrator); + } + + // ========== EMISSION MANAGER ========== // + + function _deployEmissionManager(bytes calldata) public returns (address) { + // No additional arguments for EmissionManager + + // Log dependencies + console2.log("EmissionManager parameters:"); + console2.log(" kernel", address(kernel)); + console2.log(" ohm", address(ohm)); + console2.log(" gohm", address(gohm)); + console2.log(" reserve", address(reserve)); + console2.log(" sReserve", address(sReserve)); + console2.log(" auctioneer", address(bondAuctioneer)); + console2.log(" teller", address(bondFixedTermTeller)); + + // Deploy EmissionManager + vm.broadcast(); + emissionManager = new EmissionManager( + kernel, + address(ohm), + address(gohm), + address(reserve), + address(sReserve), + address(bondAuctioneer), + address(bondFixedTermTeller) + ); + + console2.log("EmissionManager deployed at:", address(emissionManager)); + + return address(emissionManager); + } + // ========== VERIFICATION ========== // /// @dev Verifies that the environment variable addresses were set correctly following deployment @@ -1227,8 +1299,8 @@ contract OlympusDeploy is Script { kernel = Kernel(vm.envAddress("KERNEL")); /// Operator Roles - require(ROLES.hasRole(address(heart), "operator_operate")); - require(ROLES.hasRole(guardian_, "operator_operate")); + require(ROLES.hasRole(address(heart), "heart")); + require(ROLES.hasRole(guardian_, "heart")); require(ROLES.hasRole(address(callback), "operator_reporter")); require(ROLES.hasRole(policy_, "operator_policy")); require(ROLES.hasRole(guardian_, "operator_admin")); @@ -1274,8 +1346,8 @@ contract OlympusDeploy is Script { burner = Burner(vm.envAddress("BURNER")); /// Operator Roles - require(ROLES.hasRole(address(heart), "operator_operate")); - require(ROLES.hasRole(guardian_, "operator_operate")); + require(ROLES.hasRole(address(heart), "heart")); + require(ROLES.hasRole(guardian_, "heart")); require(ROLES.hasRole(address(callback), "operator_reporter")); require(ROLES.hasRole(policy_, "operator_policy")); require(ROLES.hasRole(guardian_, "operator_admin")); diff --git a/src/scripts/deploy/savedDeployments/usds_migration.json b/src/scripts/deploy/savedDeployments/usds_migration.json new file mode 100644 index 00000000..abd55c23 --- /dev/null +++ b/src/scripts/deploy/savedDeployments/usds_migration.json @@ -0,0 +1,40 @@ +{ + "sequence": [ + { + "name": "ReserveMigrator", + "args": {} + }, + { + "name": "YieldRepurchaseFacility", + "args": {} + }, + { + "name": "EmissionManager", + "args": {} + }, + { + "name": "Operator", + "args": { + "cushionDebtBuffer": 100000, + "cushionDepositInterval": 14400, + "cushionDuration": 259200, + "cushionFactor": 3000, + "regenObserve": 21, + "regenThreshold": 18, + "regenWait": 518400, + "reserveFactor": 62 + } + }, + { + "name": "Clearinghouse", + "args": {} + }, + { + "name": "OlympusHeart", + "args": { + "auctionDuration": 1200, + "maxReward": 40000000000 + } + } + ] +} \ No newline at end of file diff --git a/src/scripts/env.json b/src/scripts/env.json index 551a252e..00ddd36e 100644 --- a/src/scripts/env.json +++ b/src/scripts/env.json @@ -15,6 +15,8 @@ "tokens": { "DAI": "0x6B175474E89094C44Da98b954EedeAC495271d0F", "sDAI": "0x83F20F44975D03b1b09e64809B757c47f942BEeA", + "USDS": "0xdC035D45d973E3EC169d2276DDab16f1e407384F", + "sUSDS": "0xa3931d71877C0E7a3148CB7Eb4463524FEc27fbD", "WETH": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", "WSTETH": "0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0", "AURA": "0xC0c293ce456fF0ED870ADd98a0828Dd4d2903DBF", @@ -43,6 +45,9 @@ }, "cooler": { "CoolerFactory": "0x30Ce56e80aA96EbbA1E1a74bC5c0FEB5B0dB4216" + }, + "maker": { + "daiUsdsMigrator": "0x3225737a9Bbb6473CB4a45b7244ACa2BeFdB276A" } }, "olympus": { @@ -75,8 +80,8 @@ } }, "policies": { - "Operator": "0x0AE561226896dA978EaDA0Bec4a7d3CfAE04f506", - "OlympusHeart": "0x39F6AA3d445e6Dd8eC232c6Bd589889A88E3034d", + "Operator": "0x6417F206a0a6628Da136C0Faa39026d0134D2b52", + "OlympusHeart": "0xf7602C0421c283A2fc113172EBDf64C30F21654D", "BondCallback": "0x73df08CE9dcC8d74d22F23282c4d49F13b4c795E", "OlympusPriceConfig": "0xf6D5d06A4e8e6904E4360108749C177692F59E90", "RolesAdmin": "0xb216d714d91eeC4F7120a732c11428857C659eC8", @@ -91,11 +96,13 @@ "BLVaultLido": "0x7fdD4e808ee9608f1b2f05157A2A8098e3D432cD", "BLVaultManagerLusd": "0xF451c45C7a26e2248a0EA02382579Eb4858cAdA1", "BLVaultLusd": "0xfbB3742628e8D19E0E2d7D8dde208821C09dE960", - "Clearinghouse": "0xE6343ad0675C9b8D3f32679ae6aDbA0766A2ab4c", + "Clearinghouse": "0x1e094fE00E13Fd06D64EeA4FB3cD912893606fE0", "LegacyBurner": "0x367149cf2d04D3114fFD1Cc6b273222664908D0B", "CoolerUtils": "0xB15bcb1b6593d85890f5287Baa2245B8A29F464a", "pOLY": "0xb37796941cA55b7E4243841930C104Ee325Da5a1", - "YieldRepurchaseFacility": "0x30A967eB957E5B1eE053B75F1A57ea6bfb2e907E" + "YieldRepurchaseFacility": "0xcaA3d3E653A626e2656d2E799564fE952D39d855", + "ReserveMigrator": "0x986b99579BEc7B990331474b66CcDB94Fa2419F5", + "EmissionManager": "0x50f441a3387625bDA8B8081cE3fd6C04CC48C0A2" }, "legacy": { "OHM": "0x64aa3364F17a4D01c6f1751Fd97C2BD3D7e7f1D5", @@ -284,6 +291,8 @@ "tokens": { "DAI": "0x6B175474E89094C44Da98b954EedeAC495271d0F", "sDAI": "0x83F20F44975D03b1b09e64809B757c47f942BEeA", + "USDS": "0xdC035D45d973E3EC169d2276DDab16f1e407384F", + "sUSDS": "0xa3931d71877C0E7a3148CB7Eb4463524FEc27fbD", "WETH": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", "WSTETH": "0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0", "AURA": "0xC0c293ce456fF0ED870ADd98a0828Dd4d2903DBF", @@ -312,6 +321,9 @@ }, "cooler": { "CoolerFactory": "0x30Ce56e80aA96EbbA1E1a74bC5c0FEB5B0dB4216" + }, + "maker": { + "daiUsdsMigrator": "0x3225737a9Bbb6473CB4a45b7244ACa2BeFdB276A" } }, "olympus": { @@ -345,7 +357,7 @@ }, "policies": { "Operator": "0x0AE561226896dA978EaDA0Bec4a7d3CfAE04f506", - "OlympusHeart": "0xD5a0Ae3Bf7309416e70cB14399bDd508fE82C658", + "OlympusHeart": "0x39F6AA3d445e6Dd8eC232c6Bd589889A88E3034d", "BondCallback": "0x73df08CE9dcC8d74d22F23282c4d49F13b4c795E", "OlympusPriceConfig": "0xf6D5d06A4e8e6904E4360108749C177692F59E90", "RolesAdmin": "0xb216d714d91eeC4F7120a732c11428857C659eC8", @@ -364,7 +376,9 @@ "LegacyBurner": "0x367149cf2d04D3114fFD1Cc6b273222664908D0B", "CoolerUtils": "0xB15bcb1b6593d85890f5287Baa2245B8A29F464a", "pOLY": "0xb37796941cA55b7E4243841930C104Ee325Da5a1", - "YieldRepurchaseFacility": "0x0000000000000000000000000000000000000000" + "YieldRepurchaseFacility": "0x30A967eB957E5B1eE053B75F1A57ea6bfb2e907E", + "ReserveMigrator": "0x0000000000000000000000000000000000000000", + "EmissionManager": "0x0000000000000000000000000000000000000000" }, "legacy": { "OHM": "0x64aa3364F17a4D01c6f1751Fd97C2BD3D7e7f1D5", diff --git a/src/scripts/ops/batch.sh b/src/scripts/ops/batch.sh old mode 100644 new mode 100755 diff --git a/src/scripts/ops/batches/USDSMigration.sol b/src/scripts/ops/batches/USDSMigration.sol new file mode 100644 index 00000000..bca657cb --- /dev/null +++ b/src/scripts/ops/batches/USDSMigration.sol @@ -0,0 +1,214 @@ +// 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 "src/Kernel.sol"; + +// Bophades policies +import {YieldRepurchaseFacility} from "policies/YieldRepurchaseFacility.sol"; +import {OlympusHeart} from "policies/Heart.sol"; +import {Operator} from "policies/Operator.sol"; +import {Clearinghouse} from "policies/Clearinghouse.sol"; +import {ReserveMigrator} from "policies/ReserveMigrator.sol"; +import {BondCallback} from "policies/BondCallback.sol"; + +import {ERC20} from "solmate/tokens/ERC20.sol"; + +/// @notice +/// @dev Deactivates old heart, operator, yield repo, and clearinghouse contracts. +/// Installs new versions that references USDS instead of DAI. Also, adds the ReserveMigrator contract. +contract USDSMigration is OlyBatch { + // 1. Deactivate existing contracts that are being replaced locally (i.e. on the contract itself) - DAO MS + // + Clearinghouse v2 + // - Defund to send any reserves back to TRSRY + // - Deactivate + // + YieldRepurchaseFacility v1 + // - shutdown to send any reserves back to TRSRY + // - make sure that bond market is ended + // + Operator v1.4 + // - Deactivate to prevent swaps and bond markets + // + Heart v1.5 + // - Deactivate so it will not beat + + // 2. Deactivate policies that are being replaced on the Kernel - DAO MS + // + Clearinghouse v2 + // + YieldRepurchaseFacility v1 + // + Operator v1.4 + // + Heart v1.5 + + // 3. Activate new policies on the Kernel - DAO MS + // + Clearinghouse v2.1 + // + Operator v1.5 + // + YieldRepurchaseFacility v1.1 + // + ReserveMigrator v1 + // + Heart v1.6 + + // 4. Initialize new policies and update certain configs - DAO MS + // + Set Operator on BondCallback to Operator v1.5 + // + Set sUSDS as the wrapped token for USDS on BondCallback + // + Activate Clearinghouse v2.1 + // + Initialize YieldRepurchaseFacility v1.1 + + // TODO set these + uint256 initialReserveBalance = 0; + uint256 initialConversionRate = 0; + uint256 initialYield = 0; + + address kernel; + address oldHeart; + address oldOperator; + address oldYieldRepo; + address oldClearinghouse; + address newHeart; + address newOperator; + address newYieldRepo; + address newClearinghouse; + address reserveMigrator; + address emissionManager; + address bondCallback; + address usds; + address susds; + + function loadEnv() internal override { + // Load contract addresses from the environment file + kernel = envAddress("current", "olympus.Kernel"); + oldHeart = envAddress("last", "olympus.policies.OlympusHeart"); + oldOperator = envAddress("last", "olympus.policies.Operator"); + oldYieldRepo = envAddress("last", "olympus.policies.YieldRepurchaseFacility"); + oldClearinghouse = envAddress("last", "olympus.policies.Clearinghouse"); + newHeart = envAddress("current", "olympus.policies.OlympusHeart"); + newOperator = envAddress("current", "olympus.policies.Operator"); + newYieldRepo = envAddress("current", "olympus.policies.YieldRepurchaseFacility"); + newClearinghouse = envAddress("current", "olympus.policies.Clearinghouse"); + reserveMigrator = envAddress("current", "olympus.policies.ReserveMigrator"); + emissionManager = envAddress("current", "olympus.policies.EmissionManager"); + bondCallback = envAddress("current", "olympus.policies.BondCallback"); + usds = envAddress("current", "external.tokens.USDS"); + susds = envAddress("current", "external.tokens.sUSDS"); + } + + // Entry point for the script + function run(bool send_) external isDaoBatch(send_) { + // 1. Deactivate existing contracts that are being replaced locally + // 1a. Deactivate OlympusHeart + addToBatch(oldHeart, abi.encodeWithSelector(OlympusHeart.deactivate.selector)); + // 1b. Deactivate Operator + addToBatch(oldOperator, abi.encodeWithSelector(Operator.deactivate.selector)); + // 1c. Shutdown YieldRepurchaseFacility + ERC20[] memory tokensToTransfer = new ERC20[](2); + tokensToTransfer[0] = ERC20(envAddress("current", "external.tokens.DAI")); + tokensToTransfer[1] = ERC20(envAddress("current", "external.tokens.sDAI")); + addToBatch( + oldYieldRepo, + abi.encodeWithSelector(YieldRepurchaseFacility.shutdown.selector, tokensToTransfer) + ); + // 1d. Shutdown the old Clearinghouse + addToBatch( + oldClearinghouse, + abi.encodeWithSelector(Clearinghouse.emergencyShutdown.selector) + ); + + // 2. Deactivate policies that are being replaced on the Kernel + addToBatch( + kernel, + abi.encodeWithSelector( + Kernel.executeAction.selector, + Actions.DeactivatePolicy, + oldHeart + ) + ); + addToBatch( + kernel, + abi.encodeWithSelector( + Kernel.executeAction.selector, + Actions.DeactivatePolicy, + oldOperator + ) + ); + addToBatch( + kernel, + abi.encodeWithSelector( + Kernel.executeAction.selector, + Actions.DeactivatePolicy, + oldYieldRepo + ) + ); + addToBatch( + kernel, + abi.encodeWithSelector( + Kernel.executeAction.selector, + Actions.DeactivatePolicy, + oldClearinghouse + ) + ); + + // 3. Activate new policies on the Kernel + addToBatch( + kernel, + abi.encodeWithSelector( + Kernel.executeAction.selector, + Actions.ActivatePolicy, + newOperator + ) + ); + addToBatch( + kernel, + abi.encodeWithSelector( + Kernel.executeAction.selector, + Actions.ActivatePolicy, + newYieldRepo + ) + ); + addToBatch( + kernel, + abi.encodeWithSelector( + Kernel.executeAction.selector, + Actions.ActivatePolicy, + newClearinghouse + ) + ); + addToBatch( + kernel, + abi.encodeWithSelector( + Kernel.executeAction.selector, + Actions.ActivatePolicy, + reserveMigrator + ) + ); + addToBatch( + kernel, + abi.encodeWithSelector(Kernel.executeAction.selector, Actions.ActivatePolicy, newHeart) + ); + + // STEP 4: Policy initialization steps + // 4a. Set `BondCallback.operator()` to the new Operator policy + addToBatch( + bondCallback, + abi.encodeWithSelector(BondCallback.setOperator.selector, newOperator) + ); + + // 4b. Set sUSDS as the wrapped token for USDS on BondCallback + addToBatch( + bondCallback, + abi.encodeWithSelector(BondCallback.useWrappedVersion.selector, usds, susds) + ); + + // 4c. Activate the new Clearinghouse policy + addToBatch(newClearinghouse, abi.encodeWithSelector(Clearinghouse.activate.selector)); + + // 4d. Initialize the new YRF + addToBatch( + newYieldRepo, + abi.encodeWithSelector( + YieldRepurchaseFacility.initialize.selector, + initialReserveBalance, + initialConversionRate, + initialYield + ) + ); + } +} diff --git a/src/scripts/proposals/executeOnTestnet.sh b/src/scripts/proposals/executeOnTestnet.sh old mode 100644 new mode 100755 diff --git a/src/scripts/proposals/printInputs.sh b/src/scripts/proposals/printInputs.sh new file mode 100755 index 00000000..47507051 --- /dev/null +++ b/src/scripts/proposals/printInputs.sh @@ -0,0 +1,73 @@ +#!/bin/bash + +# This script prints the inputs for a proposal to the governor. +# +# Usage: src/scripts/proposals/printInputs.sh --file --contract --account --fork --env +# +# Environment variables: +# RPC_URL + +# Exit if any error occurs +set -e + +# Iterate through named arguments +# Source: https://unix.stackexchange.com/a/388038 +while [ $# -gt 0 ]; do + if [[ $1 == *"--"* ]]; then + v="${1/--/}" + declare $v="$2" + fi + + shift +done + +# Get the name of the .env file or use the default +ENV_FILE=${env:-".env"} +echo "Sourcing environment variables from $ENV_FILE" + +# Load environment file +set -a # Automatically export all variables +source $ENV_FILE +set +a # Disable automatic export + +# Apply defaults to command-line arguments +FORK=${fork:-false} + +# Check if the proposal file was specified +if [ -z "$file" ]; then + echo "Error: Proposal file was not specified" + exit 1 +fi + +# Check if the proposal file exists +if [ ! -f "$file" ]; then + echo "Error: Proposal file does not exist. Provide the correct relative path after the --file flag." + exit 1 +fi + +# Check if the contract name was specified +if [ -z "$contract" ]; then + echo "Error: Contract name was not specified" + exit 1 +fi + +# Check if the RPC_URL was specified +if [ -z "$RPC_URL" ]; then + echo "Error: RPC_URL was not specified" + exit 1 +fi + +echo "Using proposal contract: $file:$contract" +echo "Using RPC at URL: $RPC_URL" + +# Set the fork flag +FORK_FLAG="" +if [ "$FORK" = "true" ]; then + FORK_FLAG="--legacy" + echo "Fork: enabled" +else + echo "Fork: disabled" +fi + +# Run the forge script +forge script $file:$contract --sig "printProposalInputs()" -vvv --rpc-url $RPC_URL $FORK_FLAG diff --git a/src/scripts/proposals/submitProposal.sh b/src/scripts/proposals/submitProposal.sh old mode 100644 new mode 100755 diff --git a/src/test/mocks/MockDaiUsds.sol b/src/test/mocks/MockDaiUsds.sol new file mode 100644 index 00000000..7ab9e204 --- /dev/null +++ b/src/test/mocks/MockDaiUsds.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.0; + +import {MockERC20, ERC20} from "solmate/test/utils/mocks/MockERC20.sol"; +import {IDaiUsds} from "policies/ReserveMigrator.sol"; + +/// @dev This is a mock converter contract that trades one ERC20 token for another at a fixed 1:1 ratio +/// by minting and burning the tokens. It is based on the DaiUsds contract. +contract MockDaiUsds is IDaiUsds { + MockERC20 public dai; + MockERC20 public usds; + + constructor(MockERC20 dai_, MockERC20 usds_) { + dai = dai_; + usds = usds_; + } + + function daiToUsds(address usr, uint256 wad) external override { + dai.burn(usr, wad); + usds.mint(usr, wad); + } + + function usdsToDai(address usr, uint256 wad) external { + usds.burn(usr, wad); + dai.mint(usr, wad); + } +} diff --git a/src/test/mocks/MockEmissionManager.sol b/src/test/mocks/MockEmissionManager.sol new file mode 100644 index 00000000..bf11fc7a --- /dev/null +++ b/src/test/mocks/MockEmissionManager.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.0; + +import {IEmissionManager} from "../../policies/interfaces/IEmissionManager.sol"; + +contract MockEmissionManager is IEmissionManager { + constructor() {} + + function execute() external override { + // do nothing + } +} diff --git a/src/test/mocks/MockGohm.sol b/src/test/mocks/MockGohm.sol index bcf54dca..28a4587b 100644 --- a/src/test/mocks/MockGohm.sol +++ b/src/test/mocks/MockGohm.sol @@ -8,7 +8,7 @@ interface IDelegate { } contract MockGohm is MockERC20, IDelegate { - uint256 public constant index = 10000; + uint256 public constant index = 10000 * 1e9; address public delegatee; constructor( diff --git a/src/test/mocks/MockReserveMigrator.sol b/src/test/mocks/MockReserveMigrator.sol new file mode 100644 index 00000000..a4398315 --- /dev/null +++ b/src/test/mocks/MockReserveMigrator.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: AGPL-3.0 +pragma solidity ^0.8.0; + +import {IReserveMigrator} from "../../policies/interfaces/IReserveMigrator.sol"; + +contract MockReserveMigrator is IReserveMigrator { + constructor() {} + + function migrate() external override { + // do nothing + } +} diff --git a/src/test/policies/BondCallback.t.sol b/src/test/policies/BondCallback.t.sol index 8a7891cb..61d38e1e 100644 --- a/src/test/policies/BondCallback.t.sol +++ b/src/test/policies/BondCallback.t.sol @@ -66,6 +66,7 @@ contract BondCallbackTest is Test { MockERC4626 internal wrappedReserve; MockERC20 internal nakedReserve; MockERC20 internal other; + MockERC20 internal oldReserve; Kernel internal kernel; MockPrice internal PRICE; @@ -115,6 +116,7 @@ contract BondCallbackTest is Test { wrappedReserve = new MockERC4626(reserve, "Wrapped Reserve", "sRSV"); nakedReserve = new MockERC20("Naked Reserve", "nRSV", 18); other = new MockERC20("Other", "OTH", 18); + oldReserve = new MockERC20("Old Reserve", "oRSV", 18); } { @@ -154,7 +156,7 @@ contract BondCallbackTest is Test { kernel, IBondSDA(address(auctioneer)), callback, - [address(ohm), address(reserve), address(wrappedReserve)], + [address(ohm), address(reserve), address(wrappedReserve), address(oldReserve)], [ uint32(2000), // cushionFactor uint32(5 days), // duration @@ -196,7 +198,7 @@ contract BondCallbackTest is Test { /// Configure access control /// Operator ROLES - rolesAdmin.grantRole("operator_operate", guardian); + rolesAdmin.grantRole("heart", guardian); rolesAdmin.grantRole("operator_reporter", address(callback)); rolesAdmin.grantRole("operator_policy", policy); rolesAdmin.grantRole("operator_admin", guardian); diff --git a/src/test/policies/Clearinghouse.t.sol b/src/test/policies/Clearinghouse.t.sol index a65ffd2d..8a8a2c36 100644 --- a/src/test/policies/Clearinghouse.t.sol +++ b/src/test/policies/Clearinghouse.t.sol @@ -33,7 +33,7 @@ import {Clearinghouse, Cooler, CoolerFactory, CoolerCallback} from "policies/Cle // [X] Treasury approvals for the clearing house are correct. // [X] if necessary, sends excess DSR funds back to the Treasury. // [X] if a rebalances are missed, can execute several rebalances if FUND_CADENCE allows it. -// [X] sweepIntoDSR +// [X] sweepIntoSavingsVault // [X] excess DAI is deposited into DSR. // [X] defund // [X] only "cooler_overseer" can call. @@ -524,7 +524,7 @@ contract ClearinghouseTest is Test { // Mint 1 million to clearinghouse and sweep to simulate assets being repaid dai.mint(address(clearinghouse), oneMillion); - clearinghouse.sweepIntoDSR(); + clearinghouse.sweepIntoSavingsVault(); assertEq( sdai.maxWithdraw(address(clearinghouse)), @@ -604,12 +604,12 @@ contract ClearinghouseTest is Test { // --- SWEEP INTO DSR ------------------------------------------------ - function test_sweepIntoDSR() public { + function test_sweepIntoSavingsVault() public { uint256 sdaiBal = sdai.balanceOf(address(clearinghouse)); // Mint 1 million to clearinghouse and sweep to simulate assets being repaid dai.mint(address(clearinghouse), 1e24); - clearinghouse.sweepIntoDSR(); + clearinghouse.sweepIntoSavingsVault(); assertEq(sdai.balanceOf(address(clearinghouse)), sdaiBal + 1e24); } diff --git a/src/test/policies/EmissionManager.t.sol b/src/test/policies/EmissionManager.t.sol new file mode 100644 index 00000000..ec4bca17 --- /dev/null +++ b/src/test/policies/EmissionManager.t.sol @@ -0,0 +1,1738 @@ +// SPDX-License-Identifier: Unlicense +pragma solidity >=0.8.0; + +import {Test} from "forge-std/Test.sol"; +import {console2} from "forge-std/console2.sol"; +import {UserFactory} from "src/test/lib/UserFactory.sol"; + +import {BondFixedTermSDA} from "src/test/lib/bonds/BondFixedTermSDA.sol"; +import {BondAggregator} from "src/test/lib/bonds/BondAggregator.sol"; +import {BondFixedTermTeller} from "src/test/lib/bonds/BondFixedTermTeller.sol"; +import {RolesAuthority, Authority as SolmateAuthority} from "solmate/auth/authorities/RolesAuthority.sol"; + +import {MockERC20, ERC20} from "solmate/test/utils/mocks/MockERC20.sol"; +import {MockERC4626, ERC4626} from "solmate/test/utils/mocks/MockERC4626.sol"; +import {MockPrice} from "src/test/mocks/MockPrice.sol"; +import {MockOhm} from "src/test/mocks/MockOhm.sol"; +import {MockGohm} from "src/test/mocks/MockGohm.sol"; +import {MockClearinghouse} from "src/test/mocks/MockClearinghouse.sol"; + +import {IBondSDA} from "interfaces/IBondSDA.sol"; +import {IBondAggregator} from "interfaces/IBondAggregator.sol"; + +import {FullMath} from "libraries/FullMath.sol"; + +import "src/Kernel.sol"; +import {OlympusRange} from "modules/RANGE/OlympusRange.sol"; +import {OlympusTreasury} from "modules/TRSRY/OlympusTreasury.sol"; +import {OlympusMinter} from "modules/MINTR/OlympusMinter.sol"; +import {OlympusRoles} from "modules/ROLES/OlympusRoles.sol"; +import {OlympusClearinghouseRegistry} from "modules/CHREG/OlympusClearinghouseRegistry.sol"; +import {RolesAdmin} from "policies/RolesAdmin.sol"; +import {EmissionManager} from "policies/EmissionManager.sol"; + +// solhint-disable-next-line max-states-count +contract EmissionManagerTest is Test { + using FullMath for uint256; + + UserFactory public userCreator; + address internal alice; + address internal bob; + address internal heart; + address internal guardian; + + RolesAuthority internal auth; + BondAggregator internal aggregator; + BondFixedTermTeller internal teller; + BondFixedTermSDA internal auctioneer; + MockOhm internal ohm; + MockGohm internal gohm; + MockERC20 internal reserve; + MockERC4626 internal sReserve; + + Kernel internal kernel; + MockPrice internal PRICE; + OlympusRange internal RANGE; + OlympusTreasury internal TRSRY; + OlympusMinter internal MINTR; + OlympusRoles internal ROLES; + OlympusClearinghouseRegistry internal CHREG; + + MockClearinghouse internal clearinghouse; + RolesAdmin internal rolesAdmin; + EmissionManager internal emissionManager; + + // Emission manager values + uint256 internal baseEmissionRate = 1e6; // 0.1% at minimum premium + uint256 internal minimumPremium = 25e16; // 25% premium + uint256 internal backing = 10e18; + uint48 internal restartTimeframe = 1 days; + uint256 internal changeBy = 1e5; // 0.01% change per execution + uint48 internal changeDuration = 2; // 2 executions + + // test cases + // + // core functionality + // [X] execute + // [X] when not locally active + // [X] it returns without doing anything + // [X] when locally active + // [X] given the caller does not have the "heart" role + // [X] it reverts + // [X] it increments the beat counter modulo 3 + // [X] when beatCounter is incremented and != 0 + // [X] it returns without doing anything + // [X] when beatCounter is incremented and == 0 + // [X] when premium is greater than or equal to the minimum premium + // [X] sell amount is calculated as the base emissions rate * (1 + premium) / (1 + minimum premium) + // [X] it creates a new bond market with the sell amount + // [X] when premium is less than the minimum premium + // [X] it does not create a new bond market + // [X] when there is a positive emissions adjustment + // [X] it adjusts the emissions rate by the adjustment amount before calculating the sell amount + // [X] when there is a negative emissions adjustment + // [X] it adjusts the emissions rate by the adjustment amount before calculating the sell amount + // + // [X] callback unit tests + // [X] when the sender is not the teller + // [X] it reverts + // [X] when the sender is the teller + // [X] when the id parameter is not equal to the active market id + // [X] it reverts + // [X] when the id parameter is equal to the active market id + // [X] when the reserve balance of the contract is not atleast the input amount + // [X] it reverts + // [X] when the reserve balance of the contract is atleast the input amount + // [X] it updates the backing number, using the input amount as new reserves and the output amount as new supply + // [X] it mints the output amount of OHM to the teller + // [X] it deposits the reserve balance into the sReserve contract with the TRSRY as the recipient + // + // [X] execute -> callback (bond market purchase test) + // + // view functions + // [X] getSupply + // [X] returns the supply of gOHM in OHM + // [X] getReserves + // [X] returns the combined balance of the TSRSY and clearinghouses + // [X] getPremium + // [X] when price less than or equal to backing + // [X] it returns 0 + // [X] when price is greater than backing + // [X] it returns the (price - backing) / backing + // [X] getNextSale + // [X] when the premium is less than the minimum premium + // [X] it returns the premium, 0, and 0 + // [X] when the premium is greater than or equal to the minimum premium + // [X] it returns the premium, scaled emissions rate, and the emission amount for the sale + // + // emergency functions + // [X] shutdown + // [X] when the caller doesn't have emergency_shutdown role + // [X] it reverts + // [X] when the caller has emergency_shutdown role + // [X] it sets locallyActive to false + // [X] it sets the shutdown timestamp to the current block timestamp + // [ ] when the active market id is live + // [ ] it closes the market + // + // [X] restart + // [X] when the caller doesn't have emergency_restart role + // [X] it reverts + // [X] when the caller has emergency_restart role + // [X] when the restart timeframe has elapsed since shutdown + // [X] it reverts + // [X] when the restart timeframe has not elapsed since shutdown + // [X] it sets locallyActive to true + // + // admin functions + // [X] initialize + // [X] when the caller doesn't have emissions_admin role + // [X] it reverts + // [X] when the caller has emissions_admin role + // [X] when the contract is locally active + // [X] it reverts + // [X] when the restart timeframe has not passed since the last shutdown + // [X] it reverts + // [X] when the baseEmissionRate is zero + // [X] it reverts + // [X] when the minimumPremium is zero + // [X] it reverts + // [X] when the backing is zero + // [X] it reverts + // [X] when the restartTimeframe is zero + // [X] it reverts + // [X] it sets the baseEmissionRate + // [X] it sets the minimumPremium + // [X] it sets the backing + // [X] it sets the restartTimeframe + // [X] it sets locallyActive to true + // + // [X] changeBaseRate + // [X] when the caller doesn't have the emissions_admin role + // [X] it reverts + // [X] when the caller has the emissions_admin role + // [X] when a negative rate adjustment would result in an underflow + // [X] it reverts + // [X] when a positive rate adjustment would result in an overflow + // [X] it reverts + // [X] it sets the rateChange to changeBy, forNumBeats, and add parameters + // + // [X] setMinimumPremium + // [X] when the caller doesn't have the emissions_admin role + // [X] it reverts + // [X] when the caller has the emissions_admin role + // [X] when the new minimum premium is zero + // [X] it reverts + // [X] it sets the minimum premium + // + // [X] setBacking + // [X] when the caller doesn't have the emissions_admin role + // [X] it reverts + // [X] when the caller has the emissions_admin role + // [X] when the new backing is more than 10% lower than the current backing + // [X] it reverts + // [X] it sets the backing + // + // [X] setRestartTimeframe + // [X] when the caller doesn't have the emissions_admin role + // [X] it reverts + // [X] when the caller has the emissions_admin role + // [X] when the new restart timeframe is zero + // [X] it reverts + // [X] it sets the restart timeframe + // + // [X] setBondContracts + // [X] when the caller doesn't have the emissions_admin role + // [X] it reverts + // [X] when the caller has the emissions_admin role + // [X] when the new auctioneer address is the zero address + // [X] it reverts + // [X] when the new teller address is the zero address + // [X] it reverts + // [X] it sets the auctioneer address + // [X] it sets the teller address + + function setUp() public { + vm.warp(51 * 365 * 24 * 60 * 60); // Set timestamp at roughly Jan 1, 2021 (51 years since Unix epoch) + userCreator = new UserFactory(); + { + /// Deploy bond system to test against + address[] memory users = userCreator.create(4); + alice = users[0]; + bob = users[1]; + guardian = users[2]; + heart = users[3]; + auth = new RolesAuthority(guardian, SolmateAuthority(address(0))); + + /// Deploy the bond system + aggregator = new BondAggregator(guardian, auth); + teller = new BondFixedTermTeller(guardian, aggregator, guardian, auth); + auctioneer = new BondFixedTermSDA(teller, aggregator, guardian, auth); + + /// Register auctioneer on the bond system + vm.prank(guardian); + aggregator.registerAuctioneer(auctioneer); + } + + { + /// Deploy mock tokens + ohm = new MockOhm("Olympus", "OHM", 9); + gohm = new MockGohm("Gohm", "gOHM", 18); + reserve = new MockERC20("Reserve", "RSV", 18); + sReserve = new MockERC4626(reserve, "sReserve", "sRSV"); + } + + { + /// Deploy kernel + kernel = new Kernel(); // this contract will be the executor + + // Deploy mock clearinghouse + clearinghouse = new MockClearinghouse(address(reserve), address(sReserve)); + + /// Deploy modules (some mocks) + PRICE = new MockPrice(kernel, uint48(8 hours), 10 * 1e18); + CHREG = new OlympusClearinghouseRegistry( + kernel, + address(clearinghouse), + new address[](0) + ); + TRSRY = new OlympusTreasury(kernel); + MINTR = new OlympusMinter(kernel, address(ohm)); + ROLES = new OlympusRoles(kernel); + + /// Configure mocks + PRICE.setMovingAverage(13 * 1e18); + PRICE.setLastPrice(15 * 1e18); // + PRICE.setDecimals(18); + PRICE.setLastTime(uint48(block.timestamp)); + + /// Deploy ROLES administrator + rolesAdmin = new RolesAdmin(kernel); + + // Deploy the emission manager + emissionManager = new EmissionManager( + kernel, + address(ohm), + address(gohm), + address(reserve), + address(sReserve), + address(auctioneer), + address(teller) + ); + } + + { + /// Initialize system and kernel + + /// Install modules + kernel.executeAction(Actions.InstallModule, address(PRICE)); + kernel.executeAction(Actions.InstallModule, address(CHREG)); + kernel.executeAction(Actions.InstallModule, address(TRSRY)); + kernel.executeAction(Actions.InstallModule, address(MINTR)); + kernel.executeAction(Actions.InstallModule, address(ROLES)); + + /// Approve policies + kernel.executeAction(Actions.ActivatePolicy, address(emissionManager)); + kernel.executeAction(Actions.ActivatePolicy, address(rolesAdmin)); + } + { + /// Configure access control + + // Emission manager roles + rolesAdmin.grantRole("heart", heart); + rolesAdmin.grantRole("emissions_admin", guardian); + + // Emergency roles + rolesAdmin.grantRole("emergency_shutdown", guardian); + rolesAdmin.grantRole("emergency_restart", guardian); + } + + // Mint gOHM supply to test against + // Index is 10,000, therefore a total supply of 1,000 gOHM = 10,000,000 OHM + gohm.mint(address(this), 1_000 * 1e18); + + // Mint tokens to users, clearinghouse, and TRSRY for testing + uint256 testReserve = 1_000_000 * 1e18; + + reserve.mint(alice, testReserve); + reserve.mint(address(TRSRY), testReserve * 50); // $50M of reserves in TRSRY + + // Deposit TRSRY reserves into sReserve + vm.startPrank(address(TRSRY)); + reserve.approve(address(sReserve), testReserve * 50); + sReserve.deposit(testReserve * 50, address(TRSRY)); + vm.stopPrank(); + + // Approve the bond teller for the tokens to swap + vm.prank(alice); + reserve.approve(address(teller), testReserve); + + // Set principal receivables for the clearinghouse to $50M + clearinghouse.setPrincipalReceivables(uint256(50 * testReserve)); + + // Initialize the emissions manager + vm.prank(guardian); + emissionManager.initialize(baseEmissionRate, minimumPremium, backing, restartTimeframe); + + // Approve the emission manager to use a bond callback on the auctioneer + vm.prank(guardian); + auctioneer.setCallbackAuthStatus(address(emissionManager), true); + + // Total Reserves = $50M + $50 M = $100M + // Total Supply = 10,000,000 OHM + // => Backing = $100M / 10,000,000 OHM = $10 / OHM + // Price is set at $15 / OHM, so a 50% premium, which is above the 25% minimum premium + + // Emissions Rate is initially set to 0.1% of supply per day at the minimum premium + // This means the capacity of the initial bond market if premium == minimum premium + // is 0.1% * 10,000,000 OHM = 10,000 OHM + // For the case where the premium is 50%, then the capacity is: + // 10,000 OHM * (1 + 0.5) / (1 + 0.25) = 12,000 OHM + } + + // internal helper functions + modifier givenNextBeatIsZero() { + // Execute twice to get beat counter to 2 + vm.startPrank(heart); + emissionManager.execute(); + emissionManager.execute(); + vm.stopPrank(); + _; + } + + modifier givenPremiumEqualToMinimum() { + // Set the price to be exactly 25% above the backing + PRICE.setLastPrice(125 * 1e17); + _; + } + + modifier givenPremiumBelowMinimum() { + // Set the price below the minumum premium (20%) compared to 25% minimum premium + PRICE.setLastPrice(12 * 1e18); + _; + } + + modifier givenPremiumAboveMinimum() { + // Set the price above the minumum premium (50%) compared to 25% minimum premium + PRICE.setLastPrice(15 * 1e18); + _; + } + + modifier givenThereIsPreviousSale() { + // Execute three times to complete one cycle and create a market + triggerFullCycle(); + _; + } + + function triggerFullCycle() internal { + vm.startPrank(heart); + emissionManager.execute(); + emissionManager.execute(); + emissionManager.execute(); + vm.stopPrank(); + } + + modifier givenPositiveRateAdjustment() { + vm.prank(guardian); + emissionManager.changeBaseRate(changeBy, changeDuration, true); + _; + } + + modifier givenNegativeRateAdjustment() { + vm.prank(guardian); + emissionManager.changeBaseRate(changeBy, changeDuration, false); + _; + } + + modifier givenShutdown() { + vm.prank(guardian); + emissionManager.shutdown(); + _; + } + + modifier givenRestartTimeframeElapsed() { + vm.warp(block.timestamp + restartTimeframe); + _; + } + + // execute test cases + + function test_execute_whenNotLocallyActive_NothingHappens() public { + // Get the ID of the next bond market from the aggregator + uint256 nextBondMarketId = aggregator.marketCounter(); + + // Execute twice to get beat counter to 2 + vm.startPrank(heart); + emissionManager.execute(); + emissionManager.execute(); + vm.stopPrank(); + + // Check the beat counter is 2 + assertEq(emissionManager.beatCounter(), 2, "Beat counter should be 2"); + + // Check that a bond market was not created + assertEq(aggregator.marketCounter(), nextBondMarketId); + + // Check that the contract is locally active + assertTrue(emissionManager.locallyActive(), "Contract should be locally active"); + + // Deactivate the emission manager + vm.prank(guardian); + emissionManager.shutdown(); + + // Check that the contract is not locally active + assertFalse(emissionManager.locallyActive(), "Contract should not be locally active"); + + // Execute the emission manager + vm.startPrank(heart); + emissionManager.execute(); + + // Check that a bond market was not created + assertEq(aggregator.marketCounter(), nextBondMarketId); + + // Check that the beat counter did not increment + assertEq(emissionManager.beatCounter(), 2, "Beat counter should be 2"); + } + + function test_execute_withoutHeartRole_reverts() public { + // Call the function with the wrong caller + bytes memory err = abi.encodeWithSignature("ROLES_RequireRole(bytes32)", bytes32("heart")); + vm.expectRevert(err); + + // Call the function + vm.startPrank(guardian); + emissionManager.execute(); + } + + function test_execute_incrementsBeatCounterModulo3() public { + // Beat counter should be 0 + assertEq(emissionManager.beatCounter(), 0, "Beat counter should be 0"); + + // Execute once to get beat counter to 1 + vm.startPrank(heart); + emissionManager.execute(); + + // Check that the beat counter is 1 + assertEq(emissionManager.beatCounter(), 1, "Beat counter should be 1"); + + // Execute again to get beat counter to 2 + vm.startPrank(heart); + emissionManager.execute(); + + // Check that the beat counter is 2 + assertEq(emissionManager.beatCounter(), 2, "Beat counter should be 2"); + + // Execute again to get beat counter to 0 (wraps around) + vm.startPrank(heart); + emissionManager.execute(); + + // Check that the beat counter is 0 + assertEq(emissionManager.beatCounter(), 0, "Beat counter should be 0"); + } + + function test_execute_whenNextBeatNotZero_incrementsCounter() public { + // Get the ID of the next bond market from the aggregator + uint256 nextBondMarketId = aggregator.marketCounter(); + + // Check that the beat counter is initially 0 + assertEq(emissionManager.beatCounter(), 0, "Beat counter should be 0"); + + // Execute once to get beat counter to 1 + vm.startPrank(heart); + emissionManager.execute(); + + // Check that a bond market was not created + assertEq(aggregator.marketCounter(), nextBondMarketId); + + // Check the beat counter is 1 + assertEq(emissionManager.beatCounter(), 1, "Beat counter should be 1"); + + // Execute the emission manager + vm.startPrank(heart); + emissionManager.execute(); + + // Check that a bond market was not created + assertEq(aggregator.marketCounter(), nextBondMarketId); + + // Check that the beat counter is now 2 + assertEq(emissionManager.beatCounter(), 2, "Beat counter should be 2"); + } + + function test_execute_whenNextBeatIsZero_whenPremiumBelowMinimum_whenNoAdjustment() + public + givenNextBeatIsZero + givenPremiumBelowMinimum + { + // Get the ID of the next bond market from the aggregator + uint256 nextBondMarketId = aggregator.marketCounter(); + + // Confirm that there are no tokens in the contract yet + assertEq(ohm.balanceOf(address(emissionManager)), 0, "OHM balance should be 0"); + assertEq(reserve.balanceOf(address(emissionManager)), 0, "Reserve balance should be 0"); + + // Check that the beat counter is 2 + assertEq(emissionManager.beatCounter(), 2, "Beat counter should be 2"); + + // Call execute + vm.prank(heart); + emissionManager.execute(); + + // Check that a bond market was not created + assertEq(aggregator.marketCounter(), nextBondMarketId); + + // Confirm that the token balances are still 0 + assertEq(ohm.balanceOf(address(emissionManager)), 0, "OHM balance should be 0"); + assertEq(reserve.balanceOf(address(emissionManager)), 0, "Reserve balance should be 0"); + + // Confirm that the beat counter is now 0 + assertEq(emissionManager.beatCounter(), 0, "Beat counter should be 0"); + } + + function test_execute_whenNextBeatIsZero_whenPremiumEqualMinimum_whenNoAdjustment() + public + givenNextBeatIsZero + givenPremiumEqualToMinimum + { + // Get the ID of the next bond market from the aggregator + uint256 nextBondMarketId = aggregator.marketCounter(); + + // Confirm that there are no tokens in the contract yet + assertEq(ohm.balanceOf(address(emissionManager)), 0, "OHM balance should be 0"); + assertEq(reserve.balanceOf(address(emissionManager)), 0, "Reserve balance should be 0"); + + // Confirm that mint approval is originally zero + assertEq(MINTR.mintApproval(address(emissionManager)), 0, "Mint approval should be 0"); + + // Check that the beat counter is 2 + assertEq(emissionManager.beatCounter(), 2, "Beat counter should be 2"); + + // Call execute + vm.prank(heart); + emissionManager.execute(); + + // Check that a bond market was created + assertEq(aggregator.marketCounter(), nextBondMarketId + 1); + + // Confirm that the beat counter is now 0 + assertEq(emissionManager.beatCounter(), 0, "Beat counter should be 0"); + + // Verify the bond market parameters + // Check that the market params are correct + { + uint256 marketPrice = auctioneer.marketPrice(nextBondMarketId); + ( + address owner, + ERC20 payoutToken, + ERC20 quoteToken, + address callbackAddr, + bool isCapacityInQuote, + uint256 capacity, + , + uint256 minPrice, + uint256 maxPayout, + , + , + uint256 scale + ) = auctioneer.markets(nextBondMarketId); + + assertEq(owner, address(emissionManager), "Owner"); + assertEq(address(payoutToken), address(ohm), "Payout token"); + assertEq(address(quoteToken), address(reserve), "Quote token"); + assertEq( + callbackAddr, + address(emissionManager), + "Callback address should be the emissions manager" + ); + assertEq(isCapacityInQuote, false, "Capacity should not be in quote token"); + assertEq( + capacity, + (((baseEmissionRate * PRICE.getLastPrice()) / + ((backing * (1e18 + minimumPremium)) / 1e18)) * + gohm.totalSupply() * + gohm.index()) / 1e27, + "Capacity" + ); + assertEq(maxPayout, capacity / 6, "Max payout"); + + assertEq(scale, 10 ** uint8(36 + 9 - 18 + 0), "Scale"); + assertEq( + marketPrice, + (PRICE.getLastPrice() * 10 ** uint8(36 - 1)) / 10 ** uint8(18 - 1), + "Market price" + ); + assertEq( + minPrice, + (((backing * (1e18 + minimumPremium)) / 1e18) * 10 ** uint8(36 - 1)) / + 10 ** uint8(18 - 1), + "Min price" + ); + + // Confirm token balances are still zero since the callback will minting and receiving tokens + assertEq(ohm.balanceOf(address(emissionManager)), 0, "OHM balance should be 0"); + assertEq(reserve.balanceOf(address(emissionManager)), 0, "Reserve balance should be 0"); + + // Confirm that the emissions manager has mint approval for the capacity + assertEq( + MINTR.mintApproval(address(emissionManager)), + capacity, + "Mint approval should be the capacity" + ); + } + } + + function test_execute_whenNextBeatIsZero_givenPremiumAboveMinimum_whenNoAdjustment() + public + givenNextBeatIsZero + givenPremiumAboveMinimum + { + // Get the ID of the next bond market from the aggregator + uint256 nextBondMarketId = aggregator.marketCounter(); + + // Confirm that there are no tokens in the contract yet + assertEq(ohm.balanceOf(address(emissionManager)), 0, "OHM balance should be 0"); + assertEq(reserve.balanceOf(address(emissionManager)), 0, "Reserve balance should be 0"); + + // Confirm that mint approval is originally zero + assertEq(MINTR.mintApproval(address(emissionManager)), 0, "Mint approval should be 0"); + + // Check that the beat counter is 2 + assertEq(emissionManager.beatCounter(), 2, "Beat counter should be 2"); + + // Call execute + vm.prank(heart); + emissionManager.execute(); + + // Check that a bond market was created + assertEq(aggregator.marketCounter(), nextBondMarketId + 1); + + // Confirm that the beat counter is now 0 + assertEq(emissionManager.beatCounter(), 0, "Beat counter should be 0"); + + // Verify the bond market parameters + // Check that the market params are correct + { + uint256 marketPrice = auctioneer.marketPrice(nextBondMarketId); + ( + address owner, + ERC20 payoutToken, + ERC20 quoteToken, + address callbackAddr, + bool isCapacityInQuote, + uint256 capacity, + , + uint256 minPrice, + uint256 maxPayout, + , + , + uint256 scale + ) = auctioneer.markets(nextBondMarketId); + + assertEq(owner, address(emissionManager), "Owner"); + assertEq(address(payoutToken), address(ohm), "Payout token"); + assertEq(address(quoteToken), address(reserve), "Quote token"); + assertEq( + callbackAddr, + address(emissionManager), + "Callback address should be the emissions manager" + ); + assertEq(isCapacityInQuote, false, "Capacity should not be in quote token"); + assertEq( + capacity, + (((baseEmissionRate * PRICE.getLastPrice()) / + ((backing * (1e18 + minimumPremium)) / 1e18)) * + gohm.totalSupply() * + gohm.index()) / 1e27, + "Capacity" + ); + assertEq(maxPayout, capacity / 6, "Max payout"); + + assertEq(scale, 10 ** uint8(36 + 9 - 18 + 0), "Scale"); + assertEq( + marketPrice, + (PRICE.getLastPrice() * 10 ** uint8(36 - 1)) / 10 ** uint8(18 - 1), + "Market price" + ); + assertEq( + minPrice, + (((backing * (1e18 + minimumPremium)) / 1e18) * 10 ** uint8(36 - 1)) / + 10 ** uint8(18 - 1), + "Min price" + ); + + // Confirm token balances are still zero since the callback will minting and receiving tokens + assertEq(ohm.balanceOf(address(emissionManager)), 0, "OHM balance should be 0"); + assertEq(reserve.balanceOf(address(emissionManager)), 0, "Reserve balance should be 0"); + + // Confirm that the emissions manager has mint approval for the capacity + assertEq( + MINTR.mintApproval(address(emissionManager)), + capacity, + "Mint approval should be the capacity" + ); + } + } + + function test_execute_whenNextBeatIsZero_whenPositiveRateAdjustment() + public + givenNextBeatIsZero + givenPositiveRateAdjustment + { + // Cache the current base rate + uint256 baseRate = emissionManager.baseEmissionRate(); + + // Calculate the expected base rate after the adjustment + uint256 expectedBaseRate = baseRate + changeBy; + + // Get the ID of the next bond market from the aggregator + uint256 nextBondMarketId = aggregator.marketCounter(); + + // Calculate the expected capacity of the bond market + uint256 expectedCapacity = (((expectedBaseRate * PRICE.getLastPrice()) / + ((backing * (1e18 + minimumPremium)) / 1e18)) * + gohm.totalSupply() * + gohm.index()) / 1e27; + + // Execute to trigger the rate adjustment + vm.prank(heart); + emissionManager.execute(); + + // Confirm the base rate has been updated + assertEq( + emissionManager.baseEmissionRate(), + expectedBaseRate, + "Base rate should be updated" + ); + + // Confirm that the capacity of the bond market uses the new base rate + assertEq( + auctioneer.currentCapacity(nextBondMarketId), + expectedCapacity, + "Capacity should be updated" + ); + + // Calculate the expected base rate after the next adjustment + expectedBaseRate += changeBy; + + // Trigger a full cycle to make the next adjustment + triggerFullCycle(); + + // Confirm that the base rate has been updated + assertEq( + emissionManager.baseEmissionRate(), + expectedBaseRate, + "Base rate should be updated" + ); + + // Trigger a full cycle again. There should be no adjustment this time since it uses a duration of 2 + triggerFullCycle(); + + // Confirm that the base rate has not been updated + assertEq( + emissionManager.baseEmissionRate(), + expectedBaseRate, + "Base rate should not be updated" + ); + } + + function test_execute_whenNextBeatIsZero_whenNegativeRateAdjustment() + public + givenNextBeatIsZero + givenNegativeRateAdjustment + { + // Cache the current base rate + uint256 baseRate = emissionManager.baseEmissionRate(); + + // Calculate the expected base rate after the adjustment + uint256 expectedBaseRate = baseRate - changeBy; + + // Get the ID of the next bond market from the aggregator + uint256 nextBondMarketId = aggregator.marketCounter(); + + // Calculate the expected capacity of the bond market + uint256 expectedCapacity = (((expectedBaseRate * PRICE.getLastPrice()) / + ((backing * (1e18 + minimumPremium)) / 1e18)) * + gohm.totalSupply() * + gohm.index()) / 1e27; + + // Execute to trigger the rate adjustment + vm.prank(heart); + emissionManager.execute(); + + // Confirm the base rate has been updated + assertEq( + emissionManager.baseEmissionRate(), + expectedBaseRate, + "Base rate should be updated" + ); + + // Confirm that the capacity of the bond market uses the new base rate + assertEq( + auctioneer.currentCapacity(nextBondMarketId), + expectedCapacity, + "Capacity should be updated" + ); + + // Calculate the expected base rate after the next adjustment + expectedBaseRate -= changeBy; + + // Trigger a full cycle to make the next adjustment + triggerFullCycle(); + + // Confirm that the base rate has been updated + assertEq( + emissionManager.baseEmissionRate(), + expectedBaseRate, + "Base rate should be updated" + ); + + // Trigger a full cycle again. There should be no adjustment this time since it uses a duration of 2 + triggerFullCycle(); + + // Confirm that the base rate has not been updated + assertEq( + emissionManager.baseEmissionRate(), + expectedBaseRate, + "Base rate should not be updated" + ); + } + + // callback test cases + + function test_callback_whenSenderNotTeller_reverts() public { + // Call the callback function with the wrong sender + bytes memory err = abi.encodeWithSignature("OnlyTeller()"); + vm.prank(alice); + vm.expectRevert(err); + emissionManager.callback(0, 0, 0); + } + + function test_callback_whenIdNotActiveMarket_reverts(uint256 id_) public { + // Active market ID is originally 0 + assertEq(emissionManager.activeMarketId(), 0, "Active market ID should be 0"); + + vm.assume(id_ != 0); + + // Call the callback function with the wrong ID + bytes memory err = abi.encodeWithSignature("InvalidMarket()"); + vm.expectRevert(err); + vm.prank(address(teller)); + emissionManager.callback(id_, 0, 0); + } + + function test_callback_whenActiveMarketIdNotZero_whenIdNotActiveMarket_reverts( + uint256 id_ + ) public { + // Trigger two sales so that the active market ID is 1 + triggerFullCycle(); + triggerFullCycle(); + + // Active market ID is 1 + assertEq(emissionManager.activeMarketId(), 1, "Active market ID should be 0"); + + vm.assume(id_ != 1); + + // Call the callback function with the wrong ID + bytes memory err = abi.encodeWithSignature("InvalidMarket()"); + vm.expectRevert(err); + vm.prank(address(teller)); + emissionManager.callback(id_, 0, 0); + } + + function test_callback_whenReserveBalanceLessThanInput_reverts( + uint128 balance_, + uint128 input_ + ) public { + // Active market ID is originally 0 + assertEq(emissionManager.activeMarketId(), 0, "Active market ID should be 0"); + + // Assume that the balance is less than the input amount + // We cap these values to 2^128 - 1 to avoid overflow for practical purposes and to avoid random overflows with minting + vm.assume(balance_ < input_); + uint256 balance = uint256(balance_); + uint256 input = uint256(input_); + + // Mint the balance to the emissions manager + reserve.mint(address(emissionManager), balance); + + // Call the callback function with the wrong ID + bytes memory err = abi.encodeWithSignature("InvalidCallback()"); + vm.expectRevert(err); + vm.prank(address(teller)); + emissionManager.callback(0, input, 0); + } + + function test_callback_success(uint128 input_, uint128 output_) public { + // Active market ID is originally 0 + assertEq(emissionManager.activeMarketId(), 0, "Active market ID should be 0"); + + // We cap these values to 2^128 - 1 to avoid overflow for practical purposes and to avoid random overflows with minting + uint256 input = uint256(input_); + uint256 output = uint256(output_); + + vm.assume(input != 0 && output != 0); + + // Give the emissions manager mint approval for the output + // We will test that it functions within its mint limit granted in `execute` later + // This is strictly for the unit testing of the callback function + vm.prank(address(emissionManager)); + MINTR.increaseMintApproval(address(emissionManager), output); + + // Mint the input amount to the emissions manager + reserve.mint(address(emissionManager), input); + + // Cache the initial OHM balance of the teller and the sReserve balance of the TRSRY + uint256 tellerBalance = ohm.balanceOf(address(teller)); + uint256 treasuryBalance = sReserve.balanceOf(address(TRSRY)); + + // Cache the current backing value in the emissions manager + uint256 _backing = emissionManager.backing(); + + // Cache the reserves and supply values for the backing update calculation + uint256 reserves = emissionManager.getReserves(); + uint256 supply = emissionManager.getSupply(); + + uint256 expectedBacking = (_backing * (((input + reserves) * 1e18) / reserves)) / + (((output + supply) * 1e18) / supply); + + // Call the callback function + vm.prank(address(teller)); + emissionManager.callback(0, input, output); + + // Check that the backing has been updated + assertEq(emissionManager.backing(), expectedBacking, "Backing should be updated"); + + // Check that the output amount of OHM has been minted to the teller + assertEq( + ohm.balanceOf(address(teller)), + tellerBalance + output, + "Teller OHM balance should be updated" + ); + + // Check that the input amount of reserves have been wrapped and deposited into the treasury + assertEq( + sReserve.balanceOf(address(TRSRY)), + treasuryBalance + input, // can use the reserve amount as the sReserve amount since the conversion rate is 1:1 + "TRSRY wrapped reserve balance should be updated" + ); + } + + // execute -> callback (full cycle bond purchase) tests + + function test_executeCallback_success() public givenNextBeatIsZero { + // Change the price to 20 reserve per OHM for easier math + PRICE.setLastPrice(20 * 1e18); + + // Cache the next bond market id + uint256 nextBondMarketId = aggregator.marketCounter(); + + // Call execute to create the bond market + vm.prank(heart); + emissionManager.execute(); + + // Store initial balances + uint256 aliceOhmBalance = ohm.balanceOf(alice); + uint256 aliceReserveBalance = reserve.balanceOf(alice); + uint256 treasuryWrappedReserveBalance = sReserve.balanceOf(address(TRSRY)); + uint256 ohmSupply = ohm.totalSupply(); + + // Store initial backing value + uint256 bidAmount = 1000e18; + uint256 expectedPayout = auctioneer.payoutFor(bidAmount, nextBondMarketId, address(0)); + uint256 expectedBacking; + { + uint256 reserves = emissionManager.getReserves(); + uint256 supply = emissionManager.getSupply(); + expectedBacking = + (emissionManager.backing() * (((reserves + bidAmount) * 1e18) / reserves)) / + (((supply + expectedPayout) * 1e18) / supply); + } + + // Purchase a bond from the market + + vm.prank(alice); + teller.purchase(alice, address(0), nextBondMarketId, bidAmount, expectedPayout); + + // Confirm the balance changes + assertEq( + ohm.balanceOf(alice), + aliceOhmBalance + expectedPayout, + "OHM balance should be updated" + ); + assertEq( + reserve.balanceOf(alice), + aliceReserveBalance - bidAmount, + "Reserve balance should be updated" + ); + assertEq( + sReserve.balanceOf(address(TRSRY)), + treasuryWrappedReserveBalance + bidAmount, + "TRSRY wrapped reserve balance should be updated" + ); + assertEq( + ohm.totalSupply(), + ohmSupply + expectedPayout, + "OHM total supply should be updated" + ); + + // Confirm the backing has been updated + assertEq(emissionManager.backing(), expectedBacking, "Backing should be updated"); + } + + // shutdown tests + + function test_shutdown_whenCallerNotEmergencyShutdownRole_reverts(address rando_) public { + vm.assume(rando_ != guardian); + + // Call the shutdown function with the wrong caller + bytes memory err = abi.encodeWithSignature( + "ROLES_RequireRole(bytes32)", + bytes32("emergency_shutdown") + ); + vm.expectRevert(err); + vm.prank(rando_); + emissionManager.shutdown(); + } + + function test_shutdown_success() public { + // Check that the contract is locally active + assertTrue(emissionManager.locallyActive(), "Contract should be locally active"); + + // Check that the shutdown timestamp is 0 + assertEq(emissionManager.shutdownTimestamp(), 0, "Shutdown timestamp should be 0"); + + // Confirm that the block timestamp is not 0 + assertGt(block.timestamp, 0, "Block timestamp should not be 0"); + + // Call the shutdown function as guardian (which has the emergency_shutdown role) + vm.prank(guardian); + emissionManager.shutdown(); + + // Check that the contract is not locally active + assertFalse(emissionManager.locallyActive(), "Contract should not be locally active"); + + // Check that the shutdown timestamp is set to the current block timestamp + assertEq( + emissionManager.shutdownTimestamp(), + block.timestamp, + "Shutdown timestamp should be set" + ); + } + + function test_shutdown_whenMarketIsActive_closesMarket() + public + givenPremiumEqualToMinimum + givenThereIsPreviousSale + { + // We created a market, confirm it is active + uint256 id = emissionManager.activeMarketId(); + assertTrue(auctioneer.isLive(id)); + + // Check that the contract is locally active + assertTrue(emissionManager.locallyActive(), "Contract should be locally active"); + + // Check that the shutdown timestamp is 0 + assertEq(emissionManager.shutdownTimestamp(), 0, "Shutdown timestamp should be 0"); + + // Confirm that the block timestamp is not 0 + assertGt(block.timestamp, 0, "Block timestamp should not be 0"); + + // Call the shutdown function as guardian (which has the emergency_shutdown role) + vm.prank(guardian); + emissionManager.shutdown(); + + // Check that the contract is not locally active + assertFalse(emissionManager.locallyActive(), "Contract should not be locally active"); + + // Check that the shutdown timestamp is set to the current block timestamp + assertEq( + emissionManager.shutdownTimestamp(), + block.timestamp, + "Shutdown timestamp should be set" + ); + + // Check that the market is no longer active + assertFalse(auctioneer.isLive(id)); + } + + // restart tests + + function test_restart_whenCallerNotEmergencyRestartRole_reverts(address rando_) public { + vm.assume(rando_ != guardian); + + // Emissions Manager is currently locally active + // Call the restart function with the wrong caller + bytes memory err = abi.encodeWithSignature( + "ROLES_RequireRole(bytes32)", + bytes32("emergency_restart") + ); + vm.expectRevert(err); + vm.prank(rando_); + emissionManager.restart(); + + // Shutdown the emissions manager with the guardian + vm.prank(guardian); + emissionManager.shutdown(); + + // Emissions Manager is currently locally inactive + // Try to call restart again with the wrong caller + vm.expectRevert(err); + vm.prank(rando_); + emissionManager.restart(); + } + + function test_restart_whenRestartTimeElapsed_reverts(uint48 elapsed_) public givenShutdown { + assertFalse(emissionManager.locallyActive(), "Contract should not be locally active"); + + // Get the restart timeframe and the last shutdown timestamp + uint48 shutdownTimestamp = emissionManager.shutdownTimestamp(); + uint48 restartTimeframe_ = emissionManager.restartTimeframe(); + + vm.assume(elapsed_ <= type(uint48).max - shutdownTimestamp - restartTimeframe_); + + // Warp time to the restart timeframe plus some elapsed time (potentially 0) + vm.warp(shutdownTimestamp + restartTimeframe_ + elapsed_); + + // Try to restart the emissions manager with guardian, expect revert + bytes memory err = abi.encodeWithSignature("RestartTimeframePassed()"); + vm.expectRevert(err); + vm.prank(guardian); + emissionManager.restart(); + } + + function test_restart_whenRestartTimeFrameNotElapsed_success( + uint48 elapsed_ + ) public givenShutdown { + assertFalse(emissionManager.locallyActive(), "Contract should not be locally active"); + + // Get the restart timeframe and the last shutdown timestamp + uint48 shutdownTimestamp = emissionManager.shutdownTimestamp(); + uint48 restartTimeframe_ = emissionManager.restartTimeframe(); + + // Set the elapsed time to be less than the restart timeframe + uint48 elapsed = elapsed_ % restartTimeframe_; + + // Warp forward the elapsed time + vm.warp(shutdownTimestamp + elapsed); + + // Restart the emissions manager with guardian + vm.prank(guardian); + emissionManager.restart(); + + assertTrue(emissionManager.locallyActive(), "Contract should be locally active"); + } + + // initialize tests + + function test_initialize_whenCallerNotEmissionsAdmin_reverts(address rando_) public { + vm.assume(rando_ != guardian); + + // Call the initialize function with the wrong caller + bytes memory err = abi.encodeWithSignature( + "ROLES_RequireRole(bytes32)", + bytes32("emissions_admin") + ); + vm.expectRevert(err); + vm.prank(rando_); + emissionManager.initialize(baseEmissionRate, minimumPremium, backing, restartTimeframe); + } + + function test_initialize_whenAlreadyActive_reverts() public { + // Call the initialize function with the wrong caller + bytes memory err = abi.encodeWithSignature("AlreadyActive()"); + vm.expectRevert(err); + vm.prank(guardian); + emissionManager.initialize(baseEmissionRate, minimumPremium, backing, restartTimeframe); + } + + function test_initialize_whenRestartTimeframeNotElapsed_reverts( + uint48 elapsed_ + ) public givenShutdown { + assertFalse(emissionManager.locallyActive(), "Contract should not be locally active"); + + // Get the restart timeframe and the last shutdown timestamp + uint48 shutdownTimestamp = emissionManager.shutdownTimestamp(); + uint48 restartTimeframe_ = emissionManager.restartTimeframe(); + + // Set the elapsed time to be less than the restart timeframe + uint48 elapsed = elapsed_ % restartTimeframe_; + + // Warp forward the elapsed time + vm.warp(shutdownTimestamp + elapsed); + + // Try to initialize the emissions manager with guardian, expect revert + bytes memory err = abi.encodeWithSignature( + "CannotRestartYet(uint48)", + shutdownTimestamp + restartTimeframe_ + ); + vm.expectRevert(err); + vm.prank(guardian); + emissionManager.initialize(baseEmissionRate, minimumPremium, backing, restartTimeframe); + } + + function test_initialize_whenBaseEmissionRateZero_reverts() + public + givenShutdown + givenRestartTimeframeElapsed + { + assertFalse(emissionManager.locallyActive(), "Contract should not be locally active"); + + // Try to initialize the emissions manager with guardian, expect revert + bytes memory err = abi.encodeWithSignature("InvalidParam(string)", "baseEmissionRate"); + vm.expectRevert(err); + vm.prank(guardian); + emissionManager.initialize(0, minimumPremium, backing, restartTimeframe); + } + + function test_initialize_whenMinimumPremiumZero_reverts() + public + givenShutdown + givenRestartTimeframeElapsed + { + assertFalse(emissionManager.locallyActive(), "Contract should not be locally active"); + + // Try to initialize the emissions manager with guardian, expect revert + bytes memory err = abi.encodeWithSignature("InvalidParam(string)", "minimumPremium"); + vm.expectRevert(err); + vm.prank(guardian); + emissionManager.initialize(baseEmissionRate, 0, backing, restartTimeframe); + } + + function test_initialize_whenBackingZero_reverts() + public + givenShutdown + givenRestartTimeframeElapsed + { + assertFalse(emissionManager.locallyActive(), "Contract should not be locally active"); + + // Try to initialize the emissions manager with guardian, expect revert + bytes memory err = abi.encodeWithSignature("InvalidParam(string)", "backing"); + vm.expectRevert(err); + vm.prank(guardian); + emissionManager.initialize(baseEmissionRate, minimumPremium, 0, restartTimeframe); + } + + function test_initialize_whenRestartTimeframeZero_reverts() + public + givenShutdown + givenRestartTimeframeElapsed + { + assertFalse(emissionManager.locallyActive(), "Contract should not be locally active"); + + // Try to initialize the emissions manager with guardian, expect revert + bytes memory err = abi.encodeWithSignature("InvalidParam(string)", "restartTimeframe"); + vm.expectRevert(err); + vm.prank(guardian); + emissionManager.initialize(baseEmissionRate, minimumPremium, backing, 0); + } + + function test_initialize_success() public givenShutdown givenRestartTimeframeElapsed { + assertFalse(emissionManager.locallyActive(), "Contract should not be locally active"); + + // Values are currently as setup + + // Initialize the emissions manager with guardian using new values + vm.prank(guardian); + emissionManager.initialize( + baseEmissionRate + 1, + minimumPremium + 1, + backing + 1, + restartTimeframe + 1 + ); + + // Check that the contract is locally active + assertTrue(emissionManager.locallyActive(), "Contract should be locally active"); + assertEq( + emissionManager.baseEmissionRate(), + baseEmissionRate + 1, + "Base emission rate should be updated" + ); + assertEq( + emissionManager.minimumPremium(), + minimumPremium + 1, + "Minimum premium should be updated" + ); + assertEq(emissionManager.backing(), backing + 1, "Backing should be updated"); + assertEq( + emissionManager.restartTimeframe(), + restartTimeframe + 1, + "Restart timeframe should be updated" + ); + } + + // changeBaseRate tests + + function test_changeBaseRate_whenCallerNotEmissionsAdmin_reverts(address rando_) public { + vm.assume(rando_ != guardian); + + // Call the changeBaseRate function with the wrong caller + bytes memory err = abi.encodeWithSignature( + "ROLES_RequireRole(bytes32)", + bytes32("emissions_admin") + ); + vm.expectRevert(err); + vm.prank(rando_); + emissionManager.changeBaseRate(1e18, 1, true); + } + + function test_changeBaseRate_whenNegativeAdjustmentUnderflows_reverts() public { + uint256 changeBy_ = baseEmissionRate + 1; + uint48 forNumBeats = 1; + + // Try to change base rate, expect revert + bytes memory err = abi.encodeWithSignature( + "InvalidParam(string)", + "changeBy * forNumBeats" + ); + vm.expectRevert(err); + vm.prank(guardian); + emissionManager.changeBaseRate(changeBy_, forNumBeats, false); + } + + function test_changeBaseRate_whenPositiveAdjustmentOverflows_reverts() public { + uint256 changeBy_ = type(uint256).max - baseEmissionRate + 1; + uint48 forNumBeats = 1; + + // Try to change base rate, expect revert + bytes memory err = abi.encodeWithSignature( + "InvalidParam(string)", + "changeBy * forNumBeats" + ); + vm.expectRevert(err); + vm.prank(guardian); + emissionManager.changeBaseRate(changeBy_, forNumBeats, true); + } + + function test_changeBaseRate_positive_success() public { + // Confirm there is no current rate change + (uint256 currentChangeBy, uint48 currentBeatsLeft, bool addition) = emissionManager + .rateChange(); + assertEq(currentChangeBy, 0, "Change by should be 0"); + assertEq(currentBeatsLeft, 0, "Beats left should be 0"); + assertEq(addition, false, "Addition should be false"); + + uint256 changeBy_ = 1e3; + uint48 forNumBeats = 5; + + vm.prank(guardian); + emissionManager.changeBaseRate(changeBy_, forNumBeats, true); + + // Confirm the rate change has been set + (currentChangeBy, currentBeatsLeft, addition) = emissionManager.rateChange(); + assertEq(currentChangeBy, changeBy_, "Change by should be updated"); + assertEq(currentBeatsLeft, forNumBeats, "Beats left should be updated"); + assertEq(addition, true, "Addition should be true"); + } + + function test_changeBaseRate_negative_success() public { + // Confirm there is no current rate change + (uint256 currentChangeBy, uint48 currentBeatsLeft, bool addition) = emissionManager + .rateChange(); + assertEq(currentChangeBy, 0, "Change by should be 0"); + assertEq(currentBeatsLeft, 0, "Beats left should be 0"); + assertEq(addition, false, "Addition should be false"); + + uint256 changeBy_ = 1e3; + uint48 forNumBeats = 5; + + vm.prank(guardian); + emissionManager.changeBaseRate(changeBy_, forNumBeats, false); + + // Confirm the rate change has been set + (currentChangeBy, currentBeatsLeft, addition) = emissionManager.rateChange(); + assertEq(currentChangeBy, changeBy_, "Change by should be updated"); + assertEq(currentBeatsLeft, forNumBeats, "Beats left should be updated"); + assertEq(addition, false, "Addition should be false"); + } + + // setMinimumPremium tests + + function test_setMinimumPremium_whenCallerNotEmissionsAdmin_reverts(address rando_) public { + vm.assume(rando_ != guardian); + + // Call the setMinimumPremium function with the wrong caller + bytes memory err = abi.encodeWithSignature( + "ROLES_RequireRole(bytes32)", + bytes32("emissions_admin") + ); + vm.expectRevert(err); + vm.prank(rando_); + emissionManager.setMinimumPremium(1e18); + } + + function test_setMinimumPremium_whenMinimumPremiumZero_reverts() public { + // Try to set minimum premium to 0, expect revert + bytes memory err = abi.encodeWithSignature("InvalidParam(string)", "newMinimumPremium"); + vm.expectRevert(err); + vm.prank(guardian); + emissionManager.setMinimumPremium(0); + } + + function test_setMinimumPremium_success() public { + uint256 newMinimumPremium = 1e18; + + // Confirm the current minimum premium + assertEq(emissionManager.minimumPremium(), minimumPremium, "Minimum premium should be 0"); + + // Set the new minimum premium + vm.prank(guardian); + emissionManager.setMinimumPremium(newMinimumPremium); + + // Confirm the new minimum premium + assertEq( + emissionManager.minimumPremium(), + newMinimumPremium, + "Minimum premium should be updated" + ); + } + + // setBacking tests + + function test_setBacking_whenCallerNotEmissionsAdmin_reverts(address rando_) public { + vm.assume(rando_ != guardian); + + // Call the setBacking function with the wrong caller + bytes memory err = abi.encodeWithSignature( + "ROLES_RequireRole(bytes32)", + bytes32("emissions_admin") + ); + vm.expectRevert(err); + vm.prank(rando_); + emissionManager.setBacking(11e18); + } + + function test_setBacking_whenNewBackingTenPercentLessThanCurrent_reverts( + uint256 newBacking_ + ) public { + uint256 newBacking = newBacking_ % ((backing * 9) / 10); + + // Try to set backing to more than 10% less than current, expect revert + bytes memory err = abi.encodeWithSignature("InvalidParam(string)", "newBacking"); + vm.expectRevert(err); + vm.prank(guardian); + emissionManager.setBacking(newBacking); + } + + function test_setBacking_success(uint256 newBacking_) public { + vm.assume(newBacking_ >= ((backing * 9) / 10)); + + // Set new backing + vm.prank(guardian); + emissionManager.setBacking(newBacking_); + + // Confirm new backing + assertEq(emissionManager.backing(), newBacking_, "Backing should be updated"); + } + + // setRestartTimeframe tests + + function test_setRestartTimeframe_whenCallerNotEmissionsAdmin_reverts(address rando_) public { + vm.assume(rando_ != guardian); + + // Call the setRestartTimeframe function with the wrong caller + bytes memory err = abi.encodeWithSignature( + "ROLES_RequireRole(bytes32)", + bytes32("emissions_admin") + ); + vm.expectRevert(err); + vm.prank(rando_); + emissionManager.setRestartTimeframe(1); + } + + function test_setRestartTimeframe_whenRestartTimeframeIsZero_reverts() public { + // Try to set restart timeframe to 0, expect revert + bytes memory err = abi.encodeWithSignature("InvalidParam(string)", "newRestartTimeframe"); + vm.expectRevert(err); + vm.prank(guardian); + emissionManager.setRestartTimeframe(0); + } + + function test_setRestartTimeframe_success(uint48 restartTimeframe_) public { + vm.assume(restartTimeframe_ != 0); + + // Set new restart timeframe + vm.prank(guardian); + emissionManager.setRestartTimeframe(restartTimeframe_); + + // Confirm new restart timeframe + assertEq( + emissionManager.restartTimeframe(), + restartTimeframe_, + "Restart timeframe should be updated" + ); + } + + // setBondContracts tests + + function test_setBondContracts_whenCallerNotEmissionsAdmin_reverts(address rando_) public { + vm.assume(rando_ != guardian); + + // Call the setBondContracts function with the wrong caller + bytes memory err = abi.encodeWithSignature( + "ROLES_RequireRole(bytes32)", + bytes32("emissions_admin") + ); + vm.expectRevert(err); + vm.prank(rando_); + emissionManager.setBondContracts(address(1), address(1)); + } + + function test_setBondContracts_whenBondAuctioneerZero_reverts() public { + // Try to set bond auctioneer to 0, expect revert + bytes memory err = abi.encodeWithSignature("InvalidParam(string)", "auctioneer"); + vm.expectRevert(err); + vm.prank(guardian); + emissionManager.setBondContracts(address(0), address(1)); + } + + function test_setBondContracts_whenBondTellerZero_reverts() public { + // Try to set bond teller to 0, expect revert + bytes memory err = abi.encodeWithSignature("InvalidParam(string)", "teller"); + vm.expectRevert(err); + vm.prank(guardian); + emissionManager.setBondContracts(address(1), address(0)); + } + + function test_setBondContracts_success() public { + // Set new bond contracts + vm.prank(guardian); + emissionManager.setBondContracts(address(1), address(1)); + + // Confirm new bond contracts + assertEq( + address(emissionManager.auctioneer()), + address(1), + "Bond auctioneer should be updated" + ); + assertEq(emissionManager.teller(), address(1), "Bond teller should be updated"); + } + + // getSupply tests + + function test_getSupply_success() public { + // Confirm the supply is the total supply of OHM + assertEq( + emissionManager.getSupply(), + (gohm.totalSupply() * gohm.index()) / 1e18, + "Supply should be gOHM supply times index" + ); + + // Mint some more gOHM + uint256 mintAmount = 1000e18; + gohm.mint(address(1), mintAmount); + + // Confirm the supply is the total supply of OHM + assertEq( + emissionManager.getSupply(), + (gohm.totalSupply() * gohm.index()) / 10 ** gohm.decimals(), + "Supply should be gOHM supply times index" + ); + } + + // getReserves test + + function test_getReserves_success() public { + uint256 expectedBalance = sReserve.balanceOf(address(TRSRY)); + expectedBalance += sReserve.balanceOf(address(clearinghouse)); + expectedBalance += clearinghouse.principalReceivables(); + + // Confirm the reserves are the wrapped reserve balance of the treasury and clearinghouse + assertEq( + emissionManager.getReserves(), + expectedBalance, + "Reserves should be wrapped reserve balance of treasury and clearinghouse" + ); + + // Mint some more wrapped reserve to the treasury + uint256 mintAmount = 1000e18; + reserve.mint(address(this), 2 * mintAmount); + reserve.approve(address(sReserve), 2 * mintAmount); + sReserve.mint(mintAmount, address(TRSRY)); + expectedBalance += mintAmount; + + // Confirm the reserves are the wrapped reserve balance of the treasury and clearinghouse + assertEq( + emissionManager.getReserves(), + expectedBalance, + "Reserves should be wrapped reserve balance of treasury and clearinghouse" + ); + + // Mint some wrapped reserves to the clearinghouse + sReserve.mint(mintAmount, address(clearinghouse)); + expectedBalance += mintAmount; + + // Confirm the reserves are the wrapped reserve balance of the treasury and clearinghouse + assertEq( + emissionManager.getReserves(), + expectedBalance, + "Reserves should be wrapped reserve balance of treasury and clearinghouse" + ); + + // Increase the principal receivables of the clearinghouse + uint256 principalReceivables = clearinghouse.principalReceivables(); + clearinghouse.setPrincipalReceivables(principalReceivables + mintAmount); + expectedBalance += mintAmount; + + // Confirm the reserves are the wrapped reserve balance of the treasury and clearinghouse + assertEq( + emissionManager.getReserves(), + expectedBalance, + "Reserves should be wrapped reserve balance of treasury and clearinghouse" + ); + } + + // getNextSale tests + + function test_getNextSale_whenPremiumBelowMinimum() public givenPremiumBelowMinimum { + // Get the next sale data + (uint256 premium, uint256 emissionRate, uint256 emission) = emissionManager.getNextSale(); + + // Expect that the premium is as set in the setup + // and the other two values are zero + assertEq(premium, 20e16, "Premium should be 20%"); + assertEq(emissionRate, 0, "Emission rate should be 0"); + assertEq(emission, 0, "Emission should be 0"); + } + + function test_getNextSale_whenPremiumEqualToMinimum() public givenPremiumEqualToMinimum { + // Get the next sale data + (uint256 premium, uint256 emissionRate, uint256 emission) = emissionManager.getNextSale(); + + uint256 expectedEmission = 10_000e9; // 10,000 OHM (as described in setup) + + // Expect that the premium is as set in the setup + // and the other two values are zero + assertEq(premium, 25e16, "Premium should be 20%"); + assertEq(emissionRate, baseEmissionRate, "Emission rate should be the baseEmissionRate"); + assertEq(emission, expectedEmission, "Emission should be 10,000 OHM"); + } + + function test_getNextSale_whenPremiumAboveMinimum() public givenPremiumAboveMinimum { + // Get the next sale data + (uint256 premium, uint256 emissionRate, uint256 emission) = emissionManager.getNextSale(); + + uint256 expectedEmission = 12_000e9; // 12,000 OHM (as described in setup) + + // Expect that the premium is as set in the setup + // and the other two values are zero + assertEq(premium, 50e16, "Premium should be 50%"); + assertEq( + emissionRate, + (baseEmissionRate * 150e16) / 125e16, + "Emission rate should be the baseEmissionRate" + ); + assertEq(emission, expectedEmission, "Emission should be 10,000 OHM"); + } + + // getPremium tests + + function test_getPremium_whenPriceBelowBacking() public { + // Set the price to be below the backing ($10/OHM) + PRICE.setLastPrice(9e18); + + // Get the premium + uint256 premium = emissionManager.getPremium(); + + // Expect the premium to be 0 + assertEq(premium, 0, "Premium should be 0"); + } + + function test_getPremium_whenPriceEqualsBacking() public { + // Set the price to be equal to the backing ($10/OHM) + PRICE.setLastPrice(10e18); + + // Get the premium + uint256 premium = emissionManager.getPremium(); + + // Expect the premium to be 0 + assertEq(premium, 0, "Premium should be 0"); + } + + function test_getPremium_whenPriceAboveBacking() public { + // Set the price to be above the backing ($10/OHM) + PRICE.setLastPrice(11e18); + + // Get the premium + uint256 premium = emissionManager.getPremium(); + + // Expect the premium to be 10% + assertEq(premium, 10e16, "Premium should be 10%"); + + // Set price again + PRICE.setLastPrice(15e18); + + // Get the premium + premium = emissionManager.getPremium(); + + // Expect the premium to be 50% + assertEq(premium, 50e16, "Premium should be 50%"); + + // Set price again + PRICE.setLastPrice(30e18); + + // Get the premium + premium = emissionManager.getPremium(); + + // Expect the premium to be 200% + assertEq(premium, 200e16, "Premium should be 200%"); + } +} diff --git a/src/test/policies/Heart.t.sol b/src/test/policies/Heart.t.sol index d4b1e0d1..76a98e1b 100644 --- a/src/test/policies/Heart.t.sol +++ b/src/test/policies/Heart.t.sol @@ -14,6 +14,8 @@ import {RolesAdmin} from "policies/RolesAdmin.sol"; import {ZeroDistributor} from "policies/Distributor/ZeroDistributor.sol"; import {MockStakingZD} from "src/test/mocks/MockStakingForZD.sol"; import {MockYieldRepo} from "src/test/mocks/MockYieldRepo.sol"; +import {MockReserveMigrator} from "src/test/mocks/MockReserveMigrator.sol"; +import {MockEmissionManager} from "src/test/mocks/MockEmissionManager.sol"; import {FullMath} from "libraries/FullMath.sol"; @@ -24,6 +26,8 @@ import {OlympusHeart, IHeart} from "policies/Heart.sol"; import {IOperator} from "policies/interfaces/IOperator.sol"; import {IDistributor} from "policies/interfaces/IDistributor.sol"; import {IYieldRepo} from "policies/interfaces/IYieldRepo.sol"; +import {IReserveMigrator} from "policies/interfaces/IReserveMigrator.sol"; +import {IEmissionManager} from "policies/interfaces/IEmissionManager.sol"; /** * @notice Mock Operator to test Heart @@ -76,6 +80,8 @@ contract HeartTest is Test { ZeroDistributor internal distributor; MockYieldRepo internal yieldRepo; + MockReserveMigrator internal reserveMigrator; + MockEmissionManager internal emissionManager; uint48 internal constant PRICE_FREQUENCY = uint48(8 hours); @@ -128,12 +134,20 @@ contract HeartTest is Test { // Deploy mock yieldRepo yieldRepo = new MockYieldRepo(); + // Deploy mock reserve migrator + reserveMigrator = new MockReserveMigrator(); + + // Deploy mock emission manager + emissionManager = new MockEmissionManager(); + // Deploy heart heart = new OlympusHeart( kernel, IOperator(address(operator)), IDistributor(address(distributor)), IYieldRepo(address(yieldRepo)), + IReserveMigrator(address(reserveMigrator)), + IEmissionManager(address(emissionManager)), uint256(10e9), // max reward = 10 reward tokens uint48(12 * 50) // auction duration = 5 minutes (50 blocks on ETH mainnet) ); @@ -192,6 +206,8 @@ contract HeartTest is Test { IOperator(address(operator)), IDistributor(address(distributor)), IYieldRepo(address(yieldRepo)), + IReserveMigrator(address(reserveMigrator)), + IEmissionManager(address(emissionManager)), uint256(10e9), // max reward = 10 reward tokens uint48(12 * 50) // auction duration = 5 minutes (50 blocks on ETH mainnet) ); diff --git a/src/test/policies/Operator.t.sol b/src/test/policies/Operator.t.sol index 609db7de..5c0f4cfa 100644 --- a/src/test/policies/Operator.t.sol +++ b/src/test/policies/Operator.t.sol @@ -48,6 +48,7 @@ contract OperatorTest is Test { MockOhm internal ohm; MockERC20 internal reserve; MockERC4626 internal wrappedReserve; + MockERC20 internal oldReserve; Kernel internal kernel; MockPrice internal PRICE; @@ -95,6 +96,7 @@ contract OperatorTest is Test { ohm = new MockOhm("Olympus", "OHM", 9); reserve = new MockERC20("Reserve", "RSV", 18); wrappedReserve = new MockERC4626(reserve, "wrappedReserve", "sRSV"); + oldReserve = new MockERC20("Old Reserve", "oRSV", 18); } { @@ -131,7 +133,7 @@ contract OperatorTest is Test { kernel, IBondSDA(address(auctioneer)), callback, - [address(ohm), address(reserve), address(wrappedReserve)], + [address(ohm), address(reserve), address(wrappedReserve), address(oldReserve)], [ uint32(2000), // cushionFactor uint32(5 days), // duration @@ -171,7 +173,7 @@ contract OperatorTest is Test { /// Configure access control /// Operator ROLES - rolesAdmin.grantRole("operator_operate", address(heart)); + rolesAdmin.grantRole("heart", address(heart)); rolesAdmin.grantRole("operator_reporter", address(callback)); rolesAdmin.grantRole("operator_policy", policy); rolesAdmin.grantRole("operator_admin", guardian); @@ -1614,7 +1616,7 @@ contract OperatorTest is Test { /// Try to call operate as anyone else bytes memory err = abi.encodeWithSelector( ROLESv1.ROLES_RequireRole.selector, - bytes32("operator_operate") + bytes32("heart") ); vm.expectRevert(err); vm.prank(alice); @@ -1855,24 +1857,99 @@ contract OperatorTest is Test { operator.setSpreads(false, 99, 2000); } - function testCorrectness_setCushionFactor() public { + function testCorrectness_setCushionFactor_fuzz(uint32 cushionFactor_) public { + uint32 cushionFactor = uint32(bound(cushionFactor_, 1, 100e2)); + /// Initialize operator vm.prank(guardian); operator.initialize(); - /// Get starting cushion factor - Operator.Config memory startConfig = operator.config(); - /// Set cushion factor as admin vm.prank(policy); - operator.setCushionFactor(uint32(1000)); + operator.setCushionFactor(cushionFactor); /// Get new cushion factor Operator.Config memory newConfig = operator.config(); /// Check that the cushion factor has been set - assertEq(newConfig.cushionFactor, uint32(1000)); - assertLt(newConfig.cushionFactor, startConfig.cushionFactor); + assertEq(newConfig.cushionFactor, cushionFactor, "incorrect cushion factor"); + } + + function testCorrectness_constructor_cushionFactor_fuzz(uint32 cushionFactor_) public { + uint32 cushionFactor = uint32(bound(cushionFactor_, 1, 100e2)); + + // Deploy a new Operator + Operator newOperator = new Operator( + kernel, + IBondSDA(address(auctioneer)), + callback, + [address(ohm), address(reserve), address(wrappedReserve), address(oldReserve)], + [ + cushionFactor, // cushionFactor + uint32(5 days), // duration + uint32(100_000), // debtBuffer + uint32(1 hours), // depositInterval + uint32(1000), // reserveFactor + uint32(1 hours), // regenWait + uint32(5), // regenThreshold + uint32(7) // regenObserve + // uint32(8 hours) // observationFrequency + ] + ); + + /// Get new cushion factor + Operator.Config memory newConfig = newOperator.config(); + + /// Check that the cushion factor has been set + assertEq(newConfig.cushionFactor, cushionFactor, "incorrect cushion factor"); + } + + function testCorrectness_constructor_cushionFactor_zero() public { + // Expect revert + vm.expectRevert(abi.encodeWithSignature("Operator_InvalidParams()")); + + // Deploy a new Operator + new Operator( + kernel, + IBondSDA(address(auctioneer)), + callback, + [address(ohm), address(reserve), address(wrappedReserve), address(oldReserve)], + [ + uint32(0), // cushionFactor + uint32(5 days), // duration + uint32(100_000), // debtBuffer + uint32(1 hours), // depositInterval + uint32(1000), // reserveFactor + uint32(1 hours), // regenWait + uint32(5), // regenThreshold + uint32(7) // regenObserve + // uint32(8 hours) // observationFrequency + ] + ); + } + + function testCorrectness_constructor_cushionFactor_aboveHundredPercent() public { + // Expect revert + vm.expectRevert(abi.encodeWithSignature("Operator_InvalidParams()")); + + // Deploy a new Operator + new Operator( + kernel, + IBondSDA(address(auctioneer)), + callback, + [address(ohm), address(reserve), address(wrappedReserve), address(oldReserve)], + [ + uint32(100e2 + 1), // cushionFactor + uint32(5 days), // duration + uint32(100_000), // debtBuffer + uint32(1 hours), // depositInterval + uint32(1000), // reserveFactor + uint32(1 hours), // regenWait + uint32(5), // regenThreshold + uint32(7) // regenObserve + // uint32(8 hours) // observationFrequency + ] + ); } function testCorrectness_cannotSetCushionFactorWithInvalidParams() public { @@ -1884,7 +1961,7 @@ contract OperatorTest is Test { bytes memory err = abi.encodeWithSignature("Operator_InvalidParams()"); vm.expectRevert(err); vm.prank(policy); - operator.setCushionFactor(uint32(99)); + operator.setCushionFactor(uint32(0)); /// Set cushion factor with invalid params as admin (too high) vm.expectRevert(err); @@ -1943,24 +2020,99 @@ contract OperatorTest is Test { operator.setCushionParams(uint32(2 days), uint32(99), uint32(2 hours)); } - function testCorrectness_setReserveFactor() public { + function testCorrectness_setReserveFactor_fuzz(uint32 reserveFactor_) public { + uint32 reserveFactor = uint32(bound(reserveFactor_, 1, 100e2)); + /// Initialize operator vm.prank(guardian); operator.initialize(); - /// Get starting reserve factor - Operator.Config memory startConfig = operator.config(); - /// Set reserve factor as admin vm.prank(policy); - operator.setReserveFactor(uint32(500)); + operator.setReserveFactor(reserveFactor); /// Get new reserve factor Operator.Config memory newConfig = operator.config(); /// Check that the reserve factor has been set - assertEq(newConfig.reserveFactor, uint32(500)); - assertLt(newConfig.reserveFactor, startConfig.reserveFactor); + assertEq(newConfig.reserveFactor, reserveFactor, "incorrect reserve factor"); + } + + function testCorrectness_constructor_reserveFactor_fuzz(uint32 reserveFactor_) public { + uint32 reserveFactor = uint32(bound(reserveFactor_, 1, 100e2)); + + // Deploy a new Operator + Operator newOperator = new Operator( + kernel, + IBondSDA(address(auctioneer)), + callback, + [address(ohm), address(reserve), address(wrappedReserve), address(oldReserve)], + [ + uint32(2000), // cushionFactor + uint32(5 days), // duration + uint32(100_000), // debtBuffer + uint32(1 hours), // depositInterval + reserveFactor, // reserveFactor + uint32(1 hours), // regenWait + uint32(5), // regenThreshold + uint32(7) // regenObserve + // uint32(8 hours) // observationFrequency + ] + ); + + /// Get new reserve factor + Operator.Config memory newConfig = newOperator.config(); + + /// Check that the reserve factor has been set + assertEq(newConfig.reserveFactor, reserveFactor, "incorrect reserve factor"); + } + + function testCorrectness_constructor_reserveFactor_zero() public { + // Expect revert + vm.expectRevert(abi.encodeWithSignature("Operator_InvalidParams()")); + + // Deploy a new Operator + new Operator( + kernel, + IBondSDA(address(auctioneer)), + callback, + [address(ohm), address(reserve), address(wrappedReserve), address(oldReserve)], + [ + uint32(2000), // cushionFactor + uint32(5 days), // duration + uint32(100_000), // debtBuffer + uint32(1 hours), // depositInterval + uint32(0), // reserveFactor + uint32(1 hours), // regenWait + uint32(5), // regenThreshold + uint32(7) // regenObserve + // uint32(8 hours) // observationFrequency + ] + ); + } + + function testCorrectness_constructor_reserveFactor_aboveHundredPercent() public { + // Expect revert + vm.expectRevert(abi.encodeWithSignature("Operator_InvalidParams()")); + + // Deploy a new Operator + new Operator( + kernel, + IBondSDA(address(auctioneer)), + callback, + [address(ohm), address(reserve), address(wrappedReserve), address(oldReserve)], + [ + uint32(2000), // cushionFactor + uint32(5 days), // duration + uint32(100_000), // debtBuffer + uint32(1 hours), // depositInterval + uint32(100e2 + 1), // reserveFactor + uint32(1 hours), // regenWait + uint32(5), // regenThreshold + uint32(7) // regenObserve + // uint32(8 hours) // observationFrequency + ] + ); } function testCorrectness_cannotSetReserveFactorWithInvalidParams() public { @@ -1972,7 +2124,7 @@ contract OperatorTest is Test { bytes memory err = abi.encodeWithSignature("Operator_InvalidParams()"); vm.expectRevert(err); vm.prank(policy); - operator.setReserveFactor(uint32(99)); + operator.setReserveFactor(uint32(0)); /// Set reserve factor with invalid params as admin (too high) vm.expectRevert(err); @@ -2207,16 +2359,22 @@ contract OperatorTest is Test { vm.prank(guardian); operator.initialize(); + /// Set the price to trigger a low cushion + PRICE.setLastPrice(89 * 1e18); + /// Toggle the operator to inactive vm.prank(policy); operator.deactivate(); - /// Try to call operator, swap, and bondPurchase, expect reverts - bytes memory err = abi.encodeWithSignature("Operator_Inactive()"); - vm.expectRevert(err); + // Calling operate will not fail, but it will do nothing vm.prank(heart); operator.operate(); + // Check that the market is not live + assertFalse(auctioneer.isLive(RANGE.market(false))); + + /// Try to call operator, swap, and bondPurchase, expect reverts + bytes memory err = abi.encodeWithSignature("Operator_Inactive()"); vm.expectRevert(err); vm.prank(alice); operator.swap(ohm, 1e9, 1); diff --git a/src/test/policies/ReserveMigrator.t.sol b/src/test/policies/ReserveMigrator.t.sol new file mode 100644 index 00000000..95a8f6be --- /dev/null +++ b/src/test/policies/ReserveMigrator.t.sol @@ -0,0 +1,791 @@ +// SPDX-License-Identifier: Unlicense +pragma solidity >=0.8.0; + +import {Test} from "forge-std/Test.sol"; +import {UserFactory} from "src/test/lib/UserFactory.sol"; + +import {MockERC20, ERC20} from "solmate/test/utils/mocks/MockERC20.sol"; +import {MockERC4626, ERC4626} from "solmate/test/utils/mocks/MockERC4626.sol"; + +import {MockDaiUsds} from "src/test/mocks/MockDaiUsds.sol"; + +import "src/Kernel.sol"; +import {OlympusTreasury} from "modules/TRSRY/OlympusTreasury.sol"; +import {OlympusRoles} from "modules/ROLES/OlympusRoles.sol"; +import {RolesAdmin} from "policies/RolesAdmin.sol"; +import {ReserveMigrator} from "policies/ReserveMigrator.sol"; + +// solhint-disable-next-line max-states-count +contract ReserveMigratorTest is Test { + UserFactory public userCreator; + address internal guardian; + address internal heart; + + MockERC20 internal from; + MockERC4626 internal sFrom; + MockERC20 internal to; + MockERC4626 internal sTo; + + MockDaiUsds internal daiUsds; + + Kernel internal kernel; + OlympusTreasury internal TRSRY; + OlympusRoles internal ROLES; + + ReserveMigrator internal reserveMigrator; + RolesAdmin internal rolesAdmin; + + // Track balances in state variables to avoid stack too deep + uint256 public fromBalance; + uint256 public sFromBalance; + uint256 public toBalance; + uint256 public sToBalance; + uint256 public fromMigratorBalance; + uint256 public sFromMigratorBalance; + uint256 public toMigratorBalance; + uint256 public sToMigratorBalance; + + function setUp() public { + // Create users + userCreator = new UserFactory(); + { + /// Deploy bond system to test against + address[] memory users = userCreator.create(2); + guardian = users[0]; + heart = users[1]; + } + + // Deploy mock tokens and converter + from = new MockERC20("Dai Stablecoin", "DAI", 18); + sFrom = new MockERC4626(from, "Savings Dai", "sDAI"); + to = new MockERC20("Sky USD", "USDS", 18); + sTo = new MockERC4626(to, "Savings Sky USD", "sUSDS"); + daiUsds = new MockDaiUsds(from, to); + + // Label the tokens for easier debugging + vm.label(address(from), "fromReserve"); + vm.label(address(sFrom), "sFromReserve"); + vm.label(address(to), "toReserve"); + vm.label(address(sTo), "sToReserve"); + + // Deploy kernel, modules, and policies + kernel = new Kernel(); + TRSRY = new OlympusTreasury(kernel); + ROLES = new OlympusRoles(kernel); + rolesAdmin = new RolesAdmin(kernel); + reserveMigrator = new ReserveMigrator( + kernel, + address(sFrom), + address(sTo), + address(daiUsds) + ); + + // Install modules and policies on the kernel + kernel.executeAction(Actions.InstallModule, address(TRSRY)); + kernel.executeAction(Actions.InstallModule, address(ROLES)); + kernel.executeAction(Actions.ActivatePolicy, address(rolesAdmin)); + kernel.executeAction(Actions.ActivatePolicy, address(reserveMigrator)); + + // Grant roles for the migrator + rolesAdmin.grantRole("heart", heart); + rolesAdmin.grantRole("reserve_migrator_admin", guardian); + + // Set the conversion rate from from to sFrom and to to sTo as 2:1 + // We use this relatively high value to make the calculations simpler + // We do this by depositing 1 from into sFrom and then minting another token to it + // Same thing for to and sTo + from.mint(address(this), 1e18); + from.approve(address(sFrom), 1e18); + sFrom.deposit(1e18, address(this)); + from.mint(address(sFrom), 1e18); + + to.mint(address(this), 1e18); + to.approve(address(sTo), 1e18); + sTo.deposit(1e18, address(this)); + to.mint(address(sTo), 1e18); + } + + // helper functions and modifiers + modifier givenAmountValid(uint256 amount_) { + vm.assume(amount_ >= 2 && amount_ <= 1e29); // between 2 and 100 billion + _; + } + + function _issueFrom(address receiver_, uint256 amount_) internal { + from.mint(receiver_, amount_); + } + + modifier givenTreasuryHasFrom(uint256 amount_) { + _issueFrom(address(TRSRY), amount_); + fromBalance += amount_; + _; + } + + modifier givenMigratorHasFrom(uint256 amount_) { + _issueFrom(address(reserveMigrator), amount_); + fromMigratorBalance += amount_; + _; + } + + function _issueWrappedFrom(address receiver_, uint256 amount_) internal { + from.mint(address(this), 2 * amount_); + from.approve(address(sFrom), 2 * amount_); + sFrom.mint(amount_, receiver_); + } + + modifier givenTreasuryHasWrappedFrom(uint256 amount_) { + _issueWrappedFrom(address(TRSRY), amount_); + sFromBalance += amount_; + _; + } + + modifier givenMigratorHasWrappedFrom(uint256 amount_) { + _issueWrappedFrom(address(reserveMigrator), amount_); + sFromMigratorBalance += amount_; + _; + } + + modifier givenInactive() { + vm.prank(guardian); + reserveMigrator.deactivate(); + _; + } + + // Scaffolding for the tests + + function _validateStartBalances() internal { + assertEq(from.balanceOf(address(TRSRY)), fromBalance); + assertEq(sFrom.balanceOf(address(TRSRY)), sFromBalance); + assertEq(from.balanceOf(address(reserveMigrator)), fromMigratorBalance); + assertEq(sFrom.balanceOf(address(reserveMigrator)), sFromMigratorBalance); + assertEq(to.balanceOf(address(TRSRY)), toBalance); + assertEq(sTo.balanceOf(address(TRSRY)), sToBalance); + assertEq(to.balanceOf(address(reserveMigrator)), toMigratorBalance); + assertEq(sTo.balanceOf(address(reserveMigrator)), sToMigratorBalance); + } + + function _validateEndBalances() internal { + uint256 sToIncrease = sFromBalance + + sFromMigratorBalance + + sTo.previewDeposit(fromBalance + fromMigratorBalance); + + assertEq(from.balanceOf(address(TRSRY)), 0); + assertEq(sFrom.balanceOf(address(TRSRY)), 0); + assertEq(from.balanceOf(address(reserveMigrator)), 0); + assertEq(sFrom.balanceOf(address(reserveMigrator)), 0); + assertEq(to.balanceOf(address(TRSRY)), 0); + assertEq(sTo.balanceOf(address(TRSRY)), sToBalance + sToIncrease); + assertEq(to.balanceOf(address(reserveMigrator)), 0); + assertEq(sTo.balanceOf(address(reserveMigrator)), 0); + } + + function migrateAndValidate() public { + _validateStartBalances(); + + vm.prank(heart); + reserveMigrator.migrate(); + + _validateEndBalances(); + } + + function migrateAndValidateInactive() public { + _validateStartBalances(); + + vm.prank(heart); + reserveMigrator.migrate(); + + _validateStartBalances(); + } + + // tests + // + // migrate + // [X] when called by an address without the "heart" role + // [X] it reverts + // [X] when called by an address with the "heart" role and when the contract is locally active + // [X] when the TRSRY contract has a zero balance of from and sFrom reserves + // [X] when the reserve migrator has a zero balance of from and sFrom reserves + // [X] it does nothing + // [X] when the reserve migrator has a non-zero balance of from reserves + // [X] it migrates the from balance of the reserve migrator to the to reserve + // [X] it deposits the to reserves into sTo and sends to the TRSRY + // [X] when the reserve migrator has a non-zero balance of sFrom reserves + // [X] it redeems the sFrom balance of the reserve migrator + // [X] it migrates the from balance of the reserve migrator to the to reserve + // [X] it deposits the to reserves into sTo and sends to the TRSRY + // [X] when the reserve migrator has a non-zero balance of from and sFrom reserves + // [X] it redeems the sFrom balance of the reserve migrator + // [X] it migrates the combined from balance of the reserve migrator to the to reserve + // [X] it deposits the to reserves into sTo and sends to the TRSRY + // [X] when the TRSRY contract has a non-zero balance of from reserves + // [X] when the reserve migrator has a zero balance of from and sFrom reserves + // [X] it migrates the from balance of the TRSRY to the to reserve + // [X] it deposits the to reserves into sTo and sends to the TRSRY + // [X] when the reserve migrator has a non-zero balance of from reserves + // [X] it migrates the combined from balance of the reserve migrator and the TRSRY to the to reserve + // [X] it deposits the to reserves into sTo and sends to the TRSRY + // [X] when the reserve migrator has a non-zero balance of sFrom reserves + // [X] it redeems the sFrom balance of the reserve migrator + // [X] it migrates the combined from balance of the TRSRY and the reserve migrator to the to reserve + // [X] it deposits the to reserves into sTo and sends to the TRSRY + // [X] when the reserve migrator has a non-zero balance of from and sFrom reserves + // [X] it redeems the sFrom balance of the reserve migrator + // [X] it migrates the combined from balance of the TRSRY and the reserve migrator to the to reserve + // [X] it deposits the to reserves into sTo and sends to the TRSRY + // [X] when the TRSRY contract has a non-zero balance of sFrom reserves + // [X] when the reserve migrator has a zero balance of from and sFrom reserves + // [X] it redeems the sFrom balance of the TRSRY + // [X] it migrates the from balance of the TRSRY to the to reserve + // [X] it deposits the to reserves into sTo and sends to the TRSRY + // [X] when the reserve migrator has a non-zero balance of from reserves + // [X] it redeems the sFrom balance of the TRSRY + // [X] it migrates the combined from balance of the reserve migrator and the TRSRY to the to reserve + // [X] it deposits the to reserves into sTo and sends to the TRSRY + // [X] when the reserve migrator has a non-zero balance of sFrom reserves + // [X] it redeems the combined sFrom balance of the TRSRY and the reserve migrator + // [X] it migrates the combined from balance of the TRSRY and the reserve migrator to the to reserve + // [X] it deposits the to reserves into sTo and sends to the TRSRY + // [X] when the reserve migrator has a non-zero balance of from and sFrom reserves + // [X] it redeems the combined sFrom balance of the TRSRY and the reserve migrator + // [X] it migrates the combined from balance of the TRSRY and the reserve migrator to the to reserve + // [X] it deposits the to reserves into sTo and sends to the TRSRY + // [X] when the TRSRY contract has a non-zero balance of from and sFrom reserves + // [X] when the reserve migrator has a zero balance of from and sFrom reserves + // [X] it redeems the sFrom balance of the TRSRY + // [X] it migrates the from balance of the TRSRY to the to reserve + // [X] it deposits the to reserves into sTo and sends to the TRSRY + // [X] when the reserve migrator has a non-zero balance of from reserves + // [X] it redeems the sFrom balance of the TRSRY + // [X] it migrates the combined from balance of the reserve migrator and the TRSRY to the to reserve + // [X] it deposits the to reserves into sTo and sends to the TRSRY + // [X] when the reserve migrator has a non-zero balance of sFrom reserves + // [X] it redeems the combined sFrom balance of the TRSRY and the reserve migrator + // [X] it migrates the combined from balance of the TRSRY and the reserve migrator to the to reserve + // [X] it deposits the to reserves into sTo and sends to the TRSRY + // [X] when the reserve migrator has a non-zero balance of from and sFrom reserves + // [X] it redeems the combined sFrom balance of the TRSRY and the reserve migrator + // [X] it migrates the combined from balance of the TRSRY and the reserve migrator to the to reserve + // [X] it deposits the to reserves into sTo and sends to the TRSRY + // [X] when called by an address with the "heart" role and when the contract is not locally active + // [X] it does nothing (in all the cases listed above) + + function test_migrate_whenCalledByNonHeartRole_itReverts(address caller_) public { + vm.assume(caller_ != heart); + + // Call migrate, expect revert + bytes memory err = abi.encodeWithSignature("ROLES_RequireRole(bytes32)", bytes32("heart")); + vm.expectRevert(err); + vm.prank(caller_); + reserveMigrator.migrate(); + + // Call migrate as heart, expect success + vm.prank(heart); + reserveMigrator.migrate(); + } + + // migrate + // when called by an address with the "heart" role and when the contract is locally active + // when the TRSRY contract has a zero balance of from and sFrom reserves + // when the reserve migrator has a zero balance of from and sFrom reserves + // it does nothing + function test_migrate_whenTreasuryZero_whenMigratorZero_itDoesNothing() public { + migrateAndValidate(); + } + + // migrate + // when called by an address with the "heart" role and when the contract is locally active + // when the TRSRY contract has a zero balance of from and sFrom reserves + // when the reserve migrator has a non-zero balance of from reserves + // it migrates the from balance of the reserve migrator to the to reserve + // it deposits the to reserves into sTo and sends to the TRSRY + function test_migrate_whenTreasuryZero_whenMigratorNonZeroReserves( + uint256 amount_ + ) public givenAmountValid(amount_) givenMigratorHasFrom(amount_) { + migrateAndValidate(); + } + + // migrate + // when called by an address with the "heart" role and when the contract is locally active + // when the TRSRY contract has a zero balance of from and sFrom reserves + // when the reserve migrator has a non-zero balance of sFrom reserves + // it redeems the sFrom balance of the reserve migrator + // it migrates the from balance of the reserve migrator to the to reserve + // it deposits the to reserves into sTo and sends to the TRSRY + function test_migrate_whenTreasuryZero_whenMigratorNonZeroWrappedReserves( + uint256 amount_ + ) public givenAmountValid(amount_) givenMigratorHasWrappedFrom(amount_) { + migrateAndValidate(); + } + + // migrate + // when called by an address with the "heart" role and when the contract is locally active + // when the TRSRY contract has a zero balance of from and sFrom reserves + // when the reserve migrator has a non-zero balance of from reserves and sFrom reserves + // it redeems the sFrom balance of the reserve migrator + // it migrates the combined from balance of the reserve migrator to the to reserve + // it deposits the to reserves into sTo and sends to the TRSRY + function test_migrate_whenTreasuryZero_whenMigratorNonZeroBoth( + uint256 amount_ + ) + public + givenAmountValid(amount_) + givenMigratorHasFrom(amount_) + givenMigratorHasWrappedFrom(amount_) + { + migrateAndValidate(); + } + + // migrate + // when called by an address with the "heart" role and when the contract is locally active + // when the TRSRY contract has a non-zero balance of from reserves + // when the reserve migrator has a zero balance of from and sFrom reserves + // it migrates the from balance of the TRSRY to the to reserve + // it deposits the to reserves into sTo and sends to the TRSRY + function test_migrate_whenTreasuryNonZeroReserves_whenMigratorZero( + uint256 amount_ + ) public givenAmountValid(amount_) givenTreasuryHasFrom(amount_) { + migrateAndValidate(); + } + + // migrate + // when called by an address with the "heart" role and when the contract is locally active + // when the TRSRY contract has a non-zero balance of from reserves + // when the reserve migrator has a non-zero balance of from reserves + // it migrates the combined from balance of the reserve migrator and the TRSRY to the to reserve + // it deposits the to reserves into sTo and sends to the TRSRY + function test_migrate_whenTreasuryNonZeroReserves_whenMigratorNonZeroReserves( + uint256 amount_ + ) public givenAmountValid(amount_) givenTreasuryHasFrom(amount_) givenMigratorHasFrom(amount_) { + migrateAndValidate(); + } + + // migrate + // when called by an address with the "heart" role and when the contract is locally active + // when the TRSRY contract has a non-zero balance of from reserves + // when the reserve migrator has a non-zero balance of sFrom reserves + // it redeems the sFrom balance of the reserve migrator + // it migrates the combined from balance of the TRSRY and the reserve migrator to the to reserve + // it deposits the to reserves into sTo and sends to the TRSRY + function test_migrate_whenTreasuryNonZeroReserves_whenMigratorNonZeroWrappedReserves( + uint256 amount_ + ) + public + givenAmountValid(amount_) + givenTreasuryHasFrom(amount_) + givenMigratorHasWrappedFrom(amount_) + { + migrateAndValidate(); + } + + // migrate + // when called by an address with the "heart" role and when the contract is locally active + // when the TRSRY contract has a non-zero balance of from reserves + // when the reserve migrator has a non-zero balance of from and sFrom reserves + // it redeems the sFrom balance of the reserve migrator + // it migrates the combined from balance of the TRSRY and the reserve migrator to the to reserve + // it deposits the to reserves into sTo and sends to the TRSRY + function test_migrate_whenTreasuryNonZeroReserves_whenMigratorNonZeroBoth( + uint256 amount_ + ) + public + givenAmountValid(amount_) + givenTreasuryHasFrom(amount_) + givenMigratorHasFrom(amount_) + givenMigratorHasWrappedFrom(amount_) + { + migrateAndValidate(); + } + + // migrate + // when called by an address with the "heart" role and when the contract is locally active + // when the TRSRY contract has a non-zero balance of sFrom reserves + // when the reserve migrator has a zero balance of from and sFrom reserves + // it redeems the sFrom balance of the TRSRY + // it migrates the from balance of the TRSRY to the to reserve + // it deposits the to reserves into sTo and sends to the TRSRY + function test_migrate_whenTreasuryNonZeroWrappedReserves_whenMigratorZero( + uint256 amount_ + ) public givenAmountValid(amount_) givenTreasuryHasWrappedFrom(amount_) { + migrateAndValidate(); + } + + // migrate + // when called by an address with the "heart" role and when the contract is locally active + // when the TRSRY contract has a non-zero balance of sFrom reserves + // when the reserve migrator has a non-zero balance of from reserves + // it redeems the sFrom balance of the TRSRY + // it migrates the combined from balance of the reserve migrator and the TRSRY to the to reserve + // it deposits the to reserves into sTo and sends to the TRSRY + function test_migrate_whenTreasuryNonZeroWrappedReserves_whenMigratorNonZeroReserves( + uint256 amount_ + ) + public + givenAmountValid(amount_) + givenTreasuryHasWrappedFrom(amount_) + givenMigratorHasFrom(amount_) + { + migrateAndValidate(); + } + + // migrate + // when called by an address with the "heart" role and when the contract is locally active + // when the TRSRY contract has a non-zero balance of sFrom reserves + // when the reserve migrator has a non-zero balance of sFrom reserves + // it redeems the combined sFrom balance of the TRSRY and the reserve migrator + // it migrates the combined from balance of the TRSRY and the reserve migrator to the to reserve + // it deposits the to reserves into sTo and sends to the TRSRY + function test_migrate_whenTreasuryNonZeroWrappedReserves_whenMigratorNonZeroWrappedReserves( + uint256 amount_ + ) + public + givenAmountValid(amount_) + givenTreasuryHasWrappedFrom(amount_) + givenMigratorHasWrappedFrom(amount_) + { + migrateAndValidate(); + } + + // migrate + // when called by an address with the "heart" role and when the contract is locally active + // when the TRSRY contract has a non-zero balance of sFrom reserves + // when the reserve migrator has a non-zero balance of from and sFrom reserves + // it redeems the combined sFrom balance of the TRSRY and the reserve migrator + // it migrates the combined from balance of the TRSRY and the reserve migrator to the to reserve + // it deposits the to reserves into sTo and sends to the TRSRY + function test_migrate_whenTreasuryNonZeroWrappedReserves_whenMigratorNonZeroBoth( + uint256 amount_ + ) + public + givenAmountValid(amount_) + givenTreasuryHasWrappedFrom(amount_) + givenMigratorHasFrom(amount_) + givenMigratorHasWrappedFrom(amount_) + { + migrateAndValidate(); + } + + // migrate + // when called by an address with the "heart" role and when the contract is locally active + // when the TRSRY contract has a non-zero balance of from and sFrom reserves + // when the reserve migrator has a zero balance of from and sFrom reserves + // it redeems the sFrom balance of the TRSRY + // it migrates the from balance of the TRSRY to the to reserve + // it deposits the to reserves into sTo and sends to the TRSRY + function test_migrate_whenTreasuryNonZeroBoth_whenMigratorZero( + uint256 amount_ + ) + public + givenAmountValid(amount_) + givenTreasuryHasFrom(amount_) + givenTreasuryHasWrappedFrom(amount_) + { + migrateAndValidate(); + } + + // migrate + // when called by an address with the "heart" role and when the contract is locally active + // when the TRSRY contract has a non-zero balance of from and sFrom reserves + // when the reserve migrator has a non-zero balance of from reserves + // it redeems the sFrom balance of the TRSRY + // it migrates the combined from balance of the reserve migrator and the TRSRY to the to reserve + // it deposits the to reserves into sTo and sends to the TRSRY + function test_migrate_whenTreasuryNonZeroBoth_whenMigratorNonZeroReserves( + uint256 amount_ + ) + public + givenAmountValid(amount_) + givenTreasuryHasFrom(amount_) + givenTreasuryHasWrappedFrom(amount_) + givenMigratorHasFrom(amount_) + { + migrateAndValidate(); + } + + // migrate + // when called by an address with the "heart" role and when the contract is locally active + // when the TRSRY contract has a non-zero balance of from and sFrom reserves + // when the reserve migrator has a non-zero balance of sFrom reserves + // it redeems the combined sFrom balance of the TRSRY and the reserve migrator + // it migrates the combined from balance of the TRSRY and the reserve migrator to the to reserve + // it deposits the to reserves into sTo and sends to the TRSRY + function test_migrate_whenTreasuryNonZeroBoth_whenMigratorNonZeroWrappedReserves( + uint256 amount_ + ) + public + givenAmountValid(amount_) + givenTreasuryHasFrom(amount_) + givenTreasuryHasWrappedFrom(amount_) + givenMigratorHasWrappedFrom(amount_) + { + migrateAndValidate(); + } + + // migrate + // when called by an address with the "heart" role and when the contract is locally active + // when the TRSRY contract has a non-zero balance of from and sFrom reserves + // when the reserve migrator has a non-zero balance of from and sFrom reserves + // it redeems the combined sFrom balance of the TRSRY and the reserve migrator + // it migrates the combined from balance of the TRSRY and the reserve migrator to the to reserve + // it deposits the to reserves into sTo and sends to the TRSRY + function test_migrate_whenTreasuryNonZeroBoth_whenMigratorNonZeroBoth( + uint256 amount_ + ) + public + givenAmountValid(amount_) + givenTreasuryHasFrom(amount_) + givenTreasuryHasWrappedFrom(amount_) + givenMigratorHasFrom(amount_) + givenMigratorHasWrappedFrom(amount_) + { + migrateAndValidate(); + } + + // migrate + // when called by an address with the "heart" role and when the contract is NOT locally active + // when the TRSRY contract has a zero balance of from and sFrom reserves + // when the reserve migrator has a zero balance of from and sFrom reserves + // it does nothing + function test_migrate_whenNotLocallyActive_whenTreasuryZero_whenMigratorZero() + public + givenInactive + { + migrateAndValidateInactive(); + } + + // migrate + // when called by an address with the "heart" role and when the contract is NOT locally active + // when the TRSRY contract has a zero balance of from and sFrom reserves + // when the reserve migrator has a non-zero balance of from reserves + // it does nothing + function test_migrate_whenNotLocallyActive_whenTreasuryZero_whenMigratorNonZeroReserves( + uint256 amount_ + ) public givenInactive givenAmountValid(amount_) givenMigratorHasFrom(amount_) { + migrateAndValidateInactive(); + } + + // migrate + // when called by an address with the "heart" role and when the contract is NOT locally active + // when the TRSRY contract has a zero balance of from and sFrom reserves + // when the reserve migrator has a non-zero balance of sFrom reserves + // it does nothing + function test_migrate_whenNotLocallyActive_whenTreasuryZero_whenMigratorNonZeroWrappedReserves( + uint256 amount_ + ) public givenInactive givenAmountValid(amount_) givenMigratorHasWrappedFrom(amount_) { + migrateAndValidateInactive(); + } + + // migrate + // when called by an address with the "heart" role and when the contract is NOT locally active + // when the TRSRY contract has a zero balance of from and sFrom reserves + // when the reserve migrator has a non-zero balance of from and sFrom reserves + // it does nothing + function test_migrate_whenNotLocallyActive_whenTreasuryZero_whenMigratorNonZeroBoth( + uint256 amount_ + ) + public + givenInactive + givenAmountValid(amount_) + givenMigratorHasFrom(amount_) + givenMigratorHasWrappedFrom(amount_) + { + migrateAndValidateInactive(); + } + + // migrate + // when called by an address with the "heart" role and when the contract is NOT locally active + // when the TRSRY contract has a non-zero balance of from reserves + // when the reserve migrator has a zero balance of from and sFrom reserves + // it does nothing + function test_migrate_whenNotLocallyActive_whenTreasuryNonZeroReserves_whenMigratorZero( + uint256 amount_ + ) public givenInactive givenAmountValid(amount_) givenTreasuryHasFrom(amount_) { + migrateAndValidateInactive(); + } + + // migrate + // when called by an address with the "heart" role and when the contract is NOT locally active + // when the TRSRY contract has a non-zero balance of from reserves + // when the reserve migrator has a non-zero balance of from reserves + // it does nothing + function test_migrate_whenNotLocallyActive_whenTreasuryNonZeroReserves_whenMigratorNonZeroReserves( + uint256 amount_ + ) + public + givenInactive + givenAmountValid(amount_) + givenTreasuryHasFrom(amount_) + givenMigratorHasFrom(amount_) + { + migrateAndValidateInactive(); + } + + // migrate + // when called by an address with the "heart" role and when the contract is NOT locally active + // when the TRSRY contract has a non-zero balance of from reserves + // when the reserve migrator has a non-zero balance of sFrom reserves + // it does nothing + function test_migrate_whenNotLocallyActive_whenTreasuryNonZeroReserves_whenMigratorNonZeroWrappedReserves( + uint256 amount_ + ) + public + givenInactive + givenAmountValid(amount_) + givenTreasuryHasFrom(amount_) + givenMigratorHasWrappedFrom(amount_) + { + migrateAndValidateInactive(); + } + + // migrate + // when called by an address with the "heart" role and when the contract is NOT locally active + // when the TRSRY contract has a non-zero balance of from reserves + // when the reserve migrator has a non-zero balance of from and sFrom reserves + // it does nothing + function test_migrate_whenNotLocallyActive_whenTreasuryNonZeroReserves_whenMigratorNonZeroBoth( + uint256 amount_ + ) + public + givenInactive + givenAmountValid(amount_) + givenTreasuryHasFrom(amount_) + givenMigratorHasFrom(amount_) + givenMigratorHasWrappedFrom(amount_) + { + migrateAndValidateInactive(); + } + + // migrate + // when called by an address with the "heart" role and when the contract is NOT locally active + // when the TRSRY contract has a non-zero balance of sFrom reserves + // when the reserve migrator has a zero balance of from and sFrom reserves + // it does nothing + function test_migrate_whenNotLocallyActive_whenTreasuryNonZeroWrappedReserves_whenMigratorZero( + uint256 amount_ + ) public givenInactive givenAmountValid(amount_) givenTreasuryHasWrappedFrom(amount_) { + migrateAndValidateInactive(); + } + + // migrate + // when called by an address with the "heart" role and when the contract is NOT locally active + // when the TRSRY contract has a non-zero balance of sFrom reserves + // when the reserve migrator has a non-zero balance of from reserves + // it does nothing + function test_migrate_whenNotLocallyActive_whenTreasuryNonZeroWrappedReserves_whenMigratorNonZeroReserves( + uint256 amount_ + ) + public + givenInactive + givenAmountValid(amount_) + givenTreasuryHasWrappedFrom(amount_) + givenMigratorHasFrom(amount_) + { + migrateAndValidateInactive(); + } + + // migrate + // when called by an address with the "heart" role and when the contract is NOT locally active + // when the TRSRY contract has a non-zero balance of sFrom reserves + // when the reserve migrator has a non-zero balance of sFrom reserves + // it does nothing + function test_migrate_whenNotLocallyActive_whenTreasuryNonZeroWrappedReserves_whenMigratorNonZeroWrappedReserves( + uint256 amount_ + ) + public + givenInactive + givenAmountValid(amount_) + givenTreasuryHasWrappedFrom(amount_) + givenMigratorHasWrappedFrom(amount_) + { + migrateAndValidateInactive(); + } + + // migrate + // when called by an address with the "heart" role and when the contract is NOT locally active + // when the TRSRY contract has a non-zero balance of sFrom reserves + // when the reserve migrator has a non-zero balance of from and sFrom reserves + // it does nothing + function test_migrate_whenNotLocallyActive_whenTreasuryNonZeroWrappedReserves_whenMigratorNonZeroBoth( + uint256 amount_ + ) + public + givenInactive + givenAmountValid(amount_) + givenTreasuryHasWrappedFrom(amount_) + givenMigratorHasFrom(amount_) + givenMigratorHasWrappedFrom(amount_) + { + migrateAndValidateInactive(); + } + + // migrate + // when called by an address with the "heart" role and when the contract is NOT locally active + // when the TRSRY contract has a non-zero balance of from and sFrom reserves + // when the reserve migrator has a zero balance of from and sFrom reserves + // it does nothing + function test_migrate_whenNotLocallyActive_whenTreasuryNonZeroBoth_whenMigratorZero( + uint256 amount_ + ) + public + givenInactive + givenAmountValid(amount_) + givenTreasuryHasFrom(amount_) + givenTreasuryHasWrappedFrom(amount_) + { + migrateAndValidateInactive(); + } + + // migrate + // when called by an address with the "heart" role and when the contract is NOT locally active + // when the TRSRY contract has a non-zero balance of from and sFrom reserves + // when the reserve migrator has a non-zero balance of from reserves + // it does nothing + function test_migrate_whenNotLocallyActive_whenTreasuryNonZeroBoth_whenMigratorNonZeroReserves( + uint256 amount_ + ) + public + givenInactive + givenAmountValid(amount_) + givenTreasuryHasFrom(amount_) + givenTreasuryHasWrappedFrom(amount_) + givenMigratorHasFrom(amount_) + { + migrateAndValidateInactive(); + } + + // migrate + // when called by an address with the "heart" role and when the contract is NOT locally active + // when the TRSRY contract has a non-zero balance of from and sFrom reserves + // when the reserve migrator has a non-zero balance of sFrom reserves + // it does nothing + function test_migrate_whenNotLocallyActive_whenTreasuryNonZeroBoth_whenMigratorNonZeroWrappedReserves( + uint256 amount_ + ) + public + givenInactive + givenAmountValid(amount_) + givenTreasuryHasFrom(amount_) + givenTreasuryHasWrappedFrom(amount_) + givenMigratorHasWrappedFrom(amount_) + { + migrateAndValidateInactive(); + } + + // migrate + // when called by an address with the "heart" role and when the contract is NOT locally active + // when the TRSRY contract has a non-zero balance of from and sFrom reserves + // when the reserve migrator has a non-zero balance of from and sFrom reserves + // it does nothing + function test_migrate_whenNotLocallyActive_whenTreasuryNonZeroBoth_whenMigratorNonZeroBoth( + uint256 amount_ + ) + public + givenInactive + givenAmountValid(amount_) + givenTreasuryHasFrom(amount_) + givenTreasuryHasWrappedFrom(amount_) + givenMigratorHasFrom(amount_) + givenMigratorHasWrappedFrom(amount_) + { + migrateAndValidateInactive(); + } +} diff --git a/src/test/policies/YieldRepurchaseFacility.t.sol b/src/test/policies/YieldRepurchaseFacility.t.sol index 1a107238..0d99e982 100644 --- a/src/test/policies/YieldRepurchaseFacility.t.sol +++ b/src/test/policies/YieldRepurchaseFacility.t.sol @@ -15,6 +15,7 @@ import {MockERC4626, ERC4626} from "solmate/test/utils/mocks/MockERC4626.sol"; import {MockPrice} from "src/test/mocks/MockPrice.sol"; import {MockOhm} from "src/test/mocks/MockOhm.sol"; import {MockClearinghouse} from "src/test/mocks/MockClearinghouse.sol"; +import {ModuleTestFixtureGenerator} from "src/test/lib/ModuleTestFixtureGenerator.sol"; import {IBondSDA} from "interfaces/IBondSDA.sol"; import {IBondAggregator} from "interfaces/IBondAggregator.sol"; @@ -26,6 +27,7 @@ import {OlympusRange} from "modules/RANGE/OlympusRange.sol"; import {OlympusTreasury} from "modules/TRSRY/OlympusTreasury.sol"; import {OlympusMinter} from "modules/MINTR/OlympusMinter.sol"; import {OlympusRoles} from "modules/ROLES/OlympusRoles.sol"; +import {OlympusClearinghouseRegistry} from "modules/CHREG/OlympusClearinghouseRegistry.sol"; import {RolesAdmin} from "policies/RolesAdmin.sol"; import {YieldRepurchaseFacility} from "policies/YieldRepurchaseFacility.sol"; import {Operator} from "policies/Operator.sol"; @@ -33,6 +35,8 @@ import {BondCallback} from "policies/BondCallback.sol"; // solhint-disable-next-line max-states-count contract YieldRepurchaseFacilityTest is Test { + using ModuleTestFixtureGenerator for OlympusClearinghouseRegistry; + using FullMath for uint256; UserFactory public userCreator; @@ -48,7 +52,8 @@ contract YieldRepurchaseFacilityTest is Test { BondFixedTermSDA internal auctioneer; MockOhm internal ohm; MockERC20 internal reserve; - MockERC4626 internal wrappedReserve; + MockERC4626 internal sReserve; + MockERC20 internal oldReserve; Kernel internal kernel; MockPrice internal PRICE; @@ -56,6 +61,8 @@ contract YieldRepurchaseFacilityTest is Test { OlympusTreasury internal TRSRY; OlympusMinter internal MINTR; OlympusRoles internal ROLES; + OlympusClearinghouseRegistry internal CHREG; + address internal godmode; MockClearinghouse internal clearinghouse; YieldRepurchaseFacility internal yieldRepo; @@ -95,7 +102,8 @@ contract YieldRepurchaseFacilityTest is Test { /// Deploy mock tokens ohm = new MockOhm("Olympus", "OHM", 9); reserve = new MockERC20("Reserve", "RSV", 18); - wrappedReserve = new MockERC4626(reserve, "wrappedReserve", "sRSV"); + oldReserve = new MockERC20("Old Reserve", "oRSV", 18); + sReserve = new MockERC4626(reserve, "sReserve", "sRSV"); } { @@ -116,6 +124,14 @@ contract YieldRepurchaseFacilityTest is Test { MINTR = new OlympusMinter(kernel, address(ohm)); ROLES = new OlympusRoles(kernel); + // Deploy mock clearinghouse and registry + clearinghouse = new MockClearinghouse(address(reserve), address(sReserve)); + CHREG = new OlympusClearinghouseRegistry( + kernel, + address(clearinghouse), + new address[](0) + ); + /// Configure mocks PRICE.setMovingAverage(10 * 1e18); PRICE.setLastPrice(10 * 1e18); @@ -124,9 +140,6 @@ contract YieldRepurchaseFacilityTest is Test { } { - // Deploy mock clearinghouse - clearinghouse = new MockClearinghouse(address(reserve), address(wrappedReserve)); - /// Deploy bond callback callback = new BondCallback(kernel, IBondAggregator(address(aggregator)), ohm); @@ -135,7 +148,7 @@ contract YieldRepurchaseFacilityTest is Test { kernel, IBondSDA(address(auctioneer)), callback, - [address(ohm), address(reserve), address(wrappedReserve)], + [address(ohm), address(reserve), address(sReserve), address(oldReserve)], [ uint32(2000), // cushionFactor uint32(5 days), // duration @@ -153,11 +166,9 @@ contract YieldRepurchaseFacilityTest is Test { yieldRepo = new YieldRepurchaseFacility( kernel, address(ohm), - address(reserve), - address(wrappedReserve), + address(sReserve), address(teller), - address(auctioneer), - address(clearinghouse) + address(auctioneer) ); /// Deploy ROLES administrator @@ -173,6 +184,7 @@ contract YieldRepurchaseFacilityTest is Test { kernel.executeAction(Actions.InstallModule, address(TRSRY)); kernel.executeAction(Actions.InstallModule, address(MINTR)); kernel.executeAction(Actions.InstallModule, address(ROLES)); + kernel.executeAction(Actions.InstallModule, address(CHREG)); /// Approve policies kernel.executeAction(Actions.ActivatePolicy, address(yieldRepo)); @@ -200,20 +212,20 @@ contract YieldRepurchaseFacilityTest is Test { reserve.mint(address(TRSRY), testReserve * 80); reserve.mint(address(clearinghouse), testReserve * 20); - // Deposit TRSRY reserves into wrappedReserve + // Deposit TRSRY reserves into sReserve vm.startPrank(address(TRSRY)); - reserve.approve(address(wrappedReserve), testReserve * 80); - wrappedReserve.deposit(testReserve * 80, address(TRSRY)); + reserve.approve(address(sReserve), testReserve * 80); + sReserve.deposit(testReserve * 80, address(TRSRY)); vm.stopPrank(); - // Deposit clearinghouse reserves into wrappedReserve + // Deposit clearinghouse reserves into sReserve vm.startPrank(address(clearinghouse)); - reserve.approve(address(wrappedReserve), testReserve * 20); - wrappedReserve.deposit(testReserve * 20, address(clearinghouse)); + reserve.approve(address(sReserve), testReserve * 20); + sReserve.deposit(testReserve * 20, address(clearinghouse)); vm.stopPrank(); // Mint additional reserve to the wrapped reserve to hit the initial conversion rate - reserve.mint(address(wrappedReserve), 5 * testReserve); + reserve.mint(address(sReserve), 5 * testReserve); // Approve the bond teller for the tokens to swap vm.prank(alice); @@ -232,14 +244,14 @@ contract YieldRepurchaseFacilityTest is Test { } function _mintYield() internal { - // Get the balance of reserves in the wrappedReserve contract - uint256 wrappedReserveBalance = wrappedReserve.totalAssets(); + // Get the balance of reserves in the sReserve contract + uint256 sReserveBalance = sReserve.totalAssets(); // Calculate the yield to mint (0.01%) - uint256 yield = wrappedReserveBalance / 10000; + uint256 yield = sReserveBalance / 10000; // Mint the yield - reserve.mint(address(wrappedReserve), yield); + reserve.mint(address(sReserve), yield); } // test cases @@ -273,8 +285,8 @@ contract YieldRepurchaseFacilityTest is Test { function test_setup() public { // addresses are set correctly assertEq(address(yieldRepo.ohm()), address(ohm)); - assertEq(address(yieldRepo.dai()), address(reserve)); - assertEq(address(yieldRepo.sdai()), address(wrappedReserve)); + assertEq(address(yieldRepo.reserve()), address(reserve)); + assertEq(address(yieldRepo.sReserve()), address(sReserve)); assertEq(address(yieldRepo.teller()), address(teller)); assertEq(address(yieldRepo.auctioneer()), address(auctioneer)); @@ -284,7 +296,7 @@ contract YieldRepurchaseFacilityTest is Test { // initial conversion rate is set correctly assertEq(yieldRepo.lastConversionRate(), initialConversionRate); - assertEq((wrappedReserve.totalAssets() * 1e18) / wrappedReserve.totalSupply(), 1_05e16); + assertEq((sReserve.totalAssets() * 1e18) / sReserve.totalSupply(), 1_05e16); // initial yield is set correctly assertEq(yieldRepo.nextYield(), initialYield); @@ -294,29 +306,29 @@ contract YieldRepurchaseFacilityTest is Test { } function test_endEpoch_firstCall_currentLessThanWall() public { - // Mint yield to the wrappedReserve + // Mint yield to the sReserve _mintYield(); // Get the ID of the next bond market from the aggregator uint256 nextBondMarketId = aggregator.marketCounter(); // Cache the TRSRY sDAI balance - uint256 trsryBalance = wrappedReserve.balanceOf(address(TRSRY)); + uint256 trsryBalance = sReserve.balanceOf(address(TRSRY)); vm.prank(heart); yieldRepo.endEpoch(); // Check that the initial yield was withdrawn from the TRSRY assertEq( - wrappedReserve.balanceOf(address(TRSRY)), - trsryBalance - wrappedReserve.previewWithdraw(initialYield) + sReserve.balanceOf(address(TRSRY)), + trsryBalance - sReserve.previewWithdraw(initialYield) ); // Check that the yieldRepo contract has the correct reserve balance assertEq(reserve.balanceOf(address(yieldRepo)), initialYield / 7); assertEq( - wrappedReserve.balanceOf(address(yieldRepo)), - wrappedReserve.previewDeposit(initialYield - initialYield / 7) + sReserve.balanceOf(address(yieldRepo)), + sReserve.previewDeposit(initialYield - initialYield / 7) ); // Check that the bond market was created @@ -368,29 +380,29 @@ contract YieldRepurchaseFacilityTest is Test { // Change the current price to be greater than the wall PRICE.setLastPrice(15 * 1e18); - // Mint yield to the wrappedReserve + // Mint yield to the sReserve _mintYield(); // Get the ID of the next bond market from the aggregator uint256 nextBondMarketId = aggregator.marketCounter(); // Cache the TRSRY sDAI balance - uint256 trsryBalance = wrappedReserve.balanceOf(address(TRSRY)); + uint256 trsryBalance = sReserve.balanceOf(address(TRSRY)); vm.prank(heart); yieldRepo.endEpoch(); // Check that the initial yield was withdrawn from the TRSRY assertEq( - wrappedReserve.balanceOf(address(TRSRY)), - trsryBalance - wrappedReserve.previewWithdraw(initialYield) + sReserve.balanceOf(address(TRSRY)), + trsryBalance - sReserve.previewWithdraw(initialYield) ); // Check that the yieldRepo contract has the correct reserve balance assertEq(reserve.balanceOf(address(yieldRepo)), initialYield / 7); assertEq( - wrappedReserve.balanceOf(address(yieldRepo)), - wrappedReserve.previewDeposit(initialYield - initialYield / 7) + sReserve.balanceOf(address(yieldRepo)), + sReserve.previewDeposit(initialYield - initialYield / 7) ); // Check that a bond market was not created @@ -402,49 +414,49 @@ contract YieldRepurchaseFacilityTest is Test { vm.prank(guardian); yieldRepo.shutdown(new ERC20[](0)); - // Mint yield to the wrappedReserve + // Mint yield to the sReserve _mintYield(); // Get the ID of the next bond market from the aggregator uint256 nextBondMarketId = aggregator.marketCounter(); // Cache the TRSRY sDAI balance - uint256 trsryBalance = wrappedReserve.balanceOf(address(TRSRY)); + uint256 trsryBalance = sReserve.balanceOf(address(TRSRY)); vm.prank(heart); yieldRepo.endEpoch(); // Check that the initial yield was not withdrawn from the treasury - assertEq(wrappedReserve.balanceOf(address(TRSRY)), trsryBalance); + assertEq(sReserve.balanceOf(address(TRSRY)), trsryBalance); // Check that the yieldRepo contract has not received any funds assertEq(reserve.balanceOf(address(yieldRepo)), 0); - assertEq(wrappedReserve.balanceOf(address(yieldRepo)), 0); + assertEq(sReserve.balanceOf(address(yieldRepo)), 0); // Check that the bond market was not created assertEq(aggregator.marketCounter(), nextBondMarketId); } function test_endEpoch_notDivisBy3() public { - // Mint yield to the wrappedReserve + // Mint yield to the sReserve _mintYield(); // Make the initial call to get the epoch counter to reset vm.prank(heart); yieldRepo.endEpoch(); - // Mint yield to the wrappedReserve + // Mint yield to the sReserve _mintYield(); // Get the ID of the next bond market from the aggregator uint256 nextBondMarketId = aggregator.marketCounter(); // Cache the TRSRY sDAI balance - uint256 trsryBalance = wrappedReserve.balanceOf(address(TRSRY)); + uint256 trsryBalance = sReserve.balanceOf(address(TRSRY)); // Cache the yieldRepo contract reserve balance uint256 yieldRepoReserveBalance = reserve.balanceOf(address(yieldRepo)); - uint256 yieldRepoWrappedReserveBalance = wrappedReserve.balanceOf(address(yieldRepo)); + uint256 yieldRepoWrappedReserveBalance = sReserve.balanceOf(address(yieldRepo)); // Call end epoch again vm.prank(heart); @@ -454,18 +466,18 @@ contract YieldRepurchaseFacilityTest is Test { assertEq(aggregator.marketCounter(), nextBondMarketId); // Check that the treasury balance has not changed - assertEq(wrappedReserve.balanceOf(address(TRSRY)), trsryBalance); + assertEq(sReserve.balanceOf(address(TRSRY)), trsryBalance); // Check that the yieldRepo contract reserve balance has not changed assertEq(reserve.balanceOf(address(yieldRepo)), yieldRepoReserveBalance); - assertEq(wrappedReserve.balanceOf(address(yieldRepo)), yieldRepoWrappedReserveBalance); + assertEq(sReserve.balanceOf(address(yieldRepo)), yieldRepoWrappedReserveBalance); // Check that the epoch has been incremented assertEq(yieldRepo.epoch(), 1); } function test_endEpoch_divisBy3_notEpochLength() public { - // Mint yield to the wrappedReserve + // Mint yield to the sReserve _mintYield(); // Make the initial call to get the epoch counter to reset @@ -483,7 +495,7 @@ contract YieldRepurchaseFacilityTest is Test { // Cache the yieldRepo contract reserve balance before any bonds are issued uint256 yieldRepoReserveBalance = reserve.balanceOf(address(yieldRepo)); - uint256 yieldRepoWrappedReserveBalance = wrappedReserve.balanceOf(address(yieldRepo)); + uint256 yieldRepoWrappedReserveBalance = sReserve.balanceOf(address(yieldRepo)); // Purchase a bond from the existing bond market // So that there is some OHM in the contract to burn @@ -497,14 +509,14 @@ contract YieldRepurchaseFacilityTest is Test { // Warp forward a day so that the initial bond market ends vm.warp(block.timestamp + 1 days); - // Mint yield to the wrappedReserve + // Mint yield to the sReserve _mintYield(); // Get the ID of the next bond market from the aggregator uint256 nextBondMarketId = aggregator.marketCounter(); // Cache the TRSRY sDAI balance - uint256 trsryBalance = wrappedReserve.balanceOf(address(TRSRY)); + uint256 trsryBalance = sReserve.balanceOf(address(TRSRY)); // Cache the OHM balance in the yieldRepo contract uint256 yieldRepoOhmBalance = ohm.balanceOf(address(yieldRepo)); @@ -521,22 +533,22 @@ contract YieldRepurchaseFacilityTest is Test { assertEq(ohm.balanceOf(address(yieldRepo)), 0); // Check that the treasury balance has changed by the amount of backing withdrawn for the burnt OHM - uint256 daiFromBurnedOhm = 100e9 * yieldRepo.backingPerToken(); + uint256 reserveFromBurnedOhm = 100e9 * yieldRepo.backingPerToken(); assertEq( - wrappedReserve.balanceOf(address(TRSRY)), - trsryBalance - wrappedReserve.previewWithdraw(daiFromBurnedOhm) + sReserve.balanceOf(address(TRSRY)), + trsryBalance - sReserve.previewWithdraw(reserveFromBurnedOhm) ); // Check that the balance of the yieldRepo contract has changed correctly uint256 expectedBidAmount = (yieldRepoReserveBalance + - wrappedReserve.previewRedeem(yieldRepoWrappedReserveBalance) + - daiFromBurnedOhm) / 6; + sReserve.previewRedeem(yieldRepoWrappedReserveBalance) + + reserveFromBurnedOhm) / 6; // Check that the yieldRepo contract reserve balances have changed correctly assertEq(reserve.balanceOf(address(yieldRepo)), expectedBidAmount); assertGe( - wrappedReserve.balanceOf(address(yieldRepo)), - yieldRepoWrappedReserveBalance - wrappedReserve.previewWithdraw(expectedBidAmount) + sReserve.balanceOf(address(yieldRepo)), + yieldRepoWrappedReserveBalance - sReserve.previewWithdraw(expectedBidAmount) ); // Confirm that the bond market has the correct configuration @@ -576,7 +588,7 @@ contract YieldRepurchaseFacilityTest is Test { // error ROLES_RequireRole(bytes32 role_); function test_adjustNextYield() public { - // Mint yield to the wrappedReserve + // Mint yield to the sReserve _mintYield(); // Call endEpoch to set the next yield @@ -647,16 +659,16 @@ contract YieldRepurchaseFacilityTest is Test { // Cache the yieldRepo contract reserve balances uint256 yieldRepoReserveBalance = reserve.balanceOf(address(yieldRepo)); - uint256 yieldRepoWrappedReserveBalance = wrappedReserve.balanceOf(address(yieldRepo)); + uint256 yieldRepoWrappedReserveBalance = sReserve.balanceOf(address(yieldRepo)); // Cache the treasury balances of the reserve tokens uint256 trsryReserveBalance = reserve.balanceOf(address(TRSRY)); - uint256 trsryWrappedReserveBalance = wrappedReserve.balanceOf(address(TRSRY)); + uint256 trsryWrappedReserveBalance = sReserve.balanceOf(address(TRSRY)); // Setup array of tokens to extract ERC20[] memory tokens = new ERC20[](2); tokens[0] = reserve; - tokens[1] = wrappedReserve; + tokens[1] = sReserve; // Call shutdown with an invalid caller // Expect it to fail @@ -676,10 +688,10 @@ contract YieldRepurchaseFacilityTest is Test { // Check that the yieldRepo contract reserve balances have been transferred to the TRSRY assertEq(reserve.balanceOf(address(yieldRepo)), 0); - assertEq(wrappedReserve.balanceOf(address(yieldRepo)), 0); + assertEq(sReserve.balanceOf(address(yieldRepo)), 0); assertEq(reserve.balanceOf(address(TRSRY)), trsryReserveBalance + yieldRepoReserveBalance); assertEq( - wrappedReserve.balanceOf(address(TRSRY)), + sReserve.balanceOf(address(TRSRY)), trsryWrappedReserveBalance + yieldRepoWrappedReserveBalance ); } @@ -693,18 +705,44 @@ contract YieldRepurchaseFacilityTest is Test { yieldRepo.endEpoch(); // Cache yield earning balances in the clearinghouse and treasury - uint256 clearinghouseWrappedReserveBalance = wrappedReserve.balanceOf( - address(clearinghouse) - ); - uint256 trsryWrappedReserveBalance = wrappedReserve.balanceOf(address(TRSRY)); + uint256 clearinghouseWrappedReserveBalance = sReserve.balanceOf(address(clearinghouse)); + uint256 trsryWrappedReserveBalance = sReserve.balanceOf(address(TRSRY)); // Calculate the expected yield earning reserve balance, in reserves - uint256 expectedYieldEarningReserveBalance = wrappedReserve.previewRedeem( + uint256 expectedYieldEarningReserveBalance = sReserve.previewRedeem( clearinghouseWrappedReserveBalance + trsryWrappedReserveBalance ); // Confirm the view function matches assertEq(yieldRepo.getReserveBalance(), expectedYieldEarningReserveBalance); + + // Add new active clearinghouse and mint it some reserves + MockClearinghouse newClearinghouse = new MockClearinghouse( + address(reserve), + address(sReserve) + ); + reserve.mint(address(newClearinghouse), 1_000_000e18); + vm.startPrank(address(newClearinghouse)); + reserve.approve(address(sReserve), 1_000_000e18); + sReserve.deposit(1_000_000e18, address(newClearinghouse)); + vm.stopPrank(); + + // Register the new clearinghouse + godmode = CHREG.generateGodmodeFixture(type(OlympusClearinghouseRegistry).name); + kernel.executeAction(Actions.ActivatePolicy, godmode); + vm.prank(godmode); + CHREG.activateClearinghouse(address(newClearinghouse)); + + // Get the total yield earning balance with the new clearinghouse included + uint256 totalWrappedReserveBalance = clearinghouseWrappedReserveBalance + + trsryWrappedReserveBalance + + sReserve.balanceOf(address(newClearinghouse)); + + // Calculate the expected yield earning reserve balance, in reserves + expectedYieldEarningReserveBalance = sReserve.previewRedeem(totalWrappedReserveBalance); + + // Confirm the view function matches + assertEq(yieldRepo.getReserveBalance(), expectedYieldEarningReserveBalance); } function test_getNextYield() public { @@ -717,12 +755,11 @@ contract YieldRepurchaseFacilityTest is Test { // Get the "last values" from the yieldRepo contract uint256 lastReserveBalance = yieldRepo.lastReserveBalance(); - uint256 lastConversionRate = yieldRepo.lastConversionRate(); // Get the principal receivables from the clearinghouse uint256 principalReceivables = clearinghouse.principalReceivables(); - // Mint additional yield to the wrappedReserve + // Mint additional yield to the sReserve _mintYield(); // Calculate the expected next yield @@ -734,5 +771,36 @@ contract YieldRepurchaseFacilityTest is Test { // Confirm the view function matches assertEq(yieldRepo.getNextYield(), expectedNextYield); + + // Add new active clearinghouse and mint it some reserves + MockClearinghouse newClearinghouse = new MockClearinghouse( + address(reserve), + address(sReserve) + ); + newClearinghouse.setPrincipalReceivables(1_000_000e18); + + // Register the new clearinghouse + godmode = CHREG.generateGodmodeFixture(type(OlympusClearinghouseRegistry).name); + kernel.executeAction(Actions.ActivatePolicy, godmode); + vm.prank(godmode); + CHREG.activateClearinghouse(address(newClearinghouse)); + + // Recalculate the expected next yield + expectedNextYield = + lastReserveBalance / + 10000 + + ((clearinghouse.principalReceivables() + newClearinghouse.principalReceivables()) * 5) / + 1000 / + 52; + + // Confirm the view function matches + assertEq(yieldRepo.getNextYield(), expectedNextYield); + + // Deactivate the old clearinghouse, ensure its receivables are still included + vm.prank(godmode); + CHREG.deactivateClearinghouse(address(clearinghouse)); + + // Confirm the view function matches + assertEq(yieldRepo.getNextYield(), expectedNextYield); } } diff --git a/src/test/proposals/EmissionManagerProposal.t.sol b/src/test/proposals/EmissionManagerProposal.t.sol new file mode 100644 index 00000000..c66083dc --- /dev/null +++ b/src/test/proposals/EmissionManagerProposal.t.sol @@ -0,0 +1,84 @@ +// 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"; + +// 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. + 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); + + /// @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"); + } + } + } + + // [DO NOT DELETE] Dummy test to ensure `setUp` is executed and the proposal simulated. + function testProposal_simulate() public { + assertTrue(true); + } +} diff --git a/src/test/proposals/OIP_166.t.sol b/src/test/proposals/OIP_166.t.sol index 182d06c1..414999fa 100644 --- a/src/test/proposals/OIP_166.t.sol +++ b/src/test/proposals/OIP_166.t.sol @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.0; // Proposal test-suite imports diff --git a/src/test/proposals/OIP_168.t.sol b/src/test/proposals/OIP_168.t.sol new file mode 100644 index 00000000..7241819e --- /dev/null +++ b/src/test/proposals/OIP_168.t.sol @@ -0,0 +1,83 @@ +// 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"; + +// 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. + 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); + + /// @dev Deploy your proposal + OIP_168 proposal = new OIP_168(); + + /// @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); + } +} diff --git a/src/test/proposals/OIP_XXX.t.sol b/src/test/proposals/OIP_XXX.t.sol index d22ec6ee..80ba6b3d 100644 --- a/src/test/proposals/OIP_XXX.t.sol +++ b/src/test/proposals/OIP_XXX.t.sol @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.0; // Proposal test-suite imports diff --git a/src/test/sim/RangeSim.sol b/src/test/sim/RangeSim.sol index 3ec5371f..0949748b 100644 --- a/src/test/sim/RangeSim.sol +++ b/src/test/sim/RangeSim.sol @@ -41,6 +41,8 @@ import {MockPriceFeed} from "src/test/mocks/MockPriceFeed.sol"; import {RolesAdmin} from "policies/RolesAdmin.sol"; import {ZeroDistributor} from "policies/Distributor/ZeroDistributor.sol"; import {YieldRepurchaseFacility} from "policies/YieldRepurchaseFacility.sol"; +import {MockReserveMigrator} from "src/test/mocks/MockReserveMigrator.sol"; +import {MockEmissionManager} from "src/test/mocks/MockEmissionManager.sol"; import {TransferHelper} from "libraries/TransferHelper.sol"; import {FullMath} from "libraries/FullMath.sol"; @@ -199,6 +201,8 @@ abstract contract RangeSim is Test { RolesAdmin public rolesAdmin; ZeroDistributor public distributor; YieldRepurchaseFacility public yieldRepo; + MockReserveMigrator public reserveMigrator; + MockEmissionManager public emissionManager; mapping(uint32 => SimIO.Params) internal params; // map of sim keys to sim params mapping(uint32 => mapping(uint32 => int256)) internal netflows; // map of sim keys to epochs to netflows @@ -217,6 +221,7 @@ abstract contract RangeSim is Test { MockOhm internal ohm; MockERC20 internal reserve; MockERC4626 internal wrappedReserve; + MockERC20 internal oldReserve; ZuniswapV2Factory internal lpFactory; ZuniswapV2Pair internal pool; ZuniswapV2Router internal router; @@ -290,6 +295,7 @@ abstract contract RangeSim is Test { ohm = new MockOhm("Olympus", "OHM", 9); require(address(reserve) < address(ohm)); // ensure reserve is token0 in the LP pool wrappedReserve = new MockERC4626(reserve, "Wrapped Reserve", "WRSV"); + oldReserve = new MockERC20("Old Reserve", "ORSV", 18); ohmEthPriceFeed = new MockPriceFeed(); ohmEthPriceFeed.setDecimals(18); @@ -385,7 +391,7 @@ abstract contract RangeSim is Test { kernel, IBondSDA(address(auctioneer)), callback, - [address(ohm), address(reserve), address(wrappedReserve)], + [address(ohm), address(reserve), address(wrappedReserve), address(oldReserve)], [ _params.cushionFactor, // cushionFactor uint32(vm.envUint("CUSHION_DURATION")), // duration @@ -404,12 +410,12 @@ abstract contract RangeSim is Test { yieldRepo = new YieldRepurchaseFacility( kernel, address(ohm), - address(reserve), address(wrappedReserve), address(teller), - address(auctioneer), - address(0) // no clearinghouse + address(auctioneer) ); + reserveMigrator = new MockReserveMigrator(); + emissionManager = new MockEmissionManager(); // Deploy PriceConfig priceConfig = new OlympusPriceConfig(kernel); @@ -420,6 +426,8 @@ abstract contract RangeSim is Test { operator, distributor, yieldRepo, + reserveMigrator, + emissionManager, uint256(0), // no keeper rewards for sim uint48(0) // no keeper rewards for sim ); @@ -450,8 +458,8 @@ abstract contract RangeSim is Test { // Configure access control // Operator roles - rolesAdmin.grantRole("operator_operate", address(heart)); - rolesAdmin.grantRole("operator_operate", guardian); + rolesAdmin.grantRole("heart", address(heart)); + rolesAdmin.grantRole("heart", guardian); rolesAdmin.grantRole("operator_reporter", address(callback)); rolesAdmin.grantRole("operator_policy", policy); rolesAdmin.grantRole("operator_admin", guardian);