From 17909eb8eb43625bab7ec6558e9339c0df61d996 Mon Sep 17 00:00:00 2001 From: cgewecke Date: Wed, 18 Aug 2021 17:04:35 -0700 Subject: [PATCH] Add Manager permissions for protected modules and extensions (#71) * Make fee extension setters use mutualUpgrade * Add BaseManagerV2 with module protection methods * Update tests for new BaseManager * StreamingFeeSplitExtension: add configurable operatorFeeRecipient * FeeSplitAdapter: add configurable operatorFeeRecipient * Send fees to fee extension * Update all specs to use BaseManagerV2 * Rename FeeSplitAdapter --> FeeSplitExtension * Add initialize method to StreamingFeeSplitExtension contract * Add initialize methods to FeeSplitExtension contract --- contracts/adapters/FeeSplitAdapter.sol | 143 -- contracts/adapters/FeeSplitExtension.sol | 243 +++ .../FlexibleLeverageStrategyExtension.sol | 68 +- contracts/adapters/GIMExtension.sol | 26 +- ...nceAdapter.sol => GovernanceExtension.sol} | 34 +- .../adapters/StreamingFeeSplitExtension.sol | 101 +- contracts/interfaces/IExtension.sol | 26 + contracts/interfaces/IIssuanceModule.sol | 11 +- contracts/interfaces/IStreamingFeeModule.sol | 10 +- .../{BaseAdapter.sol => BaseExtension.sol} | 10 +- contracts/manager/BaseManager.sol | 2 +- contracts/manager/BaseManagerV2.sol | 590 ++++++ ...eAdapterMock.sol => BaseExtensionMock.sol} | 6 +- contracts/mocks/TradeAdapterMock.sol | 2 +- hardhat.config.ts | 2 + tasks/subtasks.ts | 2 +- test/adapters/feeSplitAdapter.spec.ts | 609 ------ test/adapters/feeSplitExtension.spec.ts | 1101 +++++++++++ .../flexibleLeverageStrategyExtension.spec.ts | 23 +- test/adapters/gimExtension.spec.ts | 13 +- ...er.spec.ts => governanceExtension.spec.ts} | 51 +- .../streamingFeeSplitExtension.spec.ts | 440 ++++- .../exchangeIssuance/exchangeIssuance.spec.ts | 4 +- .../exchangeIssuanceV2.spec.ts | 4 +- test/integration/fliIntegration.spec.ts | 15 +- ...eAdapter.spec.ts => baseExtension.spec.ts} | 49 +- test/manager/baseManager.spec.ts | 8 +- test/manager/baseManagerV2.spec.ts | 1642 +++++++++++++++++ utils/contracts/index.ts | 10 +- ...{deployAdapters.ts => deployExtensions.ts} | 30 +- utils/deploys/deployManager.ts | 19 +- utils/deploys/deployMocks.ts | 8 +- utils/deploys/index.ts | 6 +- 33 files changed, 4280 insertions(+), 1028 deletions(-) delete mode 100644 contracts/adapters/FeeSplitAdapter.sol create mode 100644 contracts/adapters/FeeSplitExtension.sol rename contracts/adapters/{GovernanceAdapter.sol => GovernanceExtension.sol} (85%) create mode 100644 contracts/interfaces/IExtension.sol rename contracts/lib/{BaseAdapter.sol => BaseExtension.sol} (96%) create mode 100644 contracts/manager/BaseManagerV2.sol rename contracts/mocks/{BaseAdapterMock.sol => BaseExtensionMock.sol} (89%) delete mode 100644 test/adapters/feeSplitAdapter.spec.ts create mode 100644 test/adapters/feeSplitExtension.spec.ts rename test/adapters/{governanceAdapter.spec.ts => governanceExtension.spec.ts} (81%) rename test/lib/{baseAdapter.spec.ts => baseExtension.spec.ts} (82%) create mode 100644 test/manager/baseManagerV2.spec.ts rename utils/deploys/{deployAdapters.ts => deployExtensions.ts} (82%) diff --git a/contracts/adapters/FeeSplitAdapter.sol b/contracts/adapters/FeeSplitAdapter.sol deleted file mode 100644 index 9e02b9ed..00000000 --- a/contracts/adapters/FeeSplitAdapter.sol +++ /dev/null @@ -1,143 +0,0 @@ -/* - Copyright 2021 IndexCooperative - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -pragma solidity 0.6.10; - -import { Address } from "@openzeppelin/contracts/utils/Address.sol"; -import { SafeMath } from "@openzeppelin/contracts/math/SafeMath.sol"; - -import { BaseAdapter } from "../lib/BaseAdapter.sol"; -import { IIssuanceModule } from "../interfaces/IIssuanceModule.sol"; -import { IBaseManager } from "../interfaces/IBaseManager.sol"; -import { ISetToken } from "../interfaces/ISetToken.sol"; -import { IStreamingFeeModule } from "../interfaces/IStreamingFeeModule.sol"; -import { PreciseUnitMath } from "../lib/PreciseUnitMath.sol"; -import { TimeLockUpgrade } from "../lib/TimeLockUpgrade.sol"; - - -/** - * @title FeeSplitAdapter - * @author Set Protocol - * - * Smart contract adapter that allows for splitting and setting streaming and mint/redeem fees. - */ -contract FeeSplitAdapter is BaseAdapter, TimeLockUpgrade { - using Address for address; - using PreciseUnitMath for uint256; - using SafeMath for uint256; - - /* ============ Events ============ */ - - event FeesAccrued(address indexed _operator, address indexed _methodologist, uint256 _operatorTake, uint256 _methodologistTake); - - /* ============ State Variables ============ */ - - ISetToken public setToken; - IStreamingFeeModule public streamingFeeModule; - IIssuanceModule public issuanceModule; - - // Percent of fees in precise units (10^16 = 1%) sent to operator, rest to methodologist - uint256 public operatorFeeSplit; - - /* ============ Constructor ============ */ - - constructor( - IBaseManager _manager, - IStreamingFeeModule _streamingFeeModule, - IIssuanceModule _issuanceModule, - uint256 _operatorFeeSplit - ) - public - BaseAdapter(_manager) - { - streamingFeeModule = _streamingFeeModule; - issuanceModule = _issuanceModule; - operatorFeeSplit = _operatorFeeSplit; - setToken = manager.setToken(); - } - - /* ============ External Functions ============ */ - - /** - * ANYONE CALLABLE: Accrues fees from streaming fee module. Gets resulting balance after fee accrual, calculates fees for - * operator and methodologist, and sends to each. NOTE: mint/redeem fees will automatically be sent to this address so reading - * the balance of the SetToken in the contract after accrual is sufficient for accounting for all collected fees. - */ - function accrueFeesAndDistribute() public { - streamingFeeModule.accrueFee(setToken); - - uint256 totalFees = setToken.balanceOf(address(manager)); - - address operator = manager.operator(); - address methodologist = manager.methodologist(); - - uint256 operatorTake = totalFees.preciseMul(operatorFeeSplit); - uint256 methodologistTake = totalFees.sub(operatorTake); - - if (operatorTake > 0) { - invokeManagerTransfer(address(setToken), operator, operatorTake); - } - - if (methodologistTake > 0) { - invokeManagerTransfer(address(setToken), methodologist, methodologistTake); - } - - emit FeesAccrued(operator, methodologist, operatorTake, methodologistTake); - } - - /** - * ONLY OPERATOR: Updates streaming fee on StreamingFeeModule. NOTE: This will accrue streaming fees though not send to operator - * and methodologist. - */ - function updateStreamingFee(uint256 _newFee) external onlyOperator timeLockUpgrade { - bytes memory callData = abi.encodeWithSignature("updateStreamingFee(address,uint256)", manager.setToken(), _newFee); - invokeManager(address(streamingFeeModule), callData); - } - - /** - * ONLY OPERATOR: Updates issue fee on IssuanceModule. Only is executed once time lock has passed. - */ - function updateIssueFee(uint256 _newFee) external onlyOperator timeLockUpgrade { - bytes memory callData = abi.encodeWithSignature("updateIssueFee(address,uint256)", manager.setToken(), _newFee); - invokeManager(address(issuanceModule), callData); - } - - /** - * ONLY OPERATOR: Updates redeem fee on IssuanceModule. Only is executed once time lock has passed. - */ - function updateRedeemFee(uint256 _newFee) external onlyOperator timeLockUpgrade { - bytes memory callData = abi.encodeWithSignature("updateRedeemFee(address,uint256)", manager.setToken(), _newFee); - invokeManager(address(issuanceModule), callData); - } - - /** - * ONLY OPERATOR: Updates fee recipient on both streaming fee and issuance modules. - */ - function updateFeeRecipient(address _newFeeRecipient) external onlyOperator { - bytes memory callData = abi.encodeWithSignature("updateFeeRecipient(address,address)", manager.setToken(), _newFeeRecipient); - invokeManager(address(streamingFeeModule), callData); - invokeManager(address(issuanceModule), callData); - } - - /** - * ONLY OPERATOR: Updates fee split between operator and methodologist. Split defined in precise units (1% = 10^16). - */ - function updateFeeSplit(uint256 _newFeeSplit) external onlyOperator { - require(_newFeeSplit <= PreciseUnitMath.preciseUnit(), "Fee must be less than 100%"); - accrueFeesAndDistribute(); - operatorFeeSplit = _newFeeSplit; - } -} \ No newline at end of file diff --git a/contracts/adapters/FeeSplitExtension.sol b/contracts/adapters/FeeSplitExtension.sol new file mode 100644 index 00000000..59e88465 --- /dev/null +++ b/contracts/adapters/FeeSplitExtension.sol @@ -0,0 +1,243 @@ +/* + Copyright 2021 IndexCooperative + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +pragma solidity 0.6.10; +pragma experimental ABIEncoderV2; + +import { Address } from "@openzeppelin/contracts/utils/Address.sol"; +import { SafeMath } from "@openzeppelin/contracts/math/SafeMath.sol"; + +import { BaseExtension } from "../lib/BaseExtension.sol"; +import { IIssuanceModule } from "../interfaces/IIssuanceModule.sol"; +import { IBaseManager } from "../interfaces/IBaseManager.sol"; +import { ISetToken } from "../interfaces/ISetToken.sol"; +import { IStreamingFeeModule } from "../interfaces/IStreamingFeeModule.sol"; +import { PreciseUnitMath } from "../lib/PreciseUnitMath.sol"; +import { TimeLockUpgrade } from "../lib/TimeLockUpgrade.sol"; +import { MutualUpgrade } from "../lib/MutualUpgrade.sol"; + + +/** + * @title FeeSplitExtension + * @author Set Protocol + * + * Smart contract extension that allows for splitting and setting streaming and mint/redeem fees. + */ +contract FeeSplitExtension is BaseExtension, TimeLockUpgrade, MutualUpgrade { + using Address for address; + using PreciseUnitMath for uint256; + using SafeMath for uint256; + + /* ============ Events ============ */ + + event FeesDistributed( + address indexed _operatorFeeRecipient, + address indexed _methodologist, + uint256 _operatorTake, + uint256 _methodologistTake + ); + + /* ============ State Variables ============ */ + + ISetToken public setToken; + IStreamingFeeModule public streamingFeeModule; + IIssuanceModule public issuanceModule; + + // Percent of fees in precise units (10^16 = 1%) sent to operator, rest to methodologist + uint256 public operatorFeeSplit; + + // Address which receives operator's share of fees when they're distributed. (See IIP-72) + address public operatorFeeRecipient; + + /* ============ Constructor ============ */ + + constructor( + IBaseManager _manager, + IStreamingFeeModule _streamingFeeModule, + IIssuanceModule _issuanceModule, + uint256 _operatorFeeSplit, + address _operatorFeeRecipient + ) + public + BaseExtension(_manager) + { + streamingFeeModule = _streamingFeeModule; + issuanceModule = _issuanceModule; + operatorFeeSplit = _operatorFeeSplit; + operatorFeeRecipient = _operatorFeeRecipient; + setToken = manager.setToken(); + } + + /* ============ External Functions ============ */ + + /** + * ANYONE CALLABLE: Accrues fees from streaming fee module. Gets resulting balance after fee accrual, calculates fees for + * operator and methodologist, and sends to operator fee recipient and methodologist respectively. NOTE: mint/redeem fees + * will automatically be sent to this address so reading the balance of the SetToken in the contract after accrual is + * sufficient for accounting for all collected fees. + */ + function accrueFeesAndDistribute() public { + // Emits a FeeActualized event + streamingFeeModule.accrueFee(setToken); + + uint256 totalFees = setToken.balanceOf(address(this)); + + address methodologist = manager.methodologist(); + + uint256 operatorTake = totalFees.preciseMul(operatorFeeSplit); + uint256 methodologistTake = totalFees.sub(operatorTake); + + if (operatorTake > 0) { + setToken.transfer(operatorFeeRecipient, operatorTake); + } + + if (methodologistTake > 0) { + setToken.transfer(methodologist, methodologistTake); + } + + emit FeesDistributed(operatorFeeRecipient, methodologist, operatorTake, methodologistTake); + } + + /** + * MUTUAL UPGRADE: Initializes the issuance module. Operator and Methodologist must each call + * this function to execute the update. + * + * This method is called after invoking `replaceProtectedModule` or `emergencyReplaceProtectedModule` + * to configure the replacement streaming fee module's fee settings. + */ + function initializeIssuanceModule( + ISetToken _setToken, + uint256 _maxManagerFee, + uint256 _managerIssueFee, + uint256 _managerRedeemFee, + address _feeRecipient, + address _managerIssuanceHook + ) + external + mutualUpgrade(manager.operator(), manager.methodologist()) + { + bytes memory callData = abi.encodeWithSelector( + IIssuanceModule.initialize.selector, + manager.setToken(), + _maxManagerFee, + _managerIssueFee, + _managerRedeemFee, + _feeRecipient, + _managerIssuanceHook + ); + + invokeManager(address(issuanceModule), callData); + } + + /** + * MUTUAL UPGRADE: Initializes the issuance module. Operator and Methodologist must each call + * this function to execute the update. + * + * This method is called after invoking `replaceProtectedModule` or `emergencyReplaceProtectedModule` + * to configure the replacement streaming fee module's fee settings. + */ + function initializeStreamingFeeModule(IStreamingFeeModule.FeeState memory _settings) + external + mutualUpgrade(manager.operator(), manager.methodologist()) + { + bytes memory callData = abi.encodeWithSelector( + IStreamingFeeModule.initialize.selector, + manager.setToken(), + _settings + ); + + invokeManager(address(streamingFeeModule), callData); + } + + /** + * MUTUAL UPGRADE: Updates streaming fee on StreamingFeeModule. Operator and Methodologist must each call + * this function to execute the update. Because the method is timelocked, each party must call it twice: + * once to set the lock and once to execute. + * + * NOTE: This will accrue streaming fees though not send to operator fee recipient and methodologist. + */ + function updateStreamingFee(uint256 _newFee) + external + mutualUpgrade(manager.operator(), manager.methodologist()) + timeLockUpgrade + { + bytes memory callData = abi.encodeWithSignature("updateStreamingFee(address,uint256)", manager.setToken(), _newFee); + invokeManager(address(streamingFeeModule), callData); + } + + /** + * MUTUAL UPGRADE: Updates issue fee on IssuanceModule. Only is executed once time lock has passed. + * Operator and Methodologist must each call this function to execute the update. Because the method + * is timelocked, each party must call it twice: once to set the lock and once to execute. + */ + function updateIssueFee(uint256 _newFee) + external + mutualUpgrade(manager.operator(), manager.methodologist()) + timeLockUpgrade + { + bytes memory callData = abi.encodeWithSignature("updateIssueFee(address,uint256)", manager.setToken(), _newFee); + invokeManager(address(issuanceModule), callData); + } + + /** + * MUTUAL UPGRADE: Updates redeem fee on IssuanceModule. Only is executed once time lock has passed. + * Operator and Methodologist must each call this function to execute the update. Because the method is + * timelocked, each party must call it twice: once to set the lock and once to execute. + */ + function updateRedeemFee(uint256 _newFee) + external + mutualUpgrade(manager.operator(), manager.methodologist()) + timeLockUpgrade + { + bytes memory callData = abi.encodeWithSignature("updateRedeemFee(address,uint256)", manager.setToken(), _newFee); + invokeManager(address(issuanceModule), callData); + } + + /** + * MUTUAL UPGRADE: Updates fee recipient on both streaming fee and issuance modules. + */ + function updateFeeRecipient(address _newFeeRecipient) + external + mutualUpgrade(manager.operator(), manager.methodologist()) + { + bytes memory callData = abi.encodeWithSignature("updateFeeRecipient(address,address)", manager.setToken(), _newFeeRecipient); + invokeManager(address(streamingFeeModule), callData); + invokeManager(address(issuanceModule), callData); + } + + /** + * MUTUAL UPGRADE: Updates fee split between operator and methodologist. Split defined in precise units (1% = 10^16). + */ + function updateFeeSplit(uint256 _newFeeSplit) + external + mutualUpgrade(manager.operator(), manager.methodologist()) + { + require(_newFeeSplit <= PreciseUnitMath.preciseUnit(), "Fee must be less than 100%"); + accrueFeesAndDistribute(); + operatorFeeSplit = _newFeeSplit; + } + + /** + * OPERATOR ONLY: Updates the address that receives the operator's share of the fees (see IIP-72) + */ + function updateOperatorFeeRecipient(address _newOperatorFeeRecipient) + external + onlyOperator + { + require(_newOperatorFeeRecipient != address(0), "Zero address not valid"); + operatorFeeRecipient = _newOperatorFeeRecipient; + } +} diff --git a/contracts/adapters/FlexibleLeverageStrategyExtension.sol b/contracts/adapters/FlexibleLeverageStrategyExtension.sol index 3872505c..f91a417c 100644 --- a/contracts/adapters/FlexibleLeverageStrategyExtension.sol +++ b/contracts/adapters/FlexibleLeverageStrategyExtension.sol @@ -23,7 +23,7 @@ import { Math } from "@openzeppelin/contracts/math/Math.sol"; import { SafeMath } from "@openzeppelin/contracts/math/SafeMath.sol"; import { SafeCast } from "@openzeppelin/contracts/utils/SafeCast.sol"; -import { BaseAdapter } from "../lib/BaseAdapter.sol"; +import { BaseExtension } from "../lib/BaseExtension.sol"; import { ICErc20 } from "../interfaces/ICErc20.sol"; import { IBaseManager } from "../interfaces/IBaseManager.sol"; import { IChainlinkAggregatorV3 } from "../interfaces/IChainlinkAggregatorV3.sol"; @@ -50,7 +50,7 @@ import { StringArrayUtils } from "../lib/StringArrayUtils.sol"; * * CHANGELOG 5/24/2021: * - Update _calculateActionInfo to add chainlink prices - * - Update _calculateBorrowUnits and _calculateMinRepayUnits to use chainlink as an oracle in + * - Update _calculateBorrowUnits and _calculateMinRepayUnits to use chainlink as an oracle in * * CHANGELOG 6/29/2021: c55bd3cdb0fd43c03da9904493dcc23771ef0f71 * - Add ExchangeSettings struct that contains exchange specific information @@ -66,7 +66,7 @@ import { StringArrayUtils } from "../lib/StringArrayUtils.sol"; * - Add _updateLastTradeTimestamp function to update global and exchange specific timestamp * - Change contract name to FlexibleLeverageStrategyExtension */ -contract FlexibleLeverageStrategyExtension is BaseAdapter { +contract FlexibleLeverageStrategyExtension is BaseExtension { using Address for address; using PreciseUnitMath for uint256; using SafeMath for uint256; @@ -116,7 +116,7 @@ contract FlexibleLeverageStrategyExtension is BaseAdapter { uint256 borrowDecimalAdjustment; // Decimal adjustment for chainlink oracle of the borrowing asset. Equal to 28-borrowDecimals (10^18 * 10^18 / 10^decimals / 10^8) } - struct MethodologySettings { + struct MethodologySettings { uint256 targetLeverageRatio; // Long term target ratio in precise units (10e18) uint256 minLeverageRatio; // In precise units (10e18). If current leverage is below, rebalance target is this ratio uint256 maxLeverageRatio; // In precise units (10e18). If current leverage is above, rebalance target is this ratio @@ -124,7 +124,7 @@ contract FlexibleLeverageStrategyExtension is BaseAdapter { uint256 rebalanceInterval; // Period of time required since last rebalance timestamp in seconds } - struct ExecutionSettings { + struct ExecutionSettings { uint256 unutilizedLeveragePercentage; // Percent of max borrow left unutilized in precise units (1% = 10e16) uint256 slippageTolerance; // % in precise units to price min token receive amount from trade quantities uint256 twapCooldownPeriod; // Cooldown period required since last trade timestamp in seconds @@ -230,7 +230,7 @@ contract FlexibleLeverageStrategyExtension is BaseAdapter { /** * Instantiate addresses, methodology parameters, execution parameters, and incentive parameters. - * + * * @param _manager Address of IBaseManager contract * @param _strategy Struct of contract addresses * @param _methodology Struct containing methodology parameters @@ -249,7 +249,7 @@ contract FlexibleLeverageStrategyExtension is BaseAdapter { ExchangeSettings[] memory _exchangeSettings ) public - BaseAdapter(_manager) + BaseExtension(_manager) { strategy = _strategy; methodology = _methodology; @@ -313,7 +313,7 @@ contract FlexibleLeverageStrategyExtension is BaseAdapter { } /** - * ONLY EOA AND ALLOWED CALLER: Rebalance according to flexible leverage methodology. If current leverage ratio is between the max and min bounds, then rebalance + * ONLY EOA AND ALLOWED CALLER: Rebalance according to flexible leverage methodology. If current leverage ratio is between the max and min bounds, then rebalance * can only be called once the rebalance interval has elapsed since last timestamp. If outside the max and min, rebalance can be called anytime to bring leverage * ratio back to the max or min bounds. The methodology will determine whether to delever or lever. * @@ -363,7 +363,7 @@ contract FlexibleLeverageStrategyExtension is BaseAdapter { _exchangeName ); - // Use the exchangeLastTradeTimestamp since cooldown periods are measured on a per-exchange basis, allowing it to rebalance multiple time in quick + // Use the exchangeLastTradeTimestamp since cooldown periods are measured on a per-exchange basis, allowing it to rebalance multiple time in quick // succession with different exchanges _validateNormalRebalance(leverageInfo, execution.twapCooldownPeriod, exchangeSettings[_exchangeName].exchangeLastTradeTimestamp); _validateTWAP(); @@ -396,7 +396,7 @@ contract FlexibleLeverageStrategyExtension is BaseAdapter { */ function ripcord(string memory _exchangeName) external onlyEOA { LeverageInfo memory leverageInfo = _getAndValidateLeveragedInfo( - incentive.incentivizedSlippageTolerance, + incentive.incentivizedSlippageTolerance, exchangeSettings[_exchangeName].incentivizedTwapMaxTradeSize, _exchangeName ); @@ -424,7 +424,7 @@ contract FlexibleLeverageStrategyExtension is BaseAdapter { * OPERATOR ONLY: Return leverage ratio to 1x and delever to repay loan. This can be used for upgrading or shutting down the strategy. SetToken will redeem * collateral position and trade for debt position to repay Compound. If the chunk rebalance size is less than the total notional size, then this function will * delever and repay entire borrow balance on Compound. If chunk rebalance size is above max borrow or max trade size, then operator must - * continue to call this function to complete repayment of loan. The function iterateRebalance will not work. + * continue to call this function to complete repayment of loan. The function iterateRebalance will not work. * * Note: Delever to 0 will likely result in additional units of the borrow asset added as equity on the SetToken due to oracle price / market price mismatch * @@ -526,8 +526,8 @@ contract FlexibleLeverageStrategyExtension is BaseAdapter { string memory _exchangeName, ExchangeSettings memory _exchangeSettings ) - external - onlyOperator + external + onlyOperator { require(exchangeSettings[_exchangeName].twapMaxTradeSize == 0, "Exchange already enabled"); _validateExchangeSettings(_exchangeSettings); @@ -537,7 +537,7 @@ contract FlexibleLeverageStrategyExtension is BaseAdapter { exchangeSettings[_exchangeName].leverExchangeData = _exchangeSettings.leverExchangeData; exchangeSettings[_exchangeName].deleverExchangeData = _exchangeSettings.deleverExchangeData; exchangeSettings[_exchangeName].exchangeLastTradeTimestamp = 0; - + enabledExchanges.push(_exchangeName); emit ExchangeAdded( @@ -551,7 +551,7 @@ contract FlexibleLeverageStrategyExtension is BaseAdapter { } /** - * OPERATOR ONLY: Removes an exchange. Reverts if the exchange is not already enabled. Removing exchanges during rebalances is allowed, + * OPERATOR ONLY: Removes an exchange. Reverts if the exchange is not already enabled. Removing exchanges during rebalances is allowed, * as it is not possible to enter an unexpected state while doing so. * * @param _exchangeName Name of exchange to remove @@ -566,9 +566,9 @@ contract FlexibleLeverageStrategyExtension is BaseAdapter { } /** - * OPERATOR ONLY: Updates the settings of an exchange. Reverts if exchange is not already added. When updating an exchange, exchangeLastTradeTimestamp - * is preserved. Updating exchanges during rebalances is allowed, as it is not possible to enter an unexpected state while doing so. Note: Need to - * pass in all existing parameters even if only changing a few settings. + * OPERATOR ONLY: Updates the settings of an exchange. Reverts if exchange is not already added. When updating an exchange, exchangeLastTradeTimestamp + * is preserved. Updating exchanges during rebalances is allowed, as it is not possible to enter an unexpected state while doing so. Note: Need to + * pass in all existing parameters even if only changing a few settings. * * @param _exchangeName Name of the exchange * @param _exchangeSettings Struct containing exchange parameters @@ -597,7 +597,7 @@ contract FlexibleLeverageStrategyExtension is BaseAdapter { _exchangeSettings.deleverExchangeData ); } - + /** * OPERATOR ONLY: Withdraw entire balance of ETH in this contract to operator. Rebalance must not be in progress */ @@ -623,7 +623,7 @@ contract FlexibleLeverageStrategyExtension is BaseAdapter { /** * Calculates the chunk rebalance size. This can be used by external contracts and keeper bots to calculate the optimal exchange to rebalance with. - * Note: this function does not take into account timestamps, so it may return a nonzero value even when shouldRebalance would return ShouldRebalance.NONE for + * Note: this function does not take into account timestamps, so it may return a nonzero value even when shouldRebalance would return ShouldRebalance.NONE for * all exchanges (since minimum delays have not elapsed) * * @param _exchangeNames Array of exchange names to get rebalance sizes for @@ -634,7 +634,7 @@ contract FlexibleLeverageStrategyExtension is BaseAdapter { */ function getChunkRebalanceNotional( string[] calldata _exchangeNames - ) + ) external view returns(uint256[] memory sizes, address sellAsset, address buyAsset) @@ -662,13 +662,13 @@ contract FlexibleLeverageStrategyExtension is BaseAdapter { sizes = new uint256[](_exchangeNames.length); for (uint256 i = 0; i < _exchangeNames.length; i++) { - + LeverageInfo memory leverageInfo = LeverageInfo({ action: actionInfo, currentLeverageRatio: currentLeverageRatio, slippageTolerance: isRipcord ? incentive.incentivizedSlippageTolerance : execution.slippageTolerance, - twapMaxTradeSize: isRipcord ? - exchangeSettings[_exchangeNames[i]].incentivizedTwapMaxTradeSize : + twapMaxTradeSize: isRipcord ? + exchangeSettings[_exchangeNames[i]].incentivizedTwapMaxTradeSize : exchangeSettings[_exchangeNames[i]].twapMaxTradeSize, exchangeName: _exchangeNames[i] }); @@ -684,7 +684,7 @@ contract FlexibleLeverageStrategyExtension is BaseAdapter { } /** - * Get current Ether incentive for when current leverage ratio exceeds incentivized leverage ratio and ripcord can be called. If ETH balance on the contract is + * Get current Ether incentive for when current leverage ratio exceeds incentivized leverage ratio and ripcord can be called. If ETH balance on the contract is * below the etherReward, then return the balance of ETH instead. * * return etherReward Quantity of ETH reward in base units (10e18) @@ -859,7 +859,7 @@ contract FlexibleLeverageStrategyExtension is BaseAdapter { totalRebalanceNotional ) = _calculateChunkRebalanceNotional(_leverageInfo, _newLeverageRatio, false); - _delever(_leverageInfo, chunkRebalanceNotional); + _delever(_leverageInfo, chunkRebalanceNotional); } else { ( chunkRebalanceNotional, @@ -910,7 +910,7 @@ contract FlexibleLeverageStrategyExtension is BaseAdapter { function _createActionInfo() internal view returns(ActionInfo memory) { ActionInfo memory rebalanceInfo; - // Calculate prices from chainlink. Adjusts decimals to be in line with Compound's oracles. Chainlink returns prices with 8 decimal places, but + // Calculate prices from chainlink. Adjusts decimals to be in line with Compound's oracles. Chainlink returns prices with 8 decimal places, but // compound expects 36 - underlyingDecimals decimal places from their oracles. This is so that when the underlying amount is multiplied by the // received price, the collateral valuation is normalized to 36 decimals. To perform this adjustment, we multiply by 10^(36 - 8 - underlyingDeciamls) int256 rawCollateralPrice = strategy.collateralPriceOracle.latestAnswer(); @@ -1029,7 +1029,7 @@ contract FlexibleLeverageStrategyExtension is BaseAdapter { */ function _isAdvantageousTWAP(uint256 _currentLeverageRatio) internal view returns (bool) { return ( - (twapLeverageRatio < methodology.targetLeverageRatio && _currentLeverageRatio >= twapLeverageRatio) + (twapLeverageRatio < methodology.targetLeverageRatio && _currentLeverageRatio >= twapLeverageRatio) || (twapLeverageRatio > methodology.targetLeverageRatio && _currentLeverageRatio <= twapLeverageRatio) ); } @@ -1071,7 +1071,7 @@ contract FlexibleLeverageStrategyExtension is BaseAdapter { } /** - * Calculate total notional rebalance quantity and chunked rebalance quantity in collateral units. + * Calculate total notional rebalance quantity and chunked rebalance quantity in collateral units. * * return uint256 Chunked rebalance notional in collateral units * return uint256 Total rebalance notional in collateral units @@ -1100,7 +1100,7 @@ contract FlexibleLeverageStrategyExtension is BaseAdapter { /** * Calculate the max borrow / repay amount allowed in collateral units for lever / delever. This is due to overcollateralization requirements on * assets deposited in lending protocols for borrowing. - * + * * For lever, max borrow is calculated as: * (Net borrow limit in USD - existing borrow value in USD) / collateral asset price adjusted for decimals * @@ -1194,7 +1194,7 @@ contract FlexibleLeverageStrategyExtension is BaseAdapter { // If the chunk size is equal to the total notional meaning that rebalances are not chunked, then clear TWAP state. if (_chunkRebalanceNotional == _totalRebalanceNotional) { delete twapLeverageRatio; - } + } } /** @@ -1222,14 +1222,14 @@ contract FlexibleLeverageStrategyExtension is BaseAdapter { } /** - * Transfer ETH reward to caller of the ripcord function. If the ETH balance on this contract is less than required + * Transfer ETH reward to caller of the ripcord function. If the ETH balance on this contract is less than required * incentive quantity, then transfer contract balance instead to prevent reverts. * * return uint256 Amount of ETH transferred to caller */ function _transferEtherRewardToCaller(uint256 _etherReward) internal returns(uint256) { uint256 etherToTransfer = _etherReward < address(this).balance ? _etherReward : address(this).balance; - + msg.sender.transfer(etherToTransfer); return etherToTransfer; @@ -1280,7 +1280,7 @@ contract FlexibleLeverageStrategyExtension is BaseAdapter { } } } - + return (enabledExchanges, shouldRebalanceEnums); } diff --git a/contracts/adapters/GIMExtension.sol b/contracts/adapters/GIMExtension.sol index ba7b4506..2d786ad1 100644 --- a/contracts/adapters/GIMExtension.sol +++ b/contracts/adapters/GIMExtension.sol @@ -21,7 +21,7 @@ import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { SafeMath } from "@openzeppelin/contracts/math/SafeMath.sol"; import { AddressArrayUtils } from "../lib/AddressArrayUtils.sol"; -import { BaseAdapter } from "../lib/BaseAdapter.sol"; +import { BaseExtension } from "../lib/BaseExtension.sol"; import { IBaseManager } from "../interfaces/IBaseManager.sol"; import { IGeneralIndexModule } from "../interfaces/IGeneralIndexModule.sol"; import { ISetToken } from "../interfaces/ISetToken.sol"; @@ -32,21 +32,21 @@ import { ISetToken } from "../interfaces/ISetToken.sol"; * * Smart contract manager extension that acts as a pass-through contract for interacting with GeneralIndexModule. * All functions are only callable by operator. startRebalance() on GIM maps to startRebalanceWithUnits on - * GIMExtension. + * GIMExtension. */ -contract GIMExtension is BaseAdapter { +contract GIMExtension is BaseExtension { using AddressArrayUtils for address[]; using SafeMath for uint256; /* ============ State Variables ============ */ - + ISetToken public setToken; IGeneralIndexModule public generalIndexModule; // GIM /* ============ Constructor ============ */ - constructor(IBaseManager _manager, IGeneralIndexModule _generalIndexModule) public BaseAdapter(_manager) { + constructor(IBaseManager _manager, IGeneralIndexModule _generalIndexModule) public BaseExtension(_manager) { generalIndexModule = _generalIndexModule; setToken = manager.setToken(); } @@ -97,7 +97,7 @@ contract GIMExtension is BaseAdapter { _tradeMaximums ); - invokeManager(address(generalIndexModule), callData); + invokeManager(address(generalIndexModule), callData); } /** @@ -120,7 +120,7 @@ contract GIMExtension is BaseAdapter { _exchangeNames ); - invokeManager(address(generalIndexModule), callData); + invokeManager(address(generalIndexModule), callData); } /** @@ -143,7 +143,7 @@ contract GIMExtension is BaseAdapter { _coolOffPeriods ); - invokeManager(address(generalIndexModule), callData); + invokeManager(address(generalIndexModule), callData); } /** @@ -166,7 +166,7 @@ contract GIMExtension is BaseAdapter { _exchangeData ); - invokeManager(address(generalIndexModule), callData); + invokeManager(address(generalIndexModule), callData); } /** @@ -181,7 +181,7 @@ contract GIMExtension is BaseAdapter { _raiseTargetPercentage ); - invokeManager(address(generalIndexModule), callData); + invokeManager(address(generalIndexModule), callData); } /** @@ -204,7 +204,7 @@ contract GIMExtension is BaseAdapter { _statuses ); - invokeManager(address(generalIndexModule), callData); + invokeManager(address(generalIndexModule), callData); } /** @@ -219,7 +219,7 @@ contract GIMExtension is BaseAdapter { _status ); - invokeManager(address(generalIndexModule), callData); + invokeManager(address(generalIndexModule), callData); } /** @@ -231,7 +231,7 @@ contract GIMExtension is BaseAdapter { setToken ); - invokeManager(address(generalIndexModule), callData); + invokeManager(address(generalIndexModule), callData); } /* ============ Internal Functions ============ */ diff --git a/contracts/adapters/GovernanceAdapter.sol b/contracts/adapters/GovernanceExtension.sol similarity index 85% rename from contracts/adapters/GovernanceAdapter.sol rename to contracts/adapters/GovernanceExtension.sol index c84cda44..3bcb9abb 100644 --- a/contracts/adapters/GovernanceAdapter.sol +++ b/contracts/adapters/GovernanceExtension.sol @@ -16,30 +16,30 @@ pragma solidity 0.6.10; -import { BaseAdapter } from "../lib/BaseAdapter.sol"; +import { BaseExtension } from "../lib/BaseExtension.sol"; import { IBaseManager } from "../interfaces/IBaseManager.sol"; import { IGovernanceModule } from "../interfaces/IGovernanceModule.sol"; import { ISetToken } from "../interfaces/ISetToken.sol"; /** - * @title GovernanceAdapter + * @title GovernanceExtension * @author Set Protocol * - * Smart contract adapter that acts as a manager interface for interacting with the Set Protocol + * Smart contract extension that acts as a manager interface for interacting with the Set Protocol * GovernanceModule to perform meta-governance actions. All governance functions are callable only * by a subset of allowed callers. The operator has the power to add/remove callers from the allowed * callers mapping. */ -contract GovernanceAdapter is BaseAdapter { +contract GovernanceExtension is BaseExtension { /* ============ State Variables ============ */ - + ISetToken public setToken; IGovernanceModule public governanceModule; - + /* ============ Constructor ============ */ - constructor(IBaseManager _manager, IGovernanceModule _governanceModule) public BaseAdapter(_manager) { + constructor(IBaseManager _manager, IGovernanceModule _governanceModule) public BaseExtension(_manager) { governanceModule = _governanceModule; setToken = manager.setToken(); } @@ -48,9 +48,9 @@ contract GovernanceAdapter is BaseAdapter { /** * ONLY APPROVED CALLER: Submits a delegate call to the GovernanceModule. Approved caller mapping - * is part of BaseAdapter. + * is part of BaseExtension. * - * @param _governanceName Name of governance adapter being used + * @param _governanceName Name of governance extension being used */ function delegate( string memory _governanceName, @@ -71,9 +71,9 @@ contract GovernanceAdapter is BaseAdapter { /** * ONLY APPROVED CALLER: Submits a proposal call to the GovernanceModule. Approved caller mapping - * is part of BaseAdapter. + * is part of BaseExtension. * - * @param _governanceName Name of governance adapter being used + * @param _governanceName Name of governance extension being used * @param _proposalData Byte data of proposal */ function propose( @@ -95,9 +95,9 @@ contract GovernanceAdapter is BaseAdapter { /** * ONLY APPROVED CALLER: Submits a register call to the GovernanceModule. Approved caller mapping - * is part of BaseAdapter. + * is part of BaseExtension. * - * @param _governanceName Name of governance adapter being used + * @param _governanceName Name of governance extension being used */ function register(string memory _governanceName) external onlyAllowedCaller(msg.sender) { bytes memory callData = abi.encodeWithSelector( @@ -111,9 +111,9 @@ contract GovernanceAdapter is BaseAdapter { /** * ONLY APPROVED CALLER: Submits a revoke call to the GovernanceModule. Approved caller mapping - * is part of BaseAdapter. + * is part of BaseExtension. * - * @param _governanceName Name of governance adapter being used + * @param _governanceName Name of governance extension being used */ function revoke(string memory _governanceName) external onlyAllowedCaller(msg.sender) { bytes memory callData = abi.encodeWithSelector( @@ -127,9 +127,9 @@ contract GovernanceAdapter is BaseAdapter { /** * ONLY APPROVED CALLER: Submits a vote call to the GovernanceModule. Approved caller mapping - * is part of BaseAdapter. + * is part of BaseExtension. * - * @param _governanceName Name of governance adapter being used + * @param _governanceName Name of governance extension being used * @param _proposalId Id of proposal being voted on * @param _support Boolean indicating if supporting proposal * @param _data Arbitrary bytes to be used to construct vote call data diff --git a/contracts/adapters/StreamingFeeSplitExtension.sol b/contracts/adapters/StreamingFeeSplitExtension.sol index c8db959e..1d2075d7 100644 --- a/contracts/adapters/StreamingFeeSplitExtension.sol +++ b/contracts/adapters/StreamingFeeSplitExtension.sol @@ -15,16 +15,18 @@ */ pragma solidity 0.6.10; +pragma experimental ABIEncoderV2; import { Address } from "@openzeppelin/contracts/utils/Address.sol"; import { SafeMath } from "@openzeppelin/contracts/math/SafeMath.sol"; -import { BaseAdapter } from "../lib/BaseAdapter.sol"; +import { BaseExtension } from "../lib/BaseExtension.sol"; import { IBaseManager } from "../interfaces/IBaseManager.sol"; import { ISetToken } from "../interfaces/ISetToken.sol"; import { IStreamingFeeModule } from "../interfaces/IStreamingFeeModule.sol"; import { PreciseUnitMath } from "../lib/PreciseUnitMath.sol"; import { TimeLockUpgrade } from "../lib/TimeLockUpgrade.sol"; +import { MutualUpgrade } from "../lib/MutualUpgrade.sol"; /** @@ -34,15 +36,20 @@ import { TimeLockUpgrade } from "../lib/TimeLockUpgrade.sol"; * Smart contract manager extension that allows for splitting and setting streaming fees. Fee splits are updated by operator. * Any fee updates are timelocked. */ -contract StreamingFeeSplitExtension is BaseAdapter, TimeLockUpgrade { +contract StreamingFeeSplitExtension is BaseExtension, TimeLockUpgrade, MutualUpgrade { using Address for address; using PreciseUnitMath for uint256; using SafeMath for uint256; /* ============ Events ============ */ - event FeesAccrued(address indexed _operator, address indexed _methodologist, uint256 _operatorTake, uint256 _methodologistTake); - + event FeesDistributed( + address indexed _operatorFeeRecipient, + address indexed _methodologist, + uint256 _operatorTake, + uint256 _methodologistTake + ); + /* ============ State Variables ============ */ ISetToken public setToken; @@ -51,54 +58,87 @@ contract StreamingFeeSplitExtension is BaseAdapter, TimeLockUpgrade { // Percent of fees in precise units (10^16 = 1%) sent to operator, rest to methodologist uint256 public operatorFeeSplit; + // Address which receives operator's share of fees when they're distributed. (See IIP-72) + address public operatorFeeRecipient; + /* ============ Constructor ============ */ constructor( IBaseManager _manager, IStreamingFeeModule _streamingFeeModule, - uint256 _operatorFeeSplit + uint256 _operatorFeeSplit, + address _operatorFeeRecipient ) public - BaseAdapter(_manager) + BaseExtension(_manager) { streamingFeeModule = _streamingFeeModule; operatorFeeSplit = _operatorFeeSplit; + operatorFeeRecipient = _operatorFeeRecipient; setToken = manager.setToken(); } /* ============ External Functions ============ */ /** - * ANYONE CALLABLE: Accrues fees from streaming fee module. Gets resulting balance after fee accrual, calculates fees for - * operator and methodologist, and sends to each. + * ANYONE CALLABLE: Accrues fees from streaming fee module. Gets resulting balance after fee accrual, + * calculates fees for operator and methodologist, and sends to operatorFeeRecipient and methodologist + * respectively. */ function accrueFeesAndDistribute() public { + // Emits a FeeActualized event streamingFeeModule.accrueFee(setToken); - - uint256 totalFees = setToken.balanceOf(address(manager)); - - address operator = manager.operator(); + + uint256 totalFees = setToken.balanceOf(address(this)); + address methodologist = manager.methodologist(); uint256 operatorTake = totalFees.preciseMul(operatorFeeSplit); uint256 methodologistTake = totalFees.sub(operatorTake); if (operatorTake > 0) { - invokeManagerTransfer(address(setToken), operator, operatorTake); + setToken.transfer(operatorFeeRecipient, operatorTake); } if (methodologistTake > 0) { - invokeManagerTransfer(address(setToken), methodologist, methodologistTake); + setToken.transfer(methodologist, methodologistTake); } - emit FeesAccrued(operator, methodologist, operatorTake, methodologistTake); + emit FeesDistributed(operatorFeeRecipient, methodologist, operatorTake, methodologistTake); + } + + /** + * MUTUAL UPGRADE: Initializes the streaming fee module. Operator and Methodologist must each call + * this function to execute the update. + * + * This method is called after invoking `replaceProtectedModule` or `emergencyReplaceProtectedModule` + * to configure the replacement streaming fee module's fee settings. + */ + function initializeModule(IStreamingFeeModule.FeeState memory _settings) + external + mutualUpgrade(manager.operator(), manager.methodologist()) + { + bytes memory callData = abi.encodeWithSelector( + IStreamingFeeModule.initialize.selector, + manager.setToken(), + _settings + ); + + invokeManager(address(streamingFeeModule), callData); } /** - * ONLY OPERATOR: Updates streaming fee on StreamingFeeModule. NOTE: This will accrue streaming fees to the manager contract - * but not distribute to the operator and methodologist. + * MUTUAL UPGRADE: Updates streaming fee on StreamingFeeModule. Operator and Methodologist must + * each call this function to execute the update. Because the method is timelocked, each party + * must call it twice: once to set the lock and once to execute. + * + * NOTE: This will accrue streaming fees though not send to operator fee recipient and methodologist. */ - function updateStreamingFee(uint256 _newFee) external onlyOperator timeLockUpgrade { + function updateStreamingFee(uint256 _newFee) + external + mutualUpgrade(manager.operator(), manager.methodologist()) + timeLockUpgrade + { bytes memory callData = abi.encodeWithSelector( IStreamingFeeModule.updateStreamingFee.selector, manager.setToken(), @@ -109,9 +149,12 @@ contract StreamingFeeSplitExtension is BaseAdapter, TimeLockUpgrade { } /** - * ONLY OPERATOR: Updates fee recipient on streaming fee module. + * MUTUAL UPGRADE: Updates fee recipient on streaming fee module. */ - function updateFeeRecipient(address _newFeeRecipient) external onlyOperator { + function updateFeeRecipient(address _newFeeRecipient) + external + mutualUpgrade(manager.operator(), manager.methodologist()) + { bytes memory callData = abi.encodeWithSelector( IStreamingFeeModule.updateFeeRecipient.selector, manager.setToken(), @@ -122,12 +165,26 @@ contract StreamingFeeSplitExtension is BaseAdapter, TimeLockUpgrade { } /** - * ONLY OPERATOR: Updates fee split between operator and methodologist. Split defined in precise units (1% = 10^16). Fees will be + * MUTUAL UPGRADE: Updates fee split between operator and methodologist. Split defined in precise units (1% = 10^16). Fees will be * accrued and distributed before the new split goes into effect. */ - function updateFeeSplit(uint256 _newFeeSplit) external onlyOperator { + function updateFeeSplit(uint256 _newFeeSplit) + external + mutualUpgrade(manager.operator(), manager.methodologist()) + { require(_newFeeSplit <= PreciseUnitMath.preciseUnit(), "Fee must be less than 100%"); accrueFeesAndDistribute(); operatorFeeSplit = _newFeeSplit; } + + /** + * OPERATOR ONLY: Updates the address that receives the operator's share of the fees (see IIP-72) + */ + function updateOperatorFeeRecipient(address _newOperatorFeeRecipient) + external + onlyOperator + { + require(_newOperatorFeeRecipient != address(0), "Zero address not valid"); + operatorFeeRecipient = _newOperatorFeeRecipient; + } } \ No newline at end of file diff --git a/contracts/interfaces/IExtension.sol b/contracts/interfaces/IExtension.sol new file mode 100644 index 00000000..ea1170aa --- /dev/null +++ b/contracts/interfaces/IExtension.sol @@ -0,0 +1,26 @@ +/* + Copyright 2021 Set Labs Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + SPDX-License-Identifier: Apache License, Version 2.0 +*/ + +pragma solidity 0.6.10; +pragma experimental "ABIEncoderV2"; + +import { IBaseManager } from "./IBaseManager.sol"; + +interface IExtension { + function manager() external view returns (IBaseManager); +} \ No newline at end of file diff --git a/contracts/interfaces/IIssuanceModule.sol b/contracts/interfaces/IIssuanceModule.sol index 6174b7c3..b4d574b6 100644 --- a/contracts/interfaces/IIssuanceModule.sol +++ b/contracts/interfaces/IIssuanceModule.sol @@ -29,4 +29,13 @@ interface IIssuanceModule { function updateIssueFee(ISetToken _setToken, uint256 _newIssueFee) external; function updateRedeemFee(ISetToken _setToken, uint256 _newRedeemFee) external; function updateFeeRecipient(ISetToken _setToken, address _newRedeemFee) external; -} \ No newline at end of file + + function initialize( + ISetToken _setToken, + uint256 _maxManagerFee, + uint256 _managerIssueFee, + uint256 _managerRedeemFee, + address _feeRecipient, + address _managerIssuanceHook + ) external; +} diff --git a/contracts/interfaces/IStreamingFeeModule.sol b/contracts/interfaces/IStreamingFeeModule.sol index 5102d75c..2f59aa1d 100644 --- a/contracts/interfaces/IStreamingFeeModule.sol +++ b/contracts/interfaces/IStreamingFeeModule.sol @@ -4,8 +4,16 @@ pragma experimental "ABIEncoderV2"; import { ISetToken } from "./ISetToken.sol"; interface IStreamingFeeModule { + struct FeeState { + address feeRecipient; + uint256 maxStreamingFeePercentage; + uint256 streamingFeePercentage; + uint256 lastStreamingFeeTimestamp; + } + function getFee(ISetToken _setToken) external view returns (uint256); function accrueFee(ISetToken _setToken) external; function updateStreamingFee(ISetToken _setToken, uint256 _newFee) external; function updateFeeRecipient(ISetToken _setToken, address _newFeeRecipient) external; -} \ No newline at end of file + function initialize(ISetToken _setToken, FeeState memory _settings) external; +} diff --git a/contracts/lib/BaseAdapter.sol b/contracts/lib/BaseExtension.sol similarity index 96% rename from contracts/lib/BaseAdapter.sol rename to contracts/lib/BaseExtension.sol index 3794b229..287ada10 100644 --- a/contracts/lib/BaseAdapter.sol +++ b/contracts/lib/BaseExtension.sol @@ -20,12 +20,12 @@ import { AddressArrayUtils } from "../lib/AddressArrayUtils.sol"; import { IBaseManager } from "../interfaces/IBaseManager.sol"; /** - * @title BaseAdapter + * @title BaseExtension * @author Set Protocol * - * Abstract class that houses common adapter-related state and functions. + * Abstract class that houses common extension-related state and functions. */ -abstract contract BaseAdapter { +abstract contract BaseExtension { using AddressArrayUtils for address[]; /* ============ Events ============ */ @@ -104,7 +104,7 @@ abstract contract BaseAdapter { } /** - * OPERATOR ONLY: Toggle whether anyone can call function, bypassing the callAllowlist + * OPERATOR ONLY: Toggle whether anyone can call function, bypassing the callAllowlist * * @param _status Boolean indicating whether to allow anyone call */ @@ -114,7 +114,7 @@ abstract contract BaseAdapter { } /* ============ Internal Functions ============ */ - + /** * Invoke manager to transfer tokens from manager to other contract. * diff --git a/contracts/manager/BaseManager.sol b/contracts/manager/BaseManager.sol index 50454a7b..ae2c31c7 100644 --- a/contracts/manager/BaseManager.sol +++ b/contracts/manager/BaseManager.sol @@ -210,4 +210,4 @@ contract BaseManager { function getAdapters() external view returns(address[] memory) { return adapters; } -} \ No newline at end of file +} diff --git a/contracts/manager/BaseManagerV2.sol b/contracts/manager/BaseManagerV2.sol new file mode 100644 index 00000000..15efe6f3 --- /dev/null +++ b/contracts/manager/BaseManagerV2.sol @@ -0,0 +1,590 @@ +/* + Copyright 2021 Set Labs Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +pragma solidity 0.6.10; +pragma experimental ABIEncoderV2; + +import { Address } from "@openzeppelin/contracts/utils/Address.sol"; + +import { AddressArrayUtils } from "../lib/AddressArrayUtils.sol"; +import { IExtension } from "../interfaces/IExtension.sol"; +import { ISetToken } from "../interfaces/ISetToken.sol"; +import { MutualUpgrade } from "../lib/MutualUpgrade.sol"; + + +/** + * @title BaseManagerV2 + * @author Set Protocol + * + * Smart contract manager that contains permissions and admin functionality. Implements IIP-64, supporting + * a registry of protected modules that can only be upgraded with methodologist consent. + */ +contract BaseManagerV2 is MutualUpgrade { + using Address for address; + using AddressArrayUtils for address[]; + + /* ============ Struct ========== */ + + struct ProtectedModule { + bool isProtected; // Flag set to true if module is protected + address[] authorizedExtensionsList; // List of Extensions authorized to call module + mapping(address => bool) authorizedExtensions; // Map of extensions authorized to call module + } + + /* ============ Events ============ */ + + event ExtensionAdded( + address _extension + ); + + event ExtensionRemoved( + address _extension + ); + + event MethodologistChanged( + address _oldMethodologist, + address _newMethodologist + ); + + event OperatorChanged( + address _oldOperator, + address _newOperator + ); + + event ExtensionAuthorized( + address _module, + address _extension + ); + + event ExtensionAuthorizationRevoked( + address _module, + address _extension + ); + + event ModuleProtected( + address _module, + address[] _extensions + ); + + event ModuleUnprotected( + address _module + ); + + event ReplacedProtectedModule( + address _oldModule, + address _newModule, + address[] _newExtensions + ); + + event EmergencyReplacedProtectedModule( + address _module, + address[] _extensions + ); + + event EmergencyRemovedProtectedModule( + address _module + ); + + /* ============ Modifiers ============ */ + + /** + * Throws if the sender is not the SetToken operator + */ + modifier onlyOperator() { + require(msg.sender == operator, "Must be operator"); + _; + } + + /** + * Throws if the sender is not the SetToken methodologist + */ + modifier onlyMethodologist() { + require(msg.sender == methodologist, "Must be methodologist"); + _; + } + + /** + * Throws if the sender is not a listed extension + */ + modifier onlyExtension() { + require(isExtension[msg.sender], "Must be extension"); + _; + } + + /** + * Throws if contract is in an emergency state following a unilateral operator removal of a + * protected module. + */ + modifier upgradesPermitted() { + require(emergencies == 0, "Upgrades paused by emergency"); + _; + } + + /** + * Throws if contract is *not* in an emergency state. Emergency replacement and resolution + * can only happen in an emergency + */ + modifier onlyEmergency() { + require(emergencies > 0, "Not in emergency"); + _; + } + + /* ============ State Variables ============ */ + + // Instance of SetToken + ISetToken public setToken; + + // Array of listed extensions + address[] internal extensions; + + // Mapping to check if extension is added + mapping(address => bool) public isExtension; + + // Address of operator which typically executes manager only functions on Set Protocol modules + address public operator; + + // Address of methodologist which serves as providing methodology for the index + address public methodologist; + + // Counter incremented when the operator "emergency removes" a protected module. Decremented + // when methodologist executes an "emergency replacement". Operator can only add modules and + // extensions when `emergencies` is zero. Emergencies can only be declared "over" by mutual agreement + // between operator and methodologist or by the methodologist alone via `resolveEmergency` + uint256 public emergencies; + + // Mapping of protected modules. These cannot be called or removed except by mutual upgrade. + mapping(address => ProtectedModule) public protectedModules; + + // List of protected modules, for iteration. Used when checking that an extension removal + // can happen without methodologist approval + address[] public protectedModulesList; + + // Boolean set when methodologist authorizes initialization after contract deployment. + // Must be true to call via `interactManager`. + bool public initialized; + + /* ============ Constructor ============ */ + + constructor( + ISetToken _setToken, + address _operator, + address _methodologist + ) + public + { + setToken = _setToken; + operator = _operator; + methodologist = _methodologist; + } + + /* ============ External Functions ============ */ + + /** + * ONLY METHODOLOGIST : Called by the methodologist to enable contract. All `interactManager` + * calls revert until this is invoked. Lets methodologist review and authorize initial protected + * module settings. + */ + function authorizeInitialization() external onlyMethodologist { + require(!initialized, "Initialization authorized"); + initialized = true; + } + + /** + * MUTUAL UPGRADE: Update the SetToken manager address. Operator and Methodologist must each call + * this function to execute the update. + * + * @param _newManager New manager address + */ + function setManager(address _newManager) external mutualUpgrade(operator, methodologist) { + require(_newManager != address(0), "Zero address not valid"); + setToken.setManager(_newManager); + } + + /** + * OPERATOR ONLY: Add a new extension that the BaseManager can call. + * + * @param _extension New extension to add + */ + function addExtension(address _extension) external upgradesPermitted onlyOperator { + require(!isExtension[_extension], "Extension already exists"); + require(address(IExtension(_extension).manager()) == address(this), "Extension manager invalid"); + + _addExtension(_extension); + } + + /** + * OPERATOR ONLY: Remove an existing extension tracked by the BaseManager. + * + * @param _extension Old extension to remove + */ + function removeExtension(address _extension) external onlyOperator { + require(isExtension[_extension], "Extension does not exist"); + require(!_isAuthorizedExtension(_extension), "Extension used by protected module"); + + extensions.removeStorage(_extension); + + isExtension[_extension] = false; + + emit ExtensionRemoved(_extension); + } + + /** + * MUTUAL UPGRADE: Authorizes an extension for a protected module. Operator and Methodologist must + * each call this function to execute the update. Adds extension to manager if not already present. + * + * @param _module Module to authorize extension for + * @param _extension Extension to authorize for module + */ + function authorizeExtension(address _module, address _extension) + external + mutualUpgrade(operator, methodologist) + { + require(protectedModules[_module].isProtected, "Module not protected"); + require(!protectedModules[_module].authorizedExtensions[_extension], "Extension already authorized"); + + _authorizeExtension(_module, _extension); + + emit ExtensionAuthorized(_module, _extension); + } + + /** + * MUTUAL UPGRADE: Revokes extension authorization for a protected module. Operator and Methodologist + * must each call this function to execute the update. In order to remove the extension completely + * from the contract removeExtension must be called by the operator. + * + * @param _module Module to revoke extension authorization for + * @param _extension Extension to revoke authorization of + */ + function revokeExtensionAuthorization(address _module, address _extension) + external + mutualUpgrade(operator, methodologist) + { + require(protectedModules[_module].isProtected, "Module not protected"); + require(isExtension[_extension], "Extension does not exist"); + require(protectedModules[_module].authorizedExtensions[_extension], "Extension not authorized"); + + protectedModules[_module].authorizedExtensions[_extension] = false; + protectedModules[_module].authorizedExtensionsList.removeStorage(_extension); + + emit ExtensionAuthorizationRevoked(_module, _extension); + } + + /** + * ADAPTER ONLY: Interact with a module registered on the SetToken. Manager initialization must + * have been authorized by methodologist. Extension making this call must be authorized + * to call module if module is protected. + * + * @param _module Module to interact with + * @param _data Byte data of function to call in module + */ + function interactManager(address _module, bytes memory _data) external onlyExtension { + require(initialized, "Manager not initialized"); + require(_senderAuthorizedForModule(_module, msg.sender), "Extension not authorized for module"); + + // Invoke call to module, assume value will always be 0 + _module.functionCallWithValue(_data, 0); + } + + /** + * OPERATOR ONLY: Add a new module to the SetToken. + * + * @param _module New module to add + */ + function addModule(address _module) external upgradesPermitted onlyOperator { + setToken.addModule(_module); + } + + /** + * OPERATOR ONLY: Remove a new module from the SetToken. Any extensions associated with this + * module need to be removed in separate transactions via removeExtension. + * + * @param _module Module to remove + */ + function removeModule(address _module) external onlyOperator { + require(!protectedModules[_module].isProtected, "Module protected"); + setToken.removeModule(_module); + } + + /** + * OPERATOR ONLY: Marks a currently protected module as unprotected and deletes its authorized + * extension registries. Removes module from the SetToken. Increments the `emergencies` counter, + * prohibiting any operator-only module or extension additions until `emergencyReplaceProtectedModule` + * is executed or `resolveEmergency` is called by the methodologist. + * + * Called by operator when a module must be removed immediately for security reasons and it's unsafe + * to wait for a `mutualUpgrade` process to play out. + * + * NOTE: If removing a fee module, you can ensure all fees are distributed by calling distribute + * on the module's de-authorized fee extension after this call. + * + * @param _module Module to remove + */ + function emergencyRemoveProtectedModule(address _module) external onlyOperator { + _unProtectModule(_module); + setToken.removeModule(_module); + emergencies += 1; + + emit EmergencyRemovedProtectedModule(_module); + } + + /** + * OPERATOR ONLY: Marks an existing module as protected and authorizes existing extensions for + * it. Adds module to the protected modules list + * + * The operator uses this when they're adding new features and want to assure the methodologist + * they won't be unilaterally changed in the future. Cannot be called during an emergency because + * methodologist needs to explicitly approve protection arrangements under those conditions. + * + * NOTE: If adding a fee extension while protecting a fee module, it's important to set the + * module `feeRecipient` to the new extension's address (ideally before this call). + * + * @param _module Module to protect + * @param _extensions Array of extensions to authorize for protected module + */ + function protectModule(address _module, address[] memory _extensions) + external + upgradesPermitted + onlyOperator + { + require(setToken.getModules().contains(_module), "Module not added yet"); + _protectModule(_module, _extensions); + + emit ModuleProtected(_module, _extensions); + } + + /** + * METHODOLOGIST ONLY: Marks a currently protected module as unprotected and deletes its authorized + * extension registries. Removes old module from the protected modules list. + * + * Called by the methodologist when they want to cede control over a protected module without triggering + * an emergency (for example, to remove it because its dead). + * + * @param _module Module to revoke protections for + */ + function unProtectModule(address _module) external onlyMethodologist { + _unProtectModule(_module); + + emit ModuleUnprotected(_module); + } + + /** + * MUTUAL UPGRADE: Replaces a protected module. Operator and Methodologist must each call this + * function to execute the update. + * + * > Marks a currently protected module as unprotected + * > Deletes its authorized extension registries. + * > Removes old module from SetToken. + * > Adds new module to SetToken. + * > Marks `_newModule` as protected and authorizes new extensions for it. + * + * Used when methodologists wants to guarantee that an existing protection arrangement is replaced + * with a suitable substitute (ex: upgrading a StreamingFeeSplitExtension). + * + * NOTE: If replacing a fee module, it's necessary to set the module `feeRecipient` to be + * the new fee extension address after this call. Any fees remaining in the old module's + * de-authorized extensions can be distributed by calling `distribute()` on the old extension. + * + * @param _oldModule Module to remove + * @param _newModule Module to add in place of removed module + * @param _newExtensions Extensions to authorize for new module + */ + function replaceProtectedModule(address _oldModule, address _newModule, address[] memory _newExtensions) + external + mutualUpgrade(operator, methodologist) + { + _unProtectModule(_oldModule); + + setToken.removeModule(_oldModule); + setToken.addModule(_newModule); + + _protectModule(_newModule, _newExtensions); + + emit ReplacedProtectedModule(_oldModule, _newModule, _newExtensions); + } + + /** + * MUTUAL UPGRADE & EMERGENCY ONLY: Replaces a module the operator has removed with + * `emergencyRemoveProtectedModule`. Operator and Methodologist must each call this function to + * execute the update. + * + * > Adds new module to SetToken. + * > Marks `_newModule` as protected and authorizes new extensions for it. + * > Adds `_newModule` to protectedModules list. + * > Decrements the emergencies counter, + * + * Used when methodologist wants to guarantee that a protection arrangement which was + * removed in an emergency is replaced with a suitable substitute. Operator's ability to add modules + * or extensions is restored after invoking this method (if this is the only emergency.) + * + * NOTE: If replacing a fee module, it's necessary to set the module `feeRecipient` to be + * the new fee extension address after this call. Any fees remaining in the old module's + * de-authorized extensions can be distributed by calling `distribute()` on the old extension. + * + * @param _module Module to add in place of removed module + * @param _extensions Array of extensions to authorize for replacement module + */ + function emergencyReplaceProtectedModule( + address _module, + address[] memory _extensions + ) + external + mutualUpgrade(operator, methodologist) + onlyEmergency + { + setToken.addModule(_module); + _protectModule(_module, _extensions); + + emergencies -= 1; + + emit EmergencyReplacedProtectedModule(_module, _extensions); + } + + /** + * METHODOLOGIST ONLY & EMERGENCY ONLY: Decrements the emergencies counter. + * + * Allows a methodologist to exit a state of emergency without replacing a protected module that + * was removed. This could happen if the module has no viable substitute or operator and methodologist + * agree that restoring normal operations is the best way forward. + */ + function resolveEmergency() external onlyMethodologist onlyEmergency { + emergencies -= 1; + } + + /** + * METHODOLOGIST ONLY: Update the methodologist address + * + * @param _newMethodologist New methodologist address + */ + function setMethodologist(address _newMethodologist) external onlyMethodologist { + emit MethodologistChanged(methodologist, _newMethodologist); + + methodologist = _newMethodologist; + } + + /** + * OPERATOR ONLY: Update the operator address + * + * @param _newOperator New operator address + */ + function setOperator(address _newOperator) external onlyOperator { + emit OperatorChanged(operator, _newOperator); + + operator = _newOperator; + } + + /* ============ External Getters ============ */ + + function getExtensions() external view returns(address[] memory) { + return extensions; + } + + function getAuthorizedExtensions(address _module) external view returns (address[] memory) { + return protectedModules[_module].authorizedExtensionsList; + } + + function isAuthorizedExtension(address _module, address _extension) external view returns (bool) { + return protectedModules[_module].authorizedExtensions[_extension]; + } + + function getProtectedModules() external view returns (address[] memory) { + return protectedModulesList; + } + + /* ============ Internal ============ */ + + + /** + * Add a new extension that the BaseManager can call. + */ + function _addExtension(address _extension) internal { + extensions.push(_extension); + + isExtension[_extension] = true; + + emit ExtensionAdded(_extension); + } + + /** + * Marks a currently protected module as unprotected and deletes it from authorized extension + * registries. Removes module from the SetToken. + */ + function _unProtectModule(address _module) internal { + require(protectedModules[_module].isProtected, "Module not protected"); + + // Clear mapping and array entries in struct before deleting mapping entry + for (uint256 i = 0; i < protectedModules[_module].authorizedExtensionsList.length; i++) { + address extension = protectedModules[_module].authorizedExtensionsList[i]; + protectedModules[_module].authorizedExtensions[extension] = false; + } + + delete protectedModules[_module]; + + protectedModulesList.removeStorage(_module); + } + + /** + * Adds new module to SetToken. Marks `_newModule` as protected and authorizes + * new extensions for it. Adds `_newModule` module to protectedModules list. + */ + function _protectModule(address _module, address[] memory _extensions) internal { + require(!protectedModules[_module].isProtected, "Module already protected"); + + protectedModules[_module].isProtected = true; + protectedModulesList.push(_module); + + for (uint i = 0; i < _extensions.length; i++) { + _authorizeExtension(_module, _extensions[i]); + } + } + + /** + * Adds extension if not already added and marks extension as authorized for module + */ + function _authorizeExtension(address _module, address _extension) internal { + if (!isExtension[_extension]) { + _addExtension(_extension); + } + + protectedModules[_module].authorizedExtensions[_extension] = true; + protectedModules[_module].authorizedExtensionsList.push(_extension); + } + + /** + * Searches the extension mappings of each protected modules to determine if an extension + * is authorized by any of them. Authorized extensions cannot be unilaterally removed by + * the operator. + */ + function _isAuthorizedExtension(address _extension) internal view returns (bool) { + for (uint256 i = 0; i < protectedModulesList.length; i++) { + if (protectedModules[protectedModulesList[i]].authorizedExtensions[_extension]) { + return true; + } + } + + return false; + } + + /** + * Checks if `_sender` (an extension) is allowed to call a module (which may be protected) + */ + function _senderAuthorizedForModule(address _module, address _sender) internal view returns (bool) { + if (protectedModules[_module].isProtected) { + return protectedModules[_module].authorizedExtensions[_sender]; + } + + return true; + } +} diff --git a/contracts/mocks/BaseAdapterMock.sol b/contracts/mocks/BaseExtensionMock.sol similarity index 89% rename from contracts/mocks/BaseAdapterMock.sol rename to contracts/mocks/BaseExtensionMock.sol index d61e28a6..97fadece 100644 --- a/contracts/mocks/BaseAdapterMock.sol +++ b/contracts/mocks/BaseExtensionMock.sol @@ -18,12 +18,12 @@ pragma solidity 0.6.10; -import { BaseAdapter } from "../lib/BaseAdapter.sol"; +import { BaseExtension } from "../lib/BaseExtension.sol"; import { IBaseManager } from "../interfaces/IBaseManager.sol"; -contract BaseAdapterMock is BaseAdapter { +contract BaseExtensionMock is BaseExtension { - constructor(IBaseManager _manager) public BaseAdapter(_manager) {} + constructor(IBaseManager _manager) public BaseExtension(_manager) {} /* ============ External Functions ============ */ diff --git a/contracts/mocks/TradeAdapterMock.sol b/contracts/mocks/TradeAdapterMock.sol index 400b670b..16dd72fa 100644 --- a/contracts/mocks/TradeAdapterMock.sol +++ b/contracts/mocks/TradeAdapterMock.sol @@ -16,7 +16,7 @@ contract TradeAdapterMock { uint256 balance = ERC20(_token).balanceOf(address(this)); require(ERC20(_token).transfer(msg.sender, balance), "ERC20 transfer failed"); } - + /* ============ Trade Functions ============ */ function trade( diff --git a/hardhat.config.ts b/hardhat.config.ts index dfb5ad6e..c1960ebb 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -34,6 +34,8 @@ const config: HardhatUserConfig = { hardhat: { forking: (process.env.FORK) ? forkingConfig : undefined, accounts: getHardhatPrivateKeys(), + gas: 12000000, + blockGasLimit: 12000000, }, localhost: { url: "http://127.0.0.1:8545", diff --git a/tasks/subtasks.ts b/tasks/subtasks.ts index a9f32929..18cb81a1 100644 --- a/tasks/subtasks.ts +++ b/tasks/subtasks.ts @@ -14,7 +14,7 @@ subtask(TASK_COMPILE_SOLIDITY_GET_ARTIFACT_FROM_COMPILATION_OUTPUT) // These changes should be skipped when publishing to npm. // They override ethers' gas estimation - if (!process.env.SKIP_ABI_GAS_MODS){ + if (!process.env.SKIP_ABI_GAS_MODS) { artifact.abi = addGasToAbiMethods(network.config, artifact.abi); } diff --git a/test/adapters/feeSplitAdapter.spec.ts b/test/adapters/feeSplitAdapter.spec.ts deleted file mode 100644 index 2ad14b49..00000000 --- a/test/adapters/feeSplitAdapter.spec.ts +++ /dev/null @@ -1,609 +0,0 @@ -import "module-alias/register"; - -import { solidityKeccak256 } from "ethers/lib/utils"; -import { Address, Account } from "@utils/types"; -import { ADDRESS_ZERO, ZERO, ONE_DAY_IN_SECONDS, ONE_YEAR_IN_SECONDS } from "@utils/constants"; -import { FeeSplitAdapter, BaseManager } from "@utils/contracts/index"; -import { SetToken } from "@utils/contracts/setV2"; -import DeployHelper from "@utils/deploys"; -import { - addSnapshotBeforeRestoreAfterEach, - ether, - getAccounts, - getLastBlockTimestamp, - getSetFixture, - getStreamingFee, - getStreamingFeeInflationAmount, - getTransactionTimestamp, - getWaffleExpect, - increaseTimeAsync, - preciseMul, -} from "@utils/index"; -import { SetFixture } from "@utils/fixtures"; -import { BigNumber, ContractTransaction } from "ethers"; - -const expect = getWaffleExpect(); - -describe("FeeSplitAdapter", () => { - let owner: Account; - let methodologist: Account; - let operator: Account; - let setV2Setup: SetFixture; - - let deployer: DeployHelper; - let setToken: SetToken; - - let baseManagerV2: BaseManager; - let feeAdapter: FeeSplitAdapter; - - before(async () => { - [ - owner, - methodologist, - operator, - ] = await getAccounts(); - - deployer = new DeployHelper(owner.wallet); - - setV2Setup = getSetFixture(owner.address); - await setV2Setup.initialize(); - - setToken = await setV2Setup.createSetToken( - [setV2Setup.dai.address], - [ether(1)], - [setV2Setup.debtIssuanceModule.address, setV2Setup.streamingFeeModule.address] - ); - - // Deploy BaseManager - baseManagerV2 = await deployer.manager.deployBaseManager( - setToken.address, - operator.address, - methodologist.address - ); - - const feeRecipient = baseManagerV2.address; - const maxStreamingFeePercentage = ether(.1); - const streamingFeePercentage = ether(.02); - const streamingFeeSettings = { - feeRecipient, - maxStreamingFeePercentage, - streamingFeePercentage, - lastStreamingFeeTimestamp: ZERO, - }; - await setV2Setup.streamingFeeModule.initialize(setToken.address, streamingFeeSettings); - - await setV2Setup.debtIssuanceModule.initialize( - setToken.address, - ether(.1), - ether(.01), - ether(.005), - baseManagerV2.address, - ADDRESS_ZERO - ); - }); - - addSnapshotBeforeRestoreAfterEach(); - - describe("#constructor", async () => { - let subjectManager: Address; - let subjectStreamingFeeModule: Address; - let subjectDebtIssuanceModule: Address; - let subjectOperatorFeeSplit: BigNumber; - - beforeEach(async () => { - subjectManager = baseManagerV2.address; - subjectStreamingFeeModule = setV2Setup.streamingFeeModule.address; - subjectDebtIssuanceModule = setV2Setup.debtIssuanceModule.address; - subjectOperatorFeeSplit = ether(.7); - }); - - async function subject(): Promise { - return await deployer.adapters.deployFeeSplitAdapter( - subjectManager, - subjectStreamingFeeModule, - subjectDebtIssuanceModule, - subjectOperatorFeeSplit - ); - } - - it("should set the correct SetToken address", async () => { - const feeAdapter = await subject(); - - const actualToken = await feeAdapter.setToken(); - expect(actualToken).to.eq(setToken.address); - }); - - it("should set the correct manager address", async () => { - const feeAdapter = await subject(); - - const actualManager = await feeAdapter.manager(); - expect(actualManager).to.eq(baseManagerV2.address); - }); - - it("should set the correct streaming fee module address", async () => { - const feeAdapter = await subject(); - - const actualStreamingFeeModule = await feeAdapter.streamingFeeModule(); - expect(actualStreamingFeeModule).to.eq(subjectStreamingFeeModule); - }); - - it("should set the correct debt issuance module address", async () => { - const feeAdapter = await subject(); - - const actualDebtIssuanceModule = await feeAdapter.issuanceModule(); - expect(actualDebtIssuanceModule).to.eq(subjectDebtIssuanceModule); - }); - - it("should set the correct operator fee split", async () => { - const feeAdapter = await subject(); - - const actualOperatorFeeSplit = await feeAdapter.operatorFeeSplit(); - expect(actualOperatorFeeSplit).to.eq(subjectOperatorFeeSplit); - }); - }); - - context("when fee adapter is deployed and system fully set up", async () => { - const operatorSplit: BigNumber = ether(.7); - - beforeEach(async () => { - feeAdapter = await deployer.adapters.deployFeeSplitAdapter( - baseManagerV2.address, - setV2Setup.streamingFeeModule.address, - setV2Setup.debtIssuanceModule.address, - operatorSplit - ); - - await baseManagerV2.connect(operator.wallet).addAdapter(feeAdapter.address); - - // Transfer ownership to BaseManager - await setToken.setManager(baseManagerV2.address); - }); - - describe("#accrueFeesAndDistribute", async () => { - let mintedTokens: BigNumber; - const timeFastForward: BigNumber = ONE_YEAR_IN_SECONDS; - - beforeEach(async () => { - mintedTokens = ether(2); - await setV2Setup.dai.approve(setV2Setup.debtIssuanceModule.address, ether(3)); - await setV2Setup.debtIssuanceModule.issue(setToken.address, mintedTokens, owner.address); - - await increaseTimeAsync(timeFastForward); - }); - - async function subject(): Promise { - return await feeAdapter.accrueFeesAndDistribute(); - } - - it("should send correct amount of fees to operator and methodologist", async () => { - const feeState: any = await setV2Setup.streamingFeeModule.feeStates(setToken.address); - const totalSupply = await setToken.totalSupply(); - - const txnTimestamp = await getTransactionTimestamp(subject()); - - const expectedFeeInflation = await getStreamingFee( - setV2Setup.streamingFeeModule, - setToken.address, - feeState.lastStreamingFeeTimestamp, - txnTimestamp - ); - - const feeInflation = getStreamingFeeInflationAmount(expectedFeeInflation, totalSupply); - - const expectedMintRedeemFees = preciseMul(mintedTokens, ether(.01)); - const expectedOperatorTake = preciseMul(feeInflation.add(expectedMintRedeemFees), operatorSplit); - const expectedMethodologistTake = feeInflation.add(expectedMintRedeemFees).sub(expectedOperatorTake); - - const operatorBalance = await setToken.balanceOf(operator.address); - const methodologistBalance = await setToken.balanceOf(methodologist.address); - - expect(operatorBalance).to.eq(expectedOperatorTake); - expect(methodologistBalance).to.eq(expectedMethodologistTake); - }); - - it("should emit a FeesAccrued event", async () => { - await expect(subject()).to.emit(feeAdapter, "FeesAccrued"); - }); - - describe("when methodologist fees are 0", async () => { - beforeEach(async () => { - await feeAdapter.connect(operator.wallet).updateFeeSplit(ether(1)); - }); - - it("should not send fees to methodologist", async () => { - const preMethodologistBalance = await setToken.balanceOf(methodologist.address); - - await subject(); - - const postMethodologistBalance = await setToken.balanceOf(methodologist.address); - expect(postMethodologistBalance.sub(preMethodologistBalance)).to.eq(ZERO); - }); - }); - - describe("when operator fees are 0", async () => { - beforeEach(async () => { - await feeAdapter.connect(operator.wallet).updateFeeSplit(ZERO); - }); - - it("should not send fees to operator", async () => { - const preOperatorBalance = await setToken.balanceOf(operator.address); - - await subject(); - - const postOperatorBalance = await setToken.balanceOf(operator.address); - expect(postOperatorBalance.sub(preOperatorBalance)).to.eq(ZERO); - }); - }); - }); - - describe("#updateStreamingFee", async () => { - let mintedTokens: BigNumber; - const timeFastForward: BigNumber = ONE_YEAR_IN_SECONDS; - - let subjectNewFee: BigNumber; - let subjectCaller: Account; - - beforeEach(async () => { - mintedTokens = ether(2); - await setV2Setup.dai.approve(setV2Setup.debtIssuanceModule.address, ether(3)); - await setV2Setup.debtIssuanceModule.issue(setToken.address, mintedTokens, owner.address); - - await increaseTimeAsync(timeFastForward); - - subjectNewFee = ether(.01); - subjectCaller = operator; - }); - - async function subject(): Promise { - return await feeAdapter.connect(subjectCaller.wallet).updateStreamingFee(subjectNewFee); - } - context("when no timelock period has been set", async () => { - it("should update the streaming fee", async () => { - await subject(); - - const feeState = await setV2Setup.streamingFeeModule.feeStates(setToken.address); - - expect(feeState.streamingFeePercentage).to.eq(subjectNewFee); - }); - - it("should send correct amount of fees to operator and methodologist", async () => { - const preManagerBalance = await setToken.balanceOf(baseManagerV2.address); - const feeState: any = await setV2Setup.streamingFeeModule.feeStates(setToken.address); - const totalSupply = await setToken.totalSupply(); - - const txnTimestamp = await getTransactionTimestamp(subject()); - - const expectedFeeInflation = await getStreamingFee( - setV2Setup.streamingFeeModule, - setToken.address, - feeState.lastStreamingFeeTimestamp, - txnTimestamp, - ether(.02) - ); - - const feeInflation = getStreamingFeeInflationAmount(expectedFeeInflation, totalSupply); - - const postManagerBalance = await setToken.balanceOf(baseManagerV2.address); - - expect(postManagerBalance.sub(preManagerBalance)).to.eq(feeInflation); - }); - }); - - context("when 1 day timelock period has been set", async () => { - beforeEach(async () => { - await feeAdapter.connect(owner.wallet).setTimeLockPeriod(ONE_DAY_IN_SECONDS); - }); - - it("sets the upgradeHash", async () => { - await subject(); - const timestamp = await getLastBlockTimestamp(); - const calldata = feeAdapter.interface.encodeFunctionData("updateStreamingFee", [subjectNewFee]); - const upgradeHash = solidityKeccak256(["bytes"], [calldata]); - const actualTimestamp = await feeAdapter.timeLockedUpgrades(upgradeHash); - expect(actualTimestamp).to.eq(timestamp); - }); - - context("when 1 day timelock has elapsed", async () => { - beforeEach(async () => { - await subject(); - await increaseTimeAsync(ONE_DAY_IN_SECONDS.add(1)); - }); - - it("should update the streaming fee", async () => { - await subject(); - - const feeState = await setV2Setup.streamingFeeModule.feeStates(setToken.address); - - expect(feeState.streamingFeePercentage).to.eq(subjectNewFee); - }); - - it("should send correct amount of fees to operator and methodologist", async () => { - const preManagerBalance = await setToken.balanceOf(baseManagerV2.address); - const feeState: any = await setV2Setup.streamingFeeModule.feeStates(setToken.address); - const totalSupply = await setToken.totalSupply(); - - const txnTimestamp = await getTransactionTimestamp(subject()); - - const expectedFeeInflation = await getStreamingFee( - setV2Setup.streamingFeeModule, - setToken.address, - feeState.lastStreamingFeeTimestamp, - txnTimestamp, - ether(.02) - ); - - const feeInflation = getStreamingFeeInflationAmount(expectedFeeInflation, totalSupply); - - const postManagerBalance = await setToken.balanceOf(baseManagerV2.address); - - expect(postManagerBalance.sub(preManagerBalance)).to.eq(feeInflation); - }); - }); - }); - - describe("when the caller is not the operator", async () => { - beforeEach(async () => { - subjectCaller = methodologist; - }); - - it("should revert", async () => { - await expect(subject()).to.be.revertedWith("Must be operator"); - }); - }); - }); - - describe("#updateIssueFee", async () => { - let subjectNewFee: BigNumber; - let subjectCaller: Account; - - beforeEach(async () => { - subjectNewFee = ether(.02); - subjectCaller = operator; - }); - - async function subject(): Promise { - return await feeAdapter.connect(subjectCaller.wallet).updateIssueFee(subjectNewFee); - } - - context("when no timelock period has been set", async () => { - it("should update the issue fee", async () => { - await subject(); - - const issueState: any = await setV2Setup.debtIssuanceModule.issuanceSettings(setToken.address); - - expect(issueState.managerIssueFee).to.eq(subjectNewFee); - }); - }); - - context("when 1 day timelock period has been set", async () => { - beforeEach(async () => { - await feeAdapter.connect(owner.wallet).setTimeLockPeriod(ONE_DAY_IN_SECONDS); - }); - - it("sets the upgradeHash", async () => { - await subject(); - const timestamp = await getLastBlockTimestamp(); - const calldata = feeAdapter.interface.encodeFunctionData("updateIssueFee", [subjectNewFee]); - const upgradeHash = solidityKeccak256(["bytes"], [calldata]); - const actualTimestamp = await feeAdapter.timeLockedUpgrades(upgradeHash); - expect(actualTimestamp).to.eq(timestamp); - }); - - context("when 1 day timelock has elapsed", async () => { - beforeEach(async () => { - await subject(); - await increaseTimeAsync(ONE_DAY_IN_SECONDS.add(1)); - }); - - it("sets the new streaming fee", async () => { - await subject(); - const feeStates = await setV2Setup.streamingFeeModule.feeStates(setToken.address); - const newStreamingFee = feeStates.streamingFeePercentage; - - expect(newStreamingFee).to.eq(subjectNewFee); - }); - - it("sets the upgradeHash to 0", async () => { - await subject(); - const calldata = feeAdapter.interface.encodeFunctionData("updateIssueFee", [subjectNewFee]); - const upgradeHash = solidityKeccak256(["bytes"], [calldata]); - const actualTimestamp = await feeAdapter.timeLockedUpgrades(upgradeHash); - expect(actualTimestamp).to.eq(ZERO); - }); - }); - }); - - describe("when the caller is not the operator", async () => { - beforeEach(async () => { - subjectCaller = methodologist; - }); - - it("should revert", async () => { - await expect(subject()).to.be.revertedWith("Must be operator"); - }); - }); - }); - - describe("#updateRedeemFee", async () => { - let subjectNewFee: BigNumber; - let subjectCaller: Account; - - beforeEach(async () => { - subjectNewFee = ether(.02); - subjectCaller = operator; - }); - - async function subject(): Promise { - return await feeAdapter.connect(subjectCaller.wallet).updateRedeemFee(subjectNewFee); - } - - context("when no timelock period has been set", async () => { - it("should update the redeem fee", async () => { - await subject(); - - const issuanceState: any = await setV2Setup.debtIssuanceModule.issuanceSettings(setToken.address); - - expect(issuanceState.managerRedeemFee).to.eq(subjectNewFee); - }); - }); - - context("when 1 day timelock period has been set", async () => { - beforeEach(async () => { - await feeAdapter.connect(owner.wallet).setTimeLockPeriod(ONE_DAY_IN_SECONDS); - }); - - it("sets the upgradeHash", async () => { - await subject(); - const timestamp = await getLastBlockTimestamp(); - const calldata = feeAdapter.interface.encodeFunctionData("updateRedeemFee", [subjectNewFee]); - const upgradeHash = solidityKeccak256(["bytes"], [calldata]); - const actualTimestamp = await feeAdapter.timeLockedUpgrades(upgradeHash); - expect(actualTimestamp).to.eq(timestamp); - }); - - context("when 1 day timelock has elapsed", async () => { - beforeEach(async () => { - await subject(); - await increaseTimeAsync(ONE_DAY_IN_SECONDS.add(1)); - }); - - it("sets the new streaming fee", async () => { - await subject(); - const feeStates = await setV2Setup.streamingFeeModule.feeStates(setToken.address); - const newStreamingFee = feeStates.streamingFeePercentage; - - expect(newStreamingFee).to.eq(subjectNewFee); - }); - - it("sets the upgradeHash to 0", async () => { - await subject(); - const calldata = feeAdapter.interface.encodeFunctionData("updateRedeemFee", [subjectNewFee]); - const upgradeHash = solidityKeccak256(["bytes"], [calldata]); - const actualTimestamp = await feeAdapter.timeLockedUpgrades(upgradeHash); - expect(actualTimestamp).to.eq(ZERO); - }); - }); - }); - - describe("when the caller is not the operator", async () => { - beforeEach(async () => { - subjectCaller = methodologist; - }); - - it("should revert", async () => { - await expect(subject()).to.be.revertedWith("Must be operator"); - }); - }); - }); - - describe("#updateFeeRecipient", async () => { - let subjectNewFeeRecipient: Address; - let subjectCaller: Account; - - beforeEach(async () => { - subjectNewFeeRecipient = owner.address; - subjectCaller = operator; - }); - - async function subject(): Promise { - return await feeAdapter.connect(subjectCaller.wallet).updateFeeRecipient(subjectNewFeeRecipient); - } - - it("sets the new fee recipients", async () => { - await subject(); - - const streamingFeeState = await setV2Setup.streamingFeeModule.feeStates(setToken.address); - const issuanceFeeState = await setV2Setup.debtIssuanceModule.issuanceSettings(setToken.address); - - expect(streamingFeeState.feeRecipient).to.eq(subjectNewFeeRecipient); - expect(issuanceFeeState.feeRecipient).to.eq(subjectNewFeeRecipient); - }); - - describe("when the caller is not the operator", async () => { - beforeEach(async () => { - subjectCaller = methodologist; - }); - - it("should revert", async () => { - await expect(subject()).to.be.revertedWith("Must be operator"); - }); - }); - }); - - describe("#updateFeeSplit", async () => { - let subjectNewFeeSplit: BigNumber; - let subjectCaller: Account; - - const mintedTokens: BigNumber = ether(2); - const timeFastForward: BigNumber = ONE_YEAR_IN_SECONDS; - - beforeEach(async () => { - await setV2Setup.dai.approve(setV2Setup.debtIssuanceModule.address, ether(3)); - await setV2Setup.debtIssuanceModule.issue(setToken.address, mintedTokens, owner.address); - - await increaseTimeAsync(timeFastForward); - - subjectNewFeeSplit = ether(.5); - subjectCaller = operator; - }); - - async function subject(): Promise { - return await feeAdapter.connect(subjectCaller.wallet).updateFeeSplit(subjectNewFeeSplit); - } - - it("should accrue fees and send correct amount to operator and methodologist", async () => { - const feeState: any = await setV2Setup.streamingFeeModule.feeStates(setToken.address); - const totalSupply = await setToken.totalSupply(); - - const txnTimestamp = await getTransactionTimestamp(subject()); - - const expectedFeeInflation = await getStreamingFee( - setV2Setup.streamingFeeModule, - setToken.address, - feeState.lastStreamingFeeTimestamp, - txnTimestamp - ); - - const feeInflation = getStreamingFeeInflationAmount(expectedFeeInflation, totalSupply); - - const expectedMintRedeemFees = preciseMul(mintedTokens, ether(.01)); - const expectedOperatorTake = preciseMul(feeInflation.add(expectedMintRedeemFees), operatorSplit); - const expectedMethodologistTake = feeInflation.add(expectedMintRedeemFees).sub(expectedOperatorTake); - - const operatorBalance = await setToken.balanceOf(operator.address); - const methodologistBalance = await setToken.balanceOf(methodologist.address); - - expect(operatorBalance).to.eq(expectedOperatorTake); - expect(methodologistBalance).to.eq(expectedMethodologistTake); - }); - - it("sets the new fee split", async () => { - await subject(); - - const actualFeeSplit = await feeAdapter.operatorFeeSplit(); - - expect(actualFeeSplit).to.eq(subjectNewFeeSplit); - }); - - describe("when fee splits is >100%", async () => { - beforeEach(async () => { - subjectNewFeeSplit = ether(1.1); - }); - - it("should revert", async () => { - await expect(subject()).to.be.revertedWith("Fee must be less than 100%"); - }); - }); - - describe("when the caller is not the operator", async () => { - beforeEach(async () => { - subjectCaller = owner; - }); - - it("should revert", async () => { - await expect(subject()).to.be.revertedWith("Must be operator"); - }); - }); - }); - }); -}); \ No newline at end of file diff --git a/test/adapters/feeSplitExtension.spec.ts b/test/adapters/feeSplitExtension.spec.ts new file mode 100644 index 00000000..f3dc8a30 --- /dev/null +++ b/test/adapters/feeSplitExtension.spec.ts @@ -0,0 +1,1101 @@ +import "module-alias/register"; + +import { solidityKeccak256 } from "ethers/lib/utils"; +import { Address, Account } from "@utils/types"; +import { ADDRESS_ZERO, ZERO, ONE_DAY_IN_SECONDS, ONE_YEAR_IN_SECONDS } from "@utils/constants"; +import { FeeSplitExtension, StreamingFeeModule, DebtIssuanceModule, BaseManagerV2 } from "@utils/contracts/index"; +import { SetToken } from "@utils/contracts/setV2"; +import DeployHelper from "@utils/deploys"; +import { + addSnapshotBeforeRestoreAfterEach, + ether, + getAccounts, + getLastBlockTimestamp, + getSetFixture, + getStreamingFee, + getStreamingFeeInflationAmount, + getTransactionTimestamp, + getWaffleExpect, + increaseTimeAsync, + preciseMul, + getRandomAccount +} from "@utils/index"; +import { SetFixture } from "@utils/fixtures"; +import { BigNumber, ContractTransaction } from "ethers"; + +const expect = getWaffleExpect(); + +describe("FeeSplitExtension", () => { + let owner: Account; + let methodologist: Account; + let operator: Account; + let operatorFeeRecipient: Account; + let setV2Setup: SetFixture; + + let deployer: DeployHelper; + let setToken: SetToken; + + let baseManagerV2: BaseManagerV2; + let feeExtension: FeeSplitExtension; + + before(async () => { + [ + owner, + methodologist, + operator, + operatorFeeRecipient, + ] = await getAccounts(); + + deployer = new DeployHelper(owner.wallet); + + setV2Setup = getSetFixture(owner.address); + await setV2Setup.initialize(); + + setToken = await setV2Setup.createSetToken( + [setV2Setup.dai.address], + [ether(1)], + [setV2Setup.debtIssuanceModule.address, setV2Setup.streamingFeeModule.address] + ); + + // Deploy BaseManager + baseManagerV2 = await deployer.manager.deployBaseManagerV2( + setToken.address, + operator.address, + methodologist.address + ); + await baseManagerV2.connect(methodologist.wallet).authorizeInitialization(); + + const feeRecipient = baseManagerV2.address; + const maxStreamingFeePercentage = ether(.1); + const streamingFeePercentage = ether(.02); + const streamingFeeSettings = { + feeRecipient, + maxStreamingFeePercentage, + streamingFeePercentage, + lastStreamingFeeTimestamp: ZERO, + }; + await setV2Setup.streamingFeeModule.initialize(setToken.address, streamingFeeSettings); + + await setV2Setup.debtIssuanceModule.initialize( + setToken.address, + ether(.1), + ether(.01), + ether(.005), + baseManagerV2.address, + ADDRESS_ZERO + ); + }); + + addSnapshotBeforeRestoreAfterEach(); + + describe("#constructor", async () => { + let subjectManager: Address; + let subjectStreamingFeeModule: Address; + let subjectDebtIssuanceModule: Address; + let subjectOperatorFeeSplit: BigNumber; + let subjectOperatorFeeRecipient: Address; + + beforeEach(async () => { + subjectManager = baseManagerV2.address; + subjectStreamingFeeModule = setV2Setup.streamingFeeModule.address; + subjectDebtIssuanceModule = setV2Setup.debtIssuanceModule.address; + subjectOperatorFeeSplit = ether(.7); + subjectOperatorFeeRecipient = operatorFeeRecipient.address; + }); + + async function subject(): Promise { + return await deployer.extensions.deployFeeSplitExtension( + subjectManager, + subjectStreamingFeeModule, + subjectDebtIssuanceModule, + subjectOperatorFeeSplit, + subjectOperatorFeeRecipient + ); + } + + it("should set the correct SetToken address", async () => { + const feeExtension = await subject(); + + const actualToken = await feeExtension.setToken(); + expect(actualToken).to.eq(setToken.address); + }); + + it("should set the correct manager address", async () => { + const feeExtension = await subject(); + + const actualManager = await feeExtension.manager(); + expect(actualManager).to.eq(baseManagerV2.address); + }); + + it("should set the correct streaming fee module address", async () => { + const feeExtension = await subject(); + + const actualStreamingFeeModule = await feeExtension.streamingFeeModule(); + expect(actualStreamingFeeModule).to.eq(subjectStreamingFeeModule); + }); + + it("should set the correct debt issuance module address", async () => { + const feeExtension = await subject(); + + const actualDebtIssuanceModule = await feeExtension.issuanceModule(); + expect(actualDebtIssuanceModule).to.eq(subjectDebtIssuanceModule); + }); + + it("should set the correct operator fee split", async () => { + const feeExtension = await subject(); + + const actualOperatorFeeSplit = await feeExtension.operatorFeeSplit(); + expect(actualOperatorFeeSplit).to.eq(subjectOperatorFeeSplit); + }); + + it("should set the correct operator fee recipient", async () => { + const feeExtension = await subject(); + + const actualOperatorFeeRecipient = await feeExtension.operatorFeeRecipient(); + expect(actualOperatorFeeRecipient).to.eq(subjectOperatorFeeRecipient); + }); + }); + + context("when fee extension is deployed and system fully set up", async () => { + const operatorSplit: BigNumber = ether(.7); + + beforeEach(async () => { + feeExtension = await deployer.extensions.deployFeeSplitExtension( + baseManagerV2.address, + setV2Setup.streamingFeeModule.address, + setV2Setup.debtIssuanceModule.address, + operatorSplit, + operatorFeeRecipient.address + ); + + await baseManagerV2.connect(operator.wallet).addExtension(feeExtension.address); + + // Transfer ownership to BaseManager + await setToken.setManager(baseManagerV2.address); + + // Protect StreamingFeeModule + await baseManagerV2 + .connect(operator.wallet) + .protectModule(setV2Setup.streamingFeeModule.address, [feeExtension.address]); + + // Set extension as fee recipient + await feeExtension.connect(operator.wallet).updateFeeRecipient(feeExtension.address); + await feeExtension.connect(methodologist.wallet).updateFeeRecipient(feeExtension.address); + }); + + describe("#accrueFeesAndDistribute", async () => { + let mintedTokens: BigNumber; + const timeFastForward: BigNumber = ONE_YEAR_IN_SECONDS; + + beforeEach(async () => { + mintedTokens = ether(2); + await setV2Setup.dai.approve(setV2Setup.debtIssuanceModule.address, ether(3)); + await setV2Setup.debtIssuanceModule.issue(setToken.address, mintedTokens, owner.address); + + await increaseTimeAsync(timeFastForward); + }); + + async function subject(): Promise { + return await feeExtension.accrueFeesAndDistribute(); + } + + it("should send correct amount of fees to operator fee recipient and methodologist", async () => { + const feeState: any = await setV2Setup.streamingFeeModule.feeStates(setToken.address); + const totalSupply = await setToken.totalSupply(); + + const txnTimestamp = await getTransactionTimestamp(subject()); + + const expectedFeeInflation = await getStreamingFee( + setV2Setup.streamingFeeModule, + setToken.address, + feeState.lastStreamingFeeTimestamp, + txnTimestamp + ); + + const feeInflation = getStreamingFeeInflationAmount(expectedFeeInflation, totalSupply); + + const expectedMintRedeemFees = preciseMul(mintedTokens, ether(.01)); + const expectedOperatorTake = preciseMul(feeInflation.add(expectedMintRedeemFees), operatorSplit); + const expectedMethodologistTake = feeInflation.add(expectedMintRedeemFees).sub(expectedOperatorTake); + + const operatorFeeRecipientBalance = await setToken.balanceOf(operatorFeeRecipient.address); + const methodologistBalance = await setToken.balanceOf(methodologist.address); + + expect(operatorFeeRecipientBalance).to.eq(expectedOperatorTake); + expect(methodologistBalance).to.eq(expectedMethodologistTake); + }); + + it("should emit a FeesDistributed event", async () => { + await expect(subject()).to.emit(feeExtension, "FeesDistributed"); + }); + + describe("when methodologist fees are 0", async () => { + beforeEach(async () => { + await feeExtension.connect(operator.wallet).updateFeeSplit(ether(1)); + await feeExtension.connect(methodologist.wallet).updateFeeSplit(ether(1)); + }); + + it("should not send fees to methodologist", async () => { + const preMethodologistBalance = await setToken.balanceOf(methodologist.address); + + await subject(); + + const postMethodologistBalance = await setToken.balanceOf(methodologist.address); + expect(postMethodologistBalance.sub(preMethodologistBalance)).to.eq(ZERO); + }); + }); + + describe("when operator fees are 0", async () => { + beforeEach(async () => { + await feeExtension.connect(operator.wallet).updateFeeSplit(ZERO); + await feeExtension.connect(methodologist.wallet).updateFeeSplit(ZERO); + }); + + it("should not send fees to operator fee recipient", async () => { + const preOperatorFeeRecipientBalance = await setToken.balanceOf(operatorFeeRecipient.address); + + await subject(); + + const postOperatorFeeRecipientBalance = await setToken.balanceOf(operatorFeeRecipient.address); + expect(postOperatorFeeRecipientBalance.sub(preOperatorFeeRecipientBalance)).to.eq(ZERO); + }); + }); + + describe("when extension has fees accrued, is removed and no longer the feeRecipient", () => { + let txnTimestamp: BigNumber; + let feeState: any; + let expectedFeeInflation: BigNumber; + let totalSupply: BigNumber; + + beforeEach(async () => { + feeState = await setV2Setup.streamingFeeModule.feeStates(setToken.address); + totalSupply = await setToken.totalSupply(); + + // Accrue fees to extension by StreamingFeeModule by direct call + txnTimestamp = await getTransactionTimestamp( + setV2Setup.streamingFeeModule.accrueFee(setToken.address) + ); + + expectedFeeInflation = await getStreamingFee( + setV2Setup.streamingFeeModule, + setToken.address, + feeState.lastStreamingFeeTimestamp, + txnTimestamp + ); + + // Change fee recipient to baseManagerV2; + await feeExtension.connect(operator.wallet).updateFeeRecipient(baseManagerV2.address); + await feeExtension.connect(methodologist.wallet).updateFeeRecipient(baseManagerV2.address); + + // Revoke extension authorization + await baseManagerV2.connect(operator.wallet).revokeExtensionAuthorization( + setV2Setup.streamingFeeModule.address, + feeExtension.address + ); + + await baseManagerV2.connect(methodologist.wallet).revokeExtensionAuthorization( + setV2Setup.streamingFeeModule.address, + feeExtension.address + ); + + // Remove extension + await baseManagerV2.connect(operator.wallet).removeExtension(feeExtension.address); + }); + + it("should send residual fees to operator fee recipient and methodologist", async () => { + await subject(); + + const feeInflation = getStreamingFeeInflationAmount(expectedFeeInflation, totalSupply); + + const expectedMintRedeemFees = preciseMul(mintedTokens, ether(.01)); + const expectedOperatorTake = preciseMul(feeInflation.add(expectedMintRedeemFees), operatorSplit); + const expectedMethodologistTake = feeInflation.add(expectedMintRedeemFees).sub(expectedOperatorTake); + + const operatorFeeRecipientBalance = await setToken.balanceOf(operatorFeeRecipient.address); + const methodologistBalance = await setToken.balanceOf(methodologist.address); + + expect(operatorFeeRecipientBalance).to.eq(expectedOperatorTake); + expect(methodologistBalance).to.eq(expectedMethodologistTake); + }); + }); + }); + + describe("#initializeIssuanceModule", () => { + let subjectSetToken: Address; + let subjectExtension: FeeSplitExtension; + let subjectIssuanceModule: DebtIssuanceModule; + let subjectStreamingFeeModule: StreamingFeeModule; + let subjectManager: Address; + let subjectOperatorFeeSplit: BigNumber; + let subjectOperatorFeeRecipient: Address; + let subjectMaxManagerFee: BigNumber; + let subjectManagerIssueFee: BigNumber; + let subjectManagerRedeemFee: BigNumber; + let subjectManagerIssuanceHook: Address; + + beforeEach( async () => { + subjectSetToken = setToken.address; + subjectManager = baseManagerV2.address; + subjectStreamingFeeModule = setV2Setup.streamingFeeModule; + subjectOperatorFeeSplit = ether(.7); + subjectOperatorFeeRecipient = operator.address; + subjectMaxManagerFee = ether(.1); + subjectManagerIssueFee = ether(.01); + subjectManagerRedeemFee = ether(.005); + subjectManagerIssuanceHook = ADDRESS_ZERO; + + // Protect current issuance Module + await baseManagerV2.connect(operator.wallet).protectModule(setV2Setup.debtIssuanceModule.address, []); + + // Deploy new issuance module + subjectIssuanceModule = await deployer.setV2.deployDebtIssuanceModule(setV2Setup.controller.address); + await setV2Setup.controller.addModule(subjectIssuanceModule.address); + + // Deploy new issuance extension + subjectExtension = await deployer.extensions.deployFeeSplitExtension( + subjectManager, + subjectStreamingFeeModule.address, + subjectIssuanceModule.address, + subjectOperatorFeeSplit, + subjectOperatorFeeRecipient, + ); + + // Replace module and extension + await baseManagerV2.connect(operator.wallet).replaceProtectedModule( + setV2Setup.debtIssuanceModule.address, + subjectIssuanceModule.address, + [subjectExtension.address] + ); + + await baseManagerV2.connect(methodologist.wallet).replaceProtectedModule( + setV2Setup.debtIssuanceModule.address, + subjectIssuanceModule.address, + [subjectExtension.address] + ); + + // Authorize new extension for StreamingFeeModule too.. + await baseManagerV2.connect(operator.wallet).authorizeExtension( + subjectStreamingFeeModule.address, + subjectExtension.address + ); + + await baseManagerV2.connect(methodologist.wallet).authorizeExtension( + subjectStreamingFeeModule.address, + subjectExtension.address + ); + }); + + async function subject(caller: Account): Promise { + return await subjectExtension.connect(caller.wallet).initializeIssuanceModule( + subjectSetToken, + subjectMaxManagerFee, + subjectManagerIssueFee, + subjectManagerRedeemFee, + subjectExtension.address, + subjectManagerIssuanceHook + ); + } + + context("when both parties call the method", async () => { + it("should initialize the debt issuance module", async () => { + const initialFeeRecipient = ( + await subjectIssuanceModule.issuanceSettings(subjectSetToken) + ).feeRecipient; + + await subject(operator); + await subject(methodologist); + + const finalFeeRecipient = ( + await subjectIssuanceModule.issuanceSettings(subjectSetToken) + ).feeRecipient; + + expect(initialFeeRecipient).to.equal(ADDRESS_ZERO); + expect(finalFeeRecipient).to.equal(subjectExtension.address); + }); + + it("should enable calls on the protected module", async () => { + const newFeeRecipient = baseManagerV2.address; + + await subject(operator); + await subject(methodologist); + + // Reset fee recipient + await subjectExtension.connect(operator.wallet).updateFeeRecipient(newFeeRecipient); + await subjectExtension.connect(methodologist.wallet).updateFeeRecipient(newFeeRecipient); + + const receivedFeeRecipient = ( + await subjectIssuanceModule.issuanceSettings(subjectSetToken) + ).feeRecipient; + + expect(receivedFeeRecipient).to.equal(newFeeRecipient); + }); + }); + + context("when a single mutual upgrade party has called the method", async () => { + afterEach(async () => await subject(methodologist)); + + it("should log the proposed streaming fee hash in the mutualUpgrades mapping", async () => { + const txHash = await subject(operator); + + const expectedHash = solidityKeccak256( + ["bytes", "address"], + [txHash.data, operator.address] + ); + + const isLogged = await subjectExtension.mutualUpgrades(expectedHash); + + expect(isLogged).to.be.true; + }); + }); + + describe("when the caller is not the operator or methodologist", async () => { + it("should revert", async () => { + await expect(subject(await getRandomAccount())).to.be.revertedWith("Must be authorized address"); + }); + }); + }); + + describe("#initializeStreamingFeeModule", () => { + let subjectSetToken: Address; + let subjectFeeSettings: any; + let subjectExtension: FeeSplitExtension; + let subjectIssuanceModule: DebtIssuanceModule; + let subjectFeeModule: StreamingFeeModule; + let subjectManager: Address; + let subjectOperatorFeeSplit: BigNumber; + let subjectOperatorFeeRecipient: Address; + + beforeEach( async () => { + subjectSetToken = setToken.address; + subjectIssuanceModule = setV2Setup.debtIssuanceModule; + subjectManager = baseManagerV2.address; + subjectOperatorFeeSplit = ether(.7); + subjectOperatorFeeRecipient = operator.address; + + // Deploy new fee module + subjectFeeModule = await deployer.setV2.deployStreamingFeeModule(setV2Setup.controller.address); + await setV2Setup.controller.addModule(subjectFeeModule.address); + + // Deploy new fee extension + subjectExtension = await deployer.extensions.deployFeeSplitExtension( + subjectManager, + subjectFeeModule.address, + subjectIssuanceModule.address, + subjectOperatorFeeSplit, + subjectOperatorFeeRecipient, + ); + + // Replace module and extension + await baseManagerV2.connect(operator.wallet).replaceProtectedModule( + setV2Setup.streamingFeeModule.address, + subjectFeeModule.address, + [subjectExtension.address] + ); + + await baseManagerV2.connect(methodologist.wallet).replaceProtectedModule( + setV2Setup.streamingFeeModule.address, + subjectFeeModule.address, + [subjectExtension.address] + ); + + subjectFeeSettings = { + feeRecipient: subjectExtension.address, + maxStreamingFeePercentage: ether(.01), + streamingFeePercentage: ether(.01), + lastStreamingFeeTimestamp: ZERO, + }; + }); + + async function subject(caller: Account): Promise { + return await subjectExtension + .connect(caller.wallet) + .initializeStreamingFeeModule(subjectFeeSettings); + } + + context("when both parties call the method", async () => { + it("should initialize the streaming fee module", async () => { + const initialFeeRecipient = (await subjectFeeModule.feeStates(subjectSetToken)).feeRecipient; + + await subject(operator); + await subject(methodologist); + + const finalFeeRecipient = (await subjectFeeModule.feeStates(subjectSetToken)).feeRecipient; + + expect(initialFeeRecipient).to.equal(ADDRESS_ZERO); + expect(finalFeeRecipient).to.equal(subjectExtension.address); + }); + + it("should enable calls on the protected module", async () => { + const newFeeRecipient = baseManagerV2.address; + + await subject(operator); + await subject(methodologist); + + // Reset fee recipient + await subjectExtension.connect(operator.wallet).updateFeeRecipient(newFeeRecipient); + await subjectExtension.connect(methodologist.wallet).updateFeeRecipient(newFeeRecipient); + + const receivedFeeRecipient = (await subjectFeeModule.feeStates(subjectSetToken)).feeRecipient; + + expect(receivedFeeRecipient).to.equal(newFeeRecipient); + }); + }); + + context("when a single mutual upgrade party has called the method", async () => { + afterEach(async () => await subject(methodologist)); + + it("should log the proposed streaming fee hash in the mutualUpgrades mapping", async () => { + const txHash = await subject(operator); + + const expectedHash = solidityKeccak256( + ["bytes", "address"], + [txHash.data, operator.address] + ); + + const isLogged = await subjectExtension.mutualUpgrades(expectedHash); + + expect(isLogged).to.be.true; + }); + }); + + describe("when the caller is not the operator or methodologist", async () => { + it("should revert", async () => { + await expect(subject(await getRandomAccount())).to.be.revertedWith("Must be authorized address"); + }); + }); + }); + + describe("#updateStreamingFee", async () => { + let mintedTokens: BigNumber; + const timeFastForward: BigNumber = ONE_YEAR_IN_SECONDS; + + let subjectNewFee: BigNumber; + let subjectOperatorCaller: Account; + let subjectMethodologistCaller: Account; + + beforeEach(async () => { + mintedTokens = ether(2); + await setV2Setup.dai.approve(setV2Setup.debtIssuanceModule.address, ether(3)); + await setV2Setup.debtIssuanceModule.issue(setToken.address, mintedTokens, owner.address); + + await increaseTimeAsync(timeFastForward); + + subjectNewFee = ether(.01); + subjectOperatorCaller = operator; + subjectMethodologistCaller = methodologist; + }); + + async function subject(caller: Account): Promise { + return await feeExtension.connect(caller.wallet).updateStreamingFee(subjectNewFee); + } + + context("when no timelock period has been set", async () => { + + context("when a single mutual upgrade party has called the method", () => { + it("should log the proposed streaming fee hash in the mutualUpgrades mapping", async () => { + const txHash = await subject(subjectOperatorCaller); + + const expectedHash = solidityKeccak256( + ["bytes", "address"], + [txHash.data, subjectOperatorCaller.address] + ); + + const isLogged = await feeExtension.mutualUpgrades(expectedHash); + + expect(isLogged).to.be.true; + }); + }); + + context("when both upgrade parties have called the method", () => { + it("should update the streaming fee", async () => { + await subject(subjectOperatorCaller); + await subject(subjectMethodologistCaller); + + const feeState = await setV2Setup.streamingFeeModule.feeStates(setToken.address); + expect(feeState.streamingFeePercentage).to.eq(subjectNewFee); + }); + + it("should send correct amount of fees to the fee extension", async () => { + const preExtensionBalance = await setToken.balanceOf(feeExtension.address); + const feeState: any = await setV2Setup.streamingFeeModule.feeStates(setToken.address); + const totalSupply = await setToken.totalSupply(); + + await subject(subjectOperatorCaller); + const txnTimestamp = await getTransactionTimestamp(subject(subjectMethodologistCaller)); + + const expectedFeeInflation = await getStreamingFee( + setV2Setup.streamingFeeModule, + setToken.address, + feeState.lastStreamingFeeTimestamp, + txnTimestamp, + ether(.02) + ); + + const feeInflation = getStreamingFeeInflationAmount(expectedFeeInflation, totalSupply); + + const postExtensionBalance = await setToken.balanceOf(feeExtension.address); + + expect(postExtensionBalance.sub(preExtensionBalance)).to.eq(feeInflation); + }); + }); + }); + + context("when 1 day timelock period has been set", async () => { + beforeEach(async () => { + await feeExtension.connect(owner.wallet).setTimeLockPeriod(ONE_DAY_IN_SECONDS); + }); + + it("sets the upgradeHash", async () => { + await subject(subjectOperatorCaller); + await subject(subjectMethodologistCaller); + const timestamp = await getLastBlockTimestamp(); + const calldata = feeExtension.interface.encodeFunctionData("updateStreamingFee", [subjectNewFee]); + const upgradeHash = solidityKeccak256(["bytes"], [calldata]); + const actualTimestamp = await feeExtension.timeLockedUpgrades(upgradeHash); + expect(actualTimestamp).to.eq(timestamp); + }); + + context("when 1 day timelock has elapsed", async () => { + beforeEach(async () => { + await subject(subjectOperatorCaller); + await subject(subjectMethodologistCaller); + await increaseTimeAsync(ONE_DAY_IN_SECONDS.add(1)); + }); + + it("should update the streaming fee", async () => { + await subject(subjectOperatorCaller); + await subject(subjectMethodologistCaller); + + const feeState = await setV2Setup.streamingFeeModule.feeStates(setToken.address); + + expect(feeState.streamingFeePercentage).to.eq(subjectNewFee); + }); + + it("should send correct amount of fees to the fee extension", async () => { + const preExtensionBalance = await setToken.balanceOf(feeExtension.address); + const feeState: any = await setV2Setup.streamingFeeModule.feeStates(setToken.address); + const totalSupply = await setToken.totalSupply(); + + await subject(subjectOperatorCaller); + const txnTimestamp = await getTransactionTimestamp(subject(subjectMethodologistCaller)); + + const expectedFeeInflation = await getStreamingFee( + setV2Setup.streamingFeeModule, + setToken.address, + feeState.lastStreamingFeeTimestamp, + txnTimestamp, + ether(.02) + ); + + const feeInflation = getStreamingFeeInflationAmount(expectedFeeInflation, totalSupply); + + const postExtensionBalance = await setToken.balanceOf(feeExtension.address); + + expect(postExtensionBalance.sub(preExtensionBalance)).to.eq(feeInflation); + }); + }); + }); + + describe("when the caller is not the operator or methodologist", async () => { + beforeEach(async () => { + subjectOperatorCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject(subjectOperatorCaller)).to.be.revertedWith("Must be authorized address"); + }); + }); + }); + + describe("#updateIssueFee", async () => { + let subjectNewFee: BigNumber; + let subjectOperatorCaller: Account; + let subjectMethodologistCaller: Account; + + beforeEach(async () => { + subjectNewFee = ether(.02); + subjectOperatorCaller = operator; + subjectMethodologistCaller = methodologist; + }); + + async function subject(caller: Account): Promise { + return await feeExtension.connect(caller.wallet).updateIssueFee(subjectNewFee); + } + + context("when no timelock period has been set", async () => { + context("when a single mutual upgrade party has called the method", () => { + it("should log the proposed streaming fee hash in the mutualUpgrades mapping", async () => { + const txHash = await subject(subjectOperatorCaller); + + const expectedHash = solidityKeccak256( + ["bytes", "address"], + [txHash.data, subjectOperatorCaller.address] + ); + + const isLogged = await feeExtension.mutualUpgrades(expectedHash); + + expect(isLogged).to.be.true; + }); + }); + + context("when both upgrade parties have called the method", () => { + it("should update the issue fee", async () => { + await subject(subjectOperatorCaller); + await subject(subjectMethodologistCaller); + + const issueState: any = await setV2Setup.debtIssuanceModule.issuanceSettings(setToken.address); + + expect(issueState.managerIssueFee).to.eq(subjectNewFee); + }); + }); + }); + + context("when 1 day timelock period has been set", async () => { + beforeEach(async () => { + await feeExtension.connect(owner.wallet).setTimeLockPeriod(ONE_DAY_IN_SECONDS); + }); + + it("sets the upgradeHash", async () => { + await subject(subjectOperatorCaller); + await subject(subjectMethodologistCaller); + + const timestamp = await getLastBlockTimestamp(); + const calldata = feeExtension.interface.encodeFunctionData("updateIssueFee", [subjectNewFee]); + const upgradeHash = solidityKeccak256(["bytes"], [calldata]); + const actualTimestamp = await feeExtension.timeLockedUpgrades(upgradeHash); + expect(actualTimestamp).to.eq(timestamp); + }); + + context("when 1 day timelock has elapsed", async () => { + beforeEach(async () => { + await subject(subjectOperatorCaller); + await subject(subjectMethodologistCaller); + await increaseTimeAsync(ONE_DAY_IN_SECONDS.add(1)); + }); + + it("sets the new issue fee", async () => { + await subject(subjectOperatorCaller); + await subject(subjectMethodologistCaller); + + const issueState: any = await setV2Setup.debtIssuanceModule.issuanceSettings(setToken.address); + expect(issueState.managerIssueFee).to.eq(subjectNewFee); + }); + + it("sets the upgradeHash to 0", async () => { + await subject(subjectOperatorCaller); + await subject(subjectMethodologistCaller); + + const calldata = feeExtension.interface.encodeFunctionData("updateIssueFee", [subjectNewFee]); + const upgradeHash = solidityKeccak256(["bytes"], [calldata]); + const actualTimestamp = await feeExtension.timeLockedUpgrades(upgradeHash); + expect(actualTimestamp).to.eq(ZERO); + }); + }); + }); + + describe("when the caller is not the operator or methodologist", async () => { + beforeEach(async () => { + subjectOperatorCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject(subjectOperatorCaller)).to.be.revertedWith("Must be authorized address"); + }); + }); + }); + + describe("#updateRedeemFee", async () => { + let subjectNewFee: BigNumber; + let subjectOperatorCaller: Account; + let subjectMethodologistCaller: Account; + + beforeEach(async () => { + subjectNewFee = ether(.02); + subjectOperatorCaller = operator; + subjectMethodologistCaller = methodologist; + }); + + async function subject(caller: Account): Promise { + return await feeExtension.connect(caller.wallet).updateRedeemFee(subjectNewFee); + } + + context("when no timelock period has been set", () => { + context("when a single mutual upgrade party has called the method", () => { + it("should log the proposed streaming fee hash in the mutualUpgrades mapping", async () => { + const txHash = await subject(subjectOperatorCaller); + + const expectedHash = solidityKeccak256( + ["bytes", "address"], + [txHash.data, subjectOperatorCaller.address] + ); + + const isLogged = await feeExtension.mutualUpgrades(expectedHash); + + expect(isLogged).to.be.true; + }); + }); + + context("when both upgrade parties have called the method", () => { + it("should update the redeem fee", async () => { + await subject(subjectOperatorCaller); + await subject(subjectMethodologistCaller); + + const issuanceState: any = await setV2Setup.debtIssuanceModule.issuanceSettings(setToken.address); + + expect(issuanceState.managerRedeemFee).to.eq(subjectNewFee); + }); + }); + }); + + context("when 1 day timelock period has been set", async () => { + beforeEach(async () => { + await feeExtension.connect(owner.wallet).setTimeLockPeriod(ONE_DAY_IN_SECONDS); + }); + + it("sets the upgradeHash", async () => { + await subject(subjectOperatorCaller); + await subject(subjectMethodologistCaller); + + const timestamp = await getLastBlockTimestamp(); + const calldata = feeExtension.interface.encodeFunctionData("updateRedeemFee", [subjectNewFee]); + const upgradeHash = solidityKeccak256(["bytes"], [calldata]); + const actualTimestamp = await feeExtension.timeLockedUpgrades(upgradeHash); + expect(actualTimestamp).to.eq(timestamp); + }); + + context("when 1 day timelock has elapsed", async () => { + beforeEach(async () => { + await subject(subjectOperatorCaller); + await subject(subjectMethodologistCaller); + await increaseTimeAsync(ONE_DAY_IN_SECONDS.add(1)); + }); + + it("sets the new redeem fee", async () => { + await subject(subjectOperatorCaller); + await subject(subjectMethodologistCaller); + + const issuanceState: any = await setV2Setup.debtIssuanceModule.issuanceSettings(setToken.address); + expect(issuanceState.managerRedeemFee).to.eq(subjectNewFee); + }); + + it("sets the upgradeHash to 0", async () => { + await subject(subjectOperatorCaller); + await subject(subjectMethodologistCaller); + + const calldata = feeExtension.interface.encodeFunctionData("updateRedeemFee", [subjectNewFee]); + const upgradeHash = solidityKeccak256(["bytes"], [calldata]); + const actualTimestamp = await feeExtension.timeLockedUpgrades(upgradeHash); + expect(actualTimestamp).to.eq(ZERO); + }); + }); + }); + + describe("when the caller is not the operator or methodologist", async () => { + beforeEach(async () => { + subjectOperatorCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject(subjectOperatorCaller)).to.be.revertedWith("Must be authorized address"); + }); + }); + }); + + describe("#updateFeeRecipient", async () => { + let subjectNewFeeRecipient: Address; + let subjectOperatorCaller: Account; + let subjectMethodologistCaller: Account; + + beforeEach(async () => { + subjectNewFeeRecipient = owner.address; + subjectOperatorCaller = operator; + subjectMethodologistCaller = methodologist; + }); + + async function subject(caller: Account): Promise { + return await feeExtension.connect(caller.wallet).updateFeeRecipient(subjectNewFeeRecipient); + } + + context("when a single mutual upgrade party has called the method", () => { + it("should log the proposed streaming fee hash in the mutualUpgrades mapping", async () => { + const txHash = await subject(subjectOperatorCaller); + + const expectedHash = solidityKeccak256( + ["bytes", "address"], + [txHash.data, subjectOperatorCaller.address] + ); + + const isLogged = await feeExtension.mutualUpgrades(expectedHash); + + expect(isLogged).to.be.true; + }); + }); + + context("when operator and methodologist both execute update", () => { + it("sets the new fee recipients", async () => { + await subject(subjectOperatorCaller); + await subject(subjectMethodologistCaller); + + const streamingFeeState = await setV2Setup.streamingFeeModule.feeStates(setToken.address); + const issuanceFeeState = await setV2Setup.debtIssuanceModule.issuanceSettings(setToken.address); + + expect(streamingFeeState.feeRecipient).to.eq(subjectNewFeeRecipient); + expect(issuanceFeeState.feeRecipient).to.eq(subjectNewFeeRecipient); + }); + }); + + describe("when the caller is not the operator or methodologist", async () => { + beforeEach(async () => { + subjectOperatorCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject(subjectOperatorCaller)).to.be.revertedWith("Must be authorized address"); + }); + }); + }); + + describe("#updateFeeSplit", async () => { + let subjectNewFeeSplit: BigNumber; + let subjectOperatorCaller: Account; + let subjectMethodologistCaller: Account; + + const mintedTokens: BigNumber = ether(2); + const timeFastForward: BigNumber = ONE_YEAR_IN_SECONDS; + + beforeEach(async () => { + await setV2Setup.dai.approve(setV2Setup.debtIssuanceModule.address, ether(3)); + await setV2Setup.debtIssuanceModule.issue(setToken.address, mintedTokens, owner.address); + + await increaseTimeAsync(timeFastForward); + + subjectNewFeeSplit = ether(.5); + subjectOperatorCaller = operator; + subjectMethodologistCaller = methodologist; + }); + + async function subject(caller: Account): Promise { + return await feeExtension.connect(caller.wallet).updateFeeSplit(subjectNewFeeSplit); + } + + context("when operator and methodologist both execute update", () => { + it("should accrue fees and send correct amount to operator fee recipient and methodologist", async () => { + const feeState: any = await setV2Setup.streamingFeeModule.feeStates(setToken.address); + const totalSupply = await setToken.totalSupply(); + + await subject(subjectOperatorCaller); + const txnTimestamp = await getTransactionTimestamp(await subject(subjectMethodologistCaller)); + + const expectedFeeInflation = await getStreamingFee( + setV2Setup.streamingFeeModule, + setToken.address, + feeState.lastStreamingFeeTimestamp, + txnTimestamp + ); + + const feeInflation = getStreamingFeeInflationAmount(expectedFeeInflation, totalSupply); + + const expectedMintRedeemFees = preciseMul(mintedTokens, ether(.01)); + const expectedOperatorTake = preciseMul(feeInflation.add(expectedMintRedeemFees), operatorSplit); + const expectedMethodologistTake = feeInflation.add(expectedMintRedeemFees).sub(expectedOperatorTake); + + const operatorFeeRecipientBalance = await setToken.balanceOf(operatorFeeRecipient.address); + const methodologistBalance = await setToken.balanceOf(methodologist.address); + + expect(operatorFeeRecipientBalance).to.eq(expectedOperatorTake); + expect(methodologistBalance).to.eq(expectedMethodologistTake); + }); + + it("sets the new fee split", async () => { + await subject(subjectOperatorCaller); + await subject(subjectMethodologistCaller); + + const actualFeeSplit = await feeExtension.operatorFeeSplit(); + + expect(actualFeeSplit).to.eq(subjectNewFeeSplit); + }); + + describe("when fee splits is >100%", async () => { + beforeEach(async () => { + subjectNewFeeSplit = ether(1.1); + }); + + it("should revert", async () => { + await subject(subjectOperatorCaller); + await expect(subject(subjectMethodologistCaller)).to.be.revertedWith("Fee must be less than 100%"); + }); + }); + }); + + context("when a single mutual upgrade party has called the method", async () => { + afterEach(async () => await subject(subjectMethodologistCaller)); + + it("should log the proposed streaming fee hash in the mutualUpgrades mapping", async () => { + const txHash = await subject(subjectOperatorCaller); + + const expectedHash = solidityKeccak256( + ["bytes", "address"], + [txHash.data, subjectOperatorCaller.address] + ); + + const isLogged = await feeExtension.mutualUpgrades(expectedHash); + + expect(isLogged).to.be.true; + }); + }); + + describe("when the caller is not the operator or methodologist", async () => { + beforeEach(async () => { + subjectOperatorCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject(subjectOperatorCaller)).to.be.revertedWith("Must be authorized address"); + }); + }); + }); + + describe("#updateOperatorFeeRecipient", async () => { + let subjectCaller: Account; + let subjectOperatorFeeRecipient: Address; + + beforeEach(async () => { + subjectCaller = operator; + subjectOperatorFeeRecipient = (await getRandomAccount()).address; + }); + + async function subject(): Promise { + return await feeExtension + .connect(subjectCaller.wallet) + .updateOperatorFeeRecipient(subjectOperatorFeeRecipient); + } + + it("sets the new operator fee recipient", async () => { + await subject(); + + const newOperatorFeeRecipient = await feeExtension.operatorFeeRecipient(); + expect(newOperatorFeeRecipient).to.eq(subjectOperatorFeeRecipient); + }); + + describe("when the new operator fee recipient is address zero", async () => { + beforeEach(async () => { + subjectOperatorFeeRecipient = ADDRESS_ZERO; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Zero address not valid"); + }); + }); + + describe("when the caller is not the operator", async () => { + beforeEach(async () => { + subjectCaller = methodologist; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be operator"); + }); + }); + }); + }); +}); \ No newline at end of file diff --git a/test/adapters/flexibleLeverageStrategyExtension.spec.ts b/test/adapters/flexibleLeverageStrategyExtension.spec.ts index 1a105385..2ced3d4b 100644 --- a/test/adapters/flexibleLeverageStrategyExtension.spec.ts +++ b/test/adapters/flexibleLeverageStrategyExtension.spec.ts @@ -12,7 +12,7 @@ import { ExchangeSettings } from "@utils/types"; import { ADDRESS_ZERO, ONE, TWO, THREE, ZERO, EMPTY_BYTES, MAX_UINT_256 } from "@utils/constants"; -import { FlexibleLeverageStrategyExtension, BaseManager, TradeAdapterMock, ChainlinkAggregatorV3Mock } from "@utils/contracts/index"; +import { FlexibleLeverageStrategyExtension, BaseManagerV2, TradeAdapterMock, ChainlinkAggregatorV3Mock } from "@utils/contracts/index"; import { CompoundLeverageModule, ContractCallerMock, DebtIssuanceModule, SetToken } from "@utils/contracts/setV2"; import { CEther, CERc20 } from "@utils/contracts/compound"; import DeployHelper from "@utils/deploys"; @@ -68,7 +68,7 @@ describe("FlexibleLeverageStrategyExtension", () => { let flexibleLeverageStrategyExtension: FlexibleLeverageStrategyExtension; let compoundLeverageModule: CompoundLeverageModule; let debtIssuanceModule: DebtIssuanceModule; - let baseManagerV2: BaseManager; + let baseManagerV2: BaseManagerV2; let chainlinkCollateralPriceMock: ChainlinkAggregatorV3Mock; let chainlinkBorrowPriceMock: ChainlinkAggregatorV3Mock; @@ -130,7 +130,7 @@ describe("FlexibleLeverageStrategyExtension", () => { debtIssuanceModule = await deployer.setV2.deployDebtIssuanceModule(setV2Setup.controller.address); await setV2Setup.controller.addModule(debtIssuanceModule.address); - // Deploy mock trade adapter + // Deploy mock trade extension tradeAdapterMock = await deployer.mocks.deployTradeAdapterMock(); await setV2Setup.integrationRegistry.addIntegration( compoundLeverageModule.address, @@ -138,7 +138,7 @@ describe("FlexibleLeverageStrategyExtension", () => { tradeAdapterMock.address, ); - // Deploy mock trade adapter 2 + // Deploy mock trade extension 2 tradeAdapterMock2 = await deployer.mocks.deployTradeAdapterMock(); await setV2Setup.integrationRegistry.addIntegration( compoundLeverageModule.address, @@ -191,18 +191,19 @@ describe("FlexibleLeverageStrategyExtension", () => { [setV2Setup.usdc.address] ); - baseManagerV2 = await deployer.manager.deployBaseManager( + baseManagerV2 = await deployer.manager.deployBaseManagerV2( setToken.address, owner.address, - methodologist.address, + methodologist.address ); + await baseManagerV2.connect(methodologist.wallet).authorizeInitialization(); // Transfer ownership to ic manager if ((await setToken.manager()) == owner.address) { await setToken.connect(owner.wallet).setManager(baseManagerV2.address); } - // Deploy adapter + // Deploy extension const targetLeverageRatio = customTargetLeverageRatio || ether(2); const minLeverageRatio = customMinLeverageRatio || ether(1.7); const maxLeverageRatio = ether(2.3); @@ -261,7 +262,7 @@ describe("FlexibleLeverageStrategyExtension", () => { deleverExchangeData: EMPTY_BYTES, }; - flexibleLeverageStrategyExtension = await deployer.adapters.deployFlexibleLeverageStrategyExtension( + flexibleLeverageStrategyExtension = await deployer.extensions.deployFlexibleLeverageStrategyExtension( baseManagerV2.address, strategy, methodology, @@ -271,8 +272,8 @@ describe("FlexibleLeverageStrategyExtension", () => { [ exchangeSettings ] ); - // Add adapter - await baseManagerV2.connect(owner.wallet).addAdapter(flexibleLeverageStrategyExtension.address); + // Add extension + await baseManagerV2.connect(owner.wallet).addExtension(flexibleLeverageStrategyExtension.address); }; describe("#constructor", async () => { @@ -330,7 +331,7 @@ describe("FlexibleLeverageStrategyExtension", () => { }); async function subject(): Promise { - return await deployer.adapters.deployFlexibleLeverageStrategyExtension( + return await deployer.extensions.deployFlexibleLeverageStrategyExtension( subjectManagerAddress, subjectContractSettings, subjectMethodologySettings, diff --git a/test/adapters/gimExtension.spec.ts b/test/adapters/gimExtension.spec.ts index 5061dfbd..0e4a6805 100644 --- a/test/adapters/gimExtension.spec.ts +++ b/test/adapters/gimExtension.spec.ts @@ -2,7 +2,7 @@ import "module-alias/register"; import { Address, Account } from "@utils/types"; import { ADDRESS_ZERO, ZERO } from "@utils/constants"; -import { GIMExtension, BaseManager } from "@utils/contracts/index"; +import { GIMExtension, BaseManagerV2 } from "@utils/contracts/index"; import { SetToken } from "@utils/contracts/setV2"; import DeployHelper from "@utils/deploys"; import { @@ -34,7 +34,7 @@ describe("GIMExtension", () => { let deployer: DeployHelper; let setToken: SetToken; - let baseManagerV2: BaseManager; + let baseManagerV2: BaseManagerV2; let gimExtension: GIMExtension; before(async () => { @@ -83,11 +83,12 @@ describe("GIMExtension", () => { ); // Deploy BaseManager - baseManagerV2 = await deployer.manager.deployBaseManager( + baseManagerV2 = await deployer.manager.deployBaseManagerV2( setToken.address, operator.address, methodologist.address ); + await baseManagerV2.connect(methodologist.wallet).authorizeInitialization(); }); addSnapshotBeforeRestoreAfterEach(); @@ -102,7 +103,7 @@ describe("GIMExtension", () => { }); async function subject(): Promise { - return await deployer.adapters.deployGIMExtension( + return await deployer.extensions.deployGIMExtension( subjectManager, subjectGeneralIndexModule ); @@ -132,12 +133,12 @@ describe("GIMExtension", () => { context("when GIM extension is deployed and module needs to be initialized", async () => { beforeEach(async () => { - gimExtension = await deployer.adapters.deployGIMExtension( + gimExtension = await deployer.extensions.deployGIMExtension( baseManagerV2.address, setV2Setup.generalIndexModule.address ); - await baseManagerV2.connect(operator.wallet).addAdapter(gimExtension.address); + await baseManagerV2.connect(operator.wallet).addExtension(gimExtension.address); await gimExtension.connect(operator.wallet).updateCallerStatus([approvedCaller.address], [true]); diff --git a/test/adapters/governanceAdapter.spec.ts b/test/adapters/governanceExtension.spec.ts similarity index 81% rename from test/adapters/governanceAdapter.spec.ts rename to test/adapters/governanceExtension.spec.ts index 8423f83c..945e162c 100644 --- a/test/adapters/governanceAdapter.spec.ts +++ b/test/adapters/governanceExtension.spec.ts @@ -2,7 +2,7 @@ import "module-alias/register"; import { Address, Account } from "@utils/types"; import { ADDRESS_ZERO, EMPTY_BYTES, ONE, TWO } from "@utils/constants"; -import { GovernanceAdapter, BaseManager, GovernanceAdapterMock } from "@utils/contracts/index"; +import { GovernanceExtension, BaseManagerV2, GovernanceAdapterMock } from "@utils/contracts/index"; import { SetToken } from "@utils/contracts/setV2"; import DeployHelper from "@utils/deploys"; import { @@ -18,7 +18,7 @@ import { BigNumber, ContractTransaction } from "ethers"; const expect = getWaffleExpect(); -describe("GovernanceAdapter", () => { +describe("GovernanceExtension", () => { let owner: Account; let methodologist: Account; let operator: Account; @@ -30,8 +30,8 @@ describe("GovernanceAdapter", () => { let deployer: DeployHelper; let setToken: SetToken; - let baseManagerV2: BaseManager; - let governanceAdapter: GovernanceAdapter; + let baseManagerV2: BaseManagerV2; + let governanceExtension: GovernanceExtension; let governanceMock: GovernanceAdapterMock; const governanceMockName: string = "GovernanceMock"; @@ -64,11 +64,12 @@ describe("GovernanceAdapter", () => { ); // Deploy BaseManager - baseManagerV2 = await deployer.manager.deployBaseManager( + baseManagerV2 = await deployer.manager.deployBaseManagerV2( setToken.address, operator.address, methodologist.address ); + await baseManagerV2.connect(methodologist.wallet).authorizeInitialization(); }); addSnapshotBeforeRestoreAfterEach(); @@ -82,45 +83,45 @@ describe("GovernanceAdapter", () => { subjectGovernanceModule = setV2Setup.governanceModule.address; }); - async function subject(): Promise { - return await deployer.adapters.deployGovernanceAdapter( + async function subject(): Promise { + return await deployer.extensions.deployGovernanceExtension( subjectManager, subjectGovernanceModule ); } it("should set the correct SetToken address", async () => { - const governanceAdapter = await subject(); + const governanceExtension = await subject(); - const actualToken = await governanceAdapter.setToken(); + const actualToken = await governanceExtension.setToken(); expect(actualToken).to.eq(setToken.address); }); it("should set the correct manager address", async () => { - const governanceAdapter = await subject(); + const governanceExtension = await subject(); - const actualManager = await governanceAdapter.manager(); + const actualManager = await governanceExtension.manager(); expect(actualManager).to.eq(baseManagerV2.address); }); it("should set the correct governance module address", async () => { - const governanceAdapter = await subject(); + const governanceExtension = await subject(); - const actualStreamingFeeModule = await governanceAdapter.governanceModule(); + const actualStreamingFeeModule = await governanceExtension.governanceModule(); expect(actualStreamingFeeModule).to.eq(subjectGovernanceModule); }); }); - context("when governance adapter is deployed and module needs to be initialized", async () => { + context("when governance extension is deployed and module needs to be initialized", async () => { beforeEach(async () => { - governanceAdapter = await deployer.adapters.deployGovernanceAdapter( + governanceExtension = await deployer.extensions.deployGovernanceExtension( baseManagerV2.address, setV2Setup.governanceModule.address ); - await baseManagerV2.connect(operator.wallet).addAdapter(governanceAdapter.address); + await baseManagerV2.connect(operator.wallet).addExtension(governanceExtension.address); - await governanceAdapter.connect(operator.wallet).updateCallerStatus([approvedCaller.address], [true]); + await governanceExtension.connect(operator.wallet).updateCallerStatus([approvedCaller.address], [true]); // Transfer ownership to BaseManager await setToken.setManager(baseManagerV2.address); @@ -134,7 +135,7 @@ describe("GovernanceAdapter", () => { }); async function subject(): Promise { - return await governanceAdapter.connect(subjectCaller.wallet).initialize(); + return await governanceExtension.connect(subjectCaller.wallet).initialize(); } it("should initialize GovernanceModule", async () => { @@ -155,9 +156,9 @@ describe("GovernanceAdapter", () => { }); }); - context("when governance adapter is deployed and system fully set up", async () => { + context("when governance extension is deployed and system fully set up", async () => { beforeEach(async () => { - await governanceAdapter.connect(operator.wallet).initialize(); + await governanceExtension.connect(operator.wallet).initialize(); }); describe("#delegate", async () => { @@ -172,7 +173,7 @@ describe("GovernanceAdapter", () => { }); async function subject(): Promise { - return await governanceAdapter.connect(subjectCaller.wallet).delegate(subjectGovernanceName, subjectDelegatee); + return await governanceExtension.connect(subjectCaller.wallet).delegate(subjectGovernanceName, subjectDelegatee); } it("should correctly delegate votes", async () => { @@ -205,7 +206,7 @@ describe("GovernanceAdapter", () => { }); async function subject(): Promise { - return await governanceAdapter.connect(subjectCaller.wallet).propose(subjectGovernanceName, subjectProposalData); + return await governanceExtension.connect(subjectCaller.wallet).propose(subjectGovernanceName, subjectProposalData); } it("should submit a proposal", async () => { @@ -239,7 +240,7 @@ describe("GovernanceAdapter", () => { }); async function subject(): Promise { - return await governanceAdapter.connect(subjectCaller.wallet).register(subjectGovernanceName); + return await governanceExtension.connect(subjectCaller.wallet).register(subjectGovernanceName); } it("should register the SetToken for voting", async () => { @@ -270,7 +271,7 @@ describe("GovernanceAdapter", () => { }); async function subject(): Promise { - return await governanceAdapter.connect(subjectCaller.wallet).revoke(subjectGovernanceName); + return await governanceExtension.connect(subjectCaller.wallet).revoke(subjectGovernanceName); } it("should revoke the SetToken's voting rights", async () => { @@ -307,7 +308,7 @@ describe("GovernanceAdapter", () => { }); async function subject(): Promise { - return await governanceAdapter.connect(subjectCaller.wallet).vote( + return await governanceExtension.connect(subjectCaller.wallet).vote( subjectGovernanceName, subjectProposalId, subjectSupport, diff --git a/test/adapters/streamingFeeSplitExtension.spec.ts b/test/adapters/streamingFeeSplitExtension.spec.ts index 503b3dbd..97c16bc3 100644 --- a/test/adapters/streamingFeeSplitExtension.spec.ts +++ b/test/adapters/streamingFeeSplitExtension.spec.ts @@ -3,7 +3,7 @@ import "module-alias/register"; import { solidityKeccak256 } from "ethers/lib/utils"; import { Address, Account } from "@utils/types"; import { ADDRESS_ZERO, ZERO, ONE_DAY_IN_SECONDS, ONE_YEAR_IN_SECONDS } from "@utils/constants"; -import { StreamingFeeSplitExtension, BaseManager } from "@utils/contracts/index"; +import { StreamingFeeSplitExtension, StreamingFeeModule, BaseManagerV2 } from "@utils/contracts/index"; import { SetToken } from "@utils/contracts/setV2"; import DeployHelper from "@utils/deploys"; import { @@ -18,6 +18,7 @@ import { getWaffleExpect, increaseTimeAsync, preciseMul, + getRandomAccount } from "@utils/index"; import { SetFixture } from "@utils/fixtures"; import { BigNumber, ContractTransaction } from "ethers"; @@ -28,12 +29,13 @@ describe("StreamingFeeSplitExtension", () => { let owner: Account; let methodologist: Account; let operator: Account; + let operatorFeeRecipient: Account; let setV2Setup: SetFixture; let deployer: DeployHelper; let setToken: SetToken; - let baseManagerV2: BaseManager; + let baseManagerV2: BaseManagerV2; let feeExtension: StreamingFeeSplitExtension; before(async () => { @@ -41,6 +43,7 @@ describe("StreamingFeeSplitExtension", () => { owner, methodologist, operator, + operatorFeeRecipient, ] = await getAccounts(); deployer = new DeployHelper(owner.wallet); @@ -55,11 +58,12 @@ describe("StreamingFeeSplitExtension", () => { ); // Deploy BaseManager - baseManagerV2 = await deployer.manager.deployBaseManager( + baseManagerV2 = await deployer.manager.deployBaseManagerV2( setToken.address, operator.address, methodologist.address ); + await baseManagerV2.connect(methodologist.wallet).authorizeInitialization(); const feeRecipient = baseManagerV2.address; const maxStreamingFeePercentage = ether(.1); @@ -84,18 +88,21 @@ describe("StreamingFeeSplitExtension", () => { let subjectManager: Address; let subjectStreamingFeeModule: Address; let subjectOperatorFeeSplit: BigNumber; + let subjectOperatorFeeRecipient: Address; beforeEach(async () => { subjectManager = baseManagerV2.address; subjectStreamingFeeModule = setV2Setup.streamingFeeModule.address; subjectOperatorFeeSplit = ether(.7); + subjectOperatorFeeRecipient = operatorFeeRecipient.address; }); async function subject(): Promise { - return await deployer.adapters.deployStreamingFeeSplitExtension( + return await deployer.extensions.deployStreamingFeeSplitExtension( subjectManager, subjectStreamingFeeModule, - subjectOperatorFeeSplit + subjectOperatorFeeSplit, + subjectOperatorFeeRecipient, ); } @@ -126,22 +133,39 @@ describe("StreamingFeeSplitExtension", () => { const actualOperatorFeeSplit = await feeExtension.operatorFeeSplit(); expect(actualOperatorFeeSplit).to.eq(subjectOperatorFeeSplit); }); + + it("should set the correct operator fee recipient", async () => { + const feeExtension = await subject(); + + const actualOperatorFeeRecipient = await feeExtension.operatorFeeRecipient(); + expect(actualOperatorFeeRecipient).to.eq(subjectOperatorFeeRecipient); + }); }); context("when fee extension is deployed and system fully set up", async () => { const operatorSplit: BigNumber = ether(.7); beforeEach(async () => { - feeExtension = await deployer.adapters.deployStreamingFeeSplitExtension( + feeExtension = await deployer.extensions.deployStreamingFeeSplitExtension( baseManagerV2.address, setV2Setup.streamingFeeModule.address, - operatorSplit + operatorSplit, + operatorFeeRecipient.address ); - await baseManagerV2.connect(operator.wallet).addAdapter(feeExtension.address); + await baseManagerV2.connect(operator.wallet).addExtension(feeExtension.address); // Transfer ownership to BaseManager await setToken.setManager(baseManagerV2.address); + + // Protect StreamingFeeModule + await baseManagerV2 + .connect(operator.wallet) + .protectModule(setV2Setup.streamingFeeModule.address, [feeExtension.address]); + + // Set extension as fee recipient + await feeExtension.connect(operator.wallet).updateFeeRecipient(feeExtension.address); + await feeExtension.connect(methodologist.wallet).updateFeeRecipient(feeExtension.address); }); describe("#accrueFeesAndDistribute", async () => { @@ -160,7 +184,7 @@ describe("StreamingFeeSplitExtension", () => { return await feeExtension.accrueFeesAndDistribute(); } - it("should send correct amount of fees to operator and methodologist", async () => { + it("should send correct amount of fees to operator fee recipient and methodologist", async () => { const feeState: any = await setV2Setup.streamingFeeModule.feeStates(setToken.address); const totalSupply = await setToken.totalSupply(); @@ -178,20 +202,21 @@ describe("StreamingFeeSplitExtension", () => { const expectedOperatorTake = preciseMul(feeInflation, operatorSplit); const expectedMethodologistTake = feeInflation.sub(expectedOperatorTake); - const operatorBalance = await setToken.balanceOf(operator.address); + const operatorFeeRecipientBalance = await setToken.balanceOf(operatorFeeRecipient.address); const methodologistBalance = await setToken.balanceOf(methodologist.address); - expect(operatorBalance).to.eq(expectedOperatorTake); + expect(operatorFeeRecipientBalance).to.eq(expectedOperatorTake); expect(methodologistBalance).to.eq(expectedMethodologistTake); }); - it("should emit a FeesAccrued event", async () => { - await expect(subject()).to.emit(feeExtension, "FeesAccrued"); + it("should emit a FeesDistributed event", async () => { + await expect(subject()).to.emit(feeExtension, "FeesDistributed"); }); describe("when methodologist fees are 0", async () => { beforeEach(async () => { await feeExtension.connect(operator.wallet).updateFeeSplit(ether(1)); + await feeExtension.connect(methodologist.wallet).updateFeeSplit(ether(1)); }); it("should not send fees to methodologist", async () => { @@ -207,15 +232,178 @@ describe("StreamingFeeSplitExtension", () => { describe("when operator fees are 0", async () => { beforeEach(async () => { await feeExtension.connect(operator.wallet).updateFeeSplit(ZERO); + await feeExtension.connect(methodologist.wallet).updateFeeSplit(ZERO); }); - it("should not send fees to operator", async () => { - const preOperatorBalance = await setToken.balanceOf(operator.address); + it("should not send fees to operator fee recipient", async () => { + const preOperatorFeeRecipientBalance = await setToken.balanceOf(operatorFeeRecipient.address); await subject(); - const postOperatorBalance = await setToken.balanceOf(operator.address); - expect(postOperatorBalance.sub(preOperatorBalance)).to.eq(ZERO); + const postOperatorFeeRecipientBalance = await setToken.balanceOf(operatorFeeRecipient.address); + expect(postOperatorFeeRecipientBalance.sub(preOperatorFeeRecipientBalance)).to.eq(ZERO); + }); + }); + + describe("when extension has fees accrued, is removed and no longer the feeRecipient", () => { + let txnTimestamp: BigNumber; + let feeState: any; + let expectedFeeInflation: BigNumber; + let totalSupply: BigNumber; + + beforeEach(async () => { + feeState = await setV2Setup.streamingFeeModule.feeStates(setToken.address); + totalSupply = await setToken.totalSupply(); + + // Accrue fees to extension by StreamingFeeModule by direct call + txnTimestamp = await getTransactionTimestamp( + setV2Setup.streamingFeeModule.accrueFee(setToken.address) + ); + + expectedFeeInflation = await getStreamingFee( + setV2Setup.streamingFeeModule, + setToken.address, + feeState.lastStreamingFeeTimestamp, + txnTimestamp + ); + + // Change fee recipient to baseManagerV2; + await feeExtension.connect(operator.wallet).updateFeeRecipient(baseManagerV2.address); + await feeExtension.connect(methodologist.wallet).updateFeeRecipient(baseManagerV2.address); + + // Revoke extension authorization + await baseManagerV2.connect(operator.wallet).revokeExtensionAuthorization( + setV2Setup.streamingFeeModule.address, + feeExtension.address + ); + + await baseManagerV2.connect(methodologist.wallet).revokeExtensionAuthorization( + setV2Setup.streamingFeeModule.address, + feeExtension.address + ); + + // Remove extension + await baseManagerV2.connect(operator.wallet).removeExtension(feeExtension.address); + }); + + it("should send residual fees to operator fee recipient and methodologist", async () => { + await subject(); + + const feeInflation = getStreamingFeeInflationAmount(expectedFeeInflation, totalSupply); + + const expectedOperatorTake = preciseMul(feeInflation, operatorSplit); + const expectedMethodologistTake = feeInflation.sub(expectedOperatorTake); + + const operatorFeeRecipientBalance = await setToken.balanceOf(operatorFeeRecipient.address); + const methodologistBalance = await setToken.balanceOf(methodologist.address); + + expect(operatorFeeRecipientBalance).to.eq(expectedOperatorTake); + expect(methodologistBalance).to.eq(expectedMethodologistTake); + }); + }); + }); + + describe("#initializeModule", () => { + let subjectSetToken: Address; + let subjectFeeSettings: any; + let subjectExtension: StreamingFeeSplitExtension; + let subjectModule: StreamingFeeModule; + let subjectManager: Address; + let subjectOperatorFeeSplit: BigNumber; + let subjectOperatorFeeRecipient: Address; + + beforeEach( async () => { + subjectSetToken = setToken.address; + subjectManager = baseManagerV2.address; + subjectOperatorFeeSplit = ether(.7); + subjectOperatorFeeRecipient = operator.address; + + // Deploy new fee module + subjectModule = await deployer.setV2.deployStreamingFeeModule(setV2Setup.controller.address); + await setV2Setup.controller.addModule(subjectModule.address); + + // Deploy new fee extension + subjectExtension = await deployer.extensions.deployStreamingFeeSplitExtension( + subjectManager, + subjectModule.address, + subjectOperatorFeeSplit, + subjectOperatorFeeRecipient, + ); + + // Replace module and extension + await baseManagerV2.connect(operator.wallet).replaceProtectedModule( + setV2Setup.streamingFeeModule.address, + subjectModule.address, + [subjectExtension.address] + ); + + await baseManagerV2.connect(methodologist.wallet).replaceProtectedModule( + setV2Setup.streamingFeeModule.address, + subjectModule.address, + [subjectExtension.address] + ); + + subjectFeeSettings = { + feeRecipient: subjectExtension.address, + maxStreamingFeePercentage: ether(.01), + streamingFeePercentage: ether(.01), + lastStreamingFeeTimestamp: ZERO, + }; + }); + + async function subject(caller: Account): Promise { + return await subjectExtension.connect(caller.wallet).initializeModule(subjectFeeSettings); + } + + context("when both parties call the method", async () => { + it("should initialize the streaming fee module", async () => { + const initialFeeRecipient = (await subjectModule.feeStates(subjectSetToken)).feeRecipient; + + await subject(operator); + await subject(methodologist); + + const finalFeeRecipient = (await subjectModule.feeStates(subjectSetToken)).feeRecipient; + + expect(initialFeeRecipient).to.equal(ADDRESS_ZERO); + expect(finalFeeRecipient).to.equal(subjectExtension.address); + }); + + it("should enable calls on the protected module", async () => { + const newFeeRecipient = baseManagerV2.address; + + await subject(operator); + await subject(methodologist); + + // Reset fee recipient + await subjectExtension.connect(operator.wallet).updateFeeRecipient(newFeeRecipient); + await subjectExtension.connect(methodologist.wallet).updateFeeRecipient(newFeeRecipient); + + const receivedFeeRecipient = (await subjectModule.feeStates(subjectSetToken)).feeRecipient; + + expect(receivedFeeRecipient).to.equal(newFeeRecipient); + }); + }); + + context("when a single mutual upgrade party has called the method", async () => { + afterEach(async () => await subject(methodologist)); + + it("should log the proposed streaming fee hash in the mutualUpgrades mapping", async () => { + const txHash = await subject(operator); + + const expectedHash = solidityKeccak256( + ["bytes", "address"], + [txHash.data, operator.address] + ); + + const isLogged = await subjectExtension.mutualUpgrades(expectedHash); + + expect(isLogged).to.be.true; + }); + }); + + describe("when the caller is not the operator or methodologist", async () => { + it("should revert", async () => { + await expect(subject(await getRandomAccount())).to.be.revertedWith("Must be authorized address"); }); }); }); @@ -225,7 +413,8 @@ describe("StreamingFeeSplitExtension", () => { const timeFastForward: BigNumber = ONE_YEAR_IN_SECONDS; let subjectNewFee: BigNumber; - let subjectCaller: Account; + let subjectOperatorCaller: Account; + let subjectMethodologistCaller: Account; beforeEach(async () => { mintedTokens = ether(2); @@ -235,41 +424,64 @@ describe("StreamingFeeSplitExtension", () => { await increaseTimeAsync(timeFastForward); subjectNewFee = ether(.01); - subjectCaller = operator; + subjectOperatorCaller = operator; + subjectMethodologistCaller = methodologist; }); - async function subject(): Promise { - return await feeExtension.connect(subjectCaller.wallet).updateStreamingFee(subjectNewFee); + async function subject(caller: Account): Promise { + return await feeExtension.connect(caller.wallet).updateStreamingFee(subjectNewFee); } + context("when no timelock period has been set", async () => { - it("should update the streaming fee", async () => { - await subject(); - const feeState = await setV2Setup.streamingFeeModule.feeStates(setToken.address); + context("when only one mutual upgrade party has called the method", () => { + it("should log the proposed streaming fee hash in the mutualUpgrades mapping", async () => { + const txHash = await subject(subjectOperatorCaller); + + const expectedHash = solidityKeccak256( + ["bytes", "address"], + [txHash.data, subjectOperatorCaller.address] + ); - expect(feeState.streamingFeePercentage).to.eq(subjectNewFee); + const isLogged = await feeExtension.mutualUpgrades(expectedHash); + + expect(isLogged).to.be.true; + }); }); - it("should send correct amount of fees to operator and methodologist", async () => { - const preManagerBalance = await setToken.balanceOf(baseManagerV2.address); - const feeState: any = await setV2Setup.streamingFeeModule.feeStates(setToken.address); - const totalSupply = await setToken.totalSupply(); + context("when both upgrade parties have called the method", () => { - const txnTimestamp = await getTransactionTimestamp(subject()); + it("should update the streaming fee", async () => { + await subject(subjectOperatorCaller); + await subject(subjectMethodologistCaller); - const expectedFeeInflation = await getStreamingFee( - setV2Setup.streamingFeeModule, - setToken.address, - feeState.lastStreamingFeeTimestamp, - txnTimestamp, - ether(.02) - ); + const feeState = await setV2Setup.streamingFeeModule.feeStates(setToken.address); - const feeInflation = getStreamingFeeInflationAmount(expectedFeeInflation, totalSupply); + expect(feeState.streamingFeePercentage).to.eq(subjectNewFee); + }); + + it("should send correct amount of fees to the fee extension", async () => { + const preExtensionBalance = await setToken.balanceOf(feeExtension.address); + const feeState: any = await setV2Setup.streamingFeeModule.feeStates(setToken.address); + const totalSupply = await setToken.totalSupply(); + + await subject(subjectOperatorCaller); + const txnTimestamp = await getTransactionTimestamp(subject(subjectMethodologistCaller)); + + const expectedFeeInflation = await getStreamingFee( + setV2Setup.streamingFeeModule, + setToken.address, + feeState.lastStreamingFeeTimestamp, + txnTimestamp, + ether(.02) + ); + + const feeInflation = getStreamingFeeInflationAmount(expectedFeeInflation, totalSupply); - const postManagerBalance = await setToken.balanceOf(baseManagerV2.address); + const postExtensionBalance = await setToken.balanceOf(feeExtension.address); - expect(postManagerBalance.sub(preManagerBalance)).to.eq(feeInflation); + expect(postExtensionBalance.sub(preExtensionBalance)).to.eq(feeInflation); + }); }); }); @@ -279,7 +491,9 @@ describe("StreamingFeeSplitExtension", () => { }); it("sets the upgradeHash", async () => { - await subject(); + await subject(subjectOperatorCaller); + await subject(subjectMethodologistCaller); + const timestamp = await getLastBlockTimestamp(); const calldata = feeExtension.interface.encodeFunctionData("updateStreamingFee", [subjectNewFee]); const upgradeHash = solidityKeccak256(["bytes"], [calldata]); @@ -289,24 +503,27 @@ describe("StreamingFeeSplitExtension", () => { context("when 1 day timelock has elapsed", async () => { beforeEach(async () => { - await subject(); + await subject(subjectOperatorCaller); + await subject(subjectMethodologistCaller); await increaseTimeAsync(ONE_DAY_IN_SECONDS.add(1)); }); it("should update the streaming fee", async () => { - await subject(); + await subject(subjectOperatorCaller); + await subject(subjectMethodologistCaller); const feeState = await setV2Setup.streamingFeeModule.feeStates(setToken.address); expect(feeState.streamingFeePercentage).to.eq(subjectNewFee); }); - it("should send correct amount of fees to operator and methodologist", async () => { - const preManagerBalance = await setToken.balanceOf(baseManagerV2.address); + it("should send correct amount of fees to the fee extension", async () => { + const preExtensionBalance = await setToken.balanceOf(feeExtension.address); const feeState: any = await setV2Setup.streamingFeeModule.feeStates(setToken.address); const totalSupply = await setToken.totalSupply(); - const txnTimestamp = await getTransactionTimestamp(subject()); + await subject(subjectOperatorCaller); + const txnTimestamp = await getTransactionTimestamp(subject(subjectMethodologistCaller)); const expectedFeeInflation = await getStreamingFee( setV2Setup.streamingFeeModule, @@ -318,59 +535,80 @@ describe("StreamingFeeSplitExtension", () => { const feeInflation = getStreamingFeeInflationAmount(expectedFeeInflation, totalSupply); - const postManagerBalance = await setToken.balanceOf(baseManagerV2.address); + const postExtensionBalance = await setToken.balanceOf(feeExtension.address); - expect(postManagerBalance.sub(preManagerBalance)).to.eq(feeInflation); + expect(postExtensionBalance.sub(preExtensionBalance)).to.eq(feeInflation); }); }); }); - describe("when the caller is not the operator", async () => { + describe("when the caller is not the operator or methodologist", async () => { beforeEach(async () => { - subjectCaller = methodologist; + subjectOperatorCaller = await getRandomAccount(); }); it("should revert", async () => { - await expect(subject()).to.be.revertedWith("Must be operator"); + await expect(subject(subjectOperatorCaller)).to.be.revertedWith("Must be authorized address"); }); }); }); describe("#updateFeeRecipient", async () => { let subjectNewFeeRecipient: Address; - let subjectCaller: Account; + let subjectOperatorCaller: Account; + let subjectMethodologistCaller: Account; beforeEach(async () => { subjectNewFeeRecipient = owner.address; - subjectCaller = operator; + subjectOperatorCaller = operator; + subjectMethodologistCaller = methodologist; }); - async function subject(): Promise { - return await feeExtension.connect(subjectCaller.wallet).updateFeeRecipient(subjectNewFeeRecipient); + async function subject(caller: Account): Promise { + return await feeExtension.connect(caller.wallet).updateFeeRecipient(subjectNewFeeRecipient); } it("sets the new fee recipient", async () => { - await subject(); + await subject(subjectOperatorCaller); + await subject(subjectMethodologistCaller); const streamingFeeState = await setV2Setup.streamingFeeModule.feeStates(setToken.address); expect(streamingFeeState.feeRecipient).to.eq(subjectNewFeeRecipient); }); - describe("when the caller is not the operator", async () => { + context("when a single mutual upgrade party has called the method", async () => { + afterEach(async () => await subject(subjectMethodologistCaller)); + + it("should log the proposed streaming fee hash in the mutualUpgrades mapping", async () => { + const txHash = await subject(subjectOperatorCaller); + + const expectedHash = solidityKeccak256( + ["bytes", "address"], + [txHash.data, subjectOperatorCaller.address] + ); + + const isLogged = await feeExtension.mutualUpgrades(expectedHash); + + expect(isLogged).to.be.true; + }); + }); + + describe("when the caller is not the operator or methodologist", async () => { beforeEach(async () => { - subjectCaller = methodologist; + subjectOperatorCaller = await getRandomAccount(); }); it("should revert", async () => { - await expect(subject()).to.be.revertedWith("Must be operator"); + await expect(subject(subjectOperatorCaller)).to.be.revertedWith("Must be authorized address"); }); }); }); describe("#updateFeeSplit", async () => { let subjectNewFeeSplit: BigNumber; - let subjectCaller: Account; + let subjectOperatorCaller: Account; + let subjectMethodologistCaller: Account; const mintedTokens: BigNumber = ether(2); const timeFastForward: BigNumber = ONE_YEAR_IN_SECONDS; @@ -382,18 +620,20 @@ describe("StreamingFeeSplitExtension", () => { await increaseTimeAsync(timeFastForward); subjectNewFeeSplit = ether(.5); - subjectCaller = operator; + subjectOperatorCaller = operator; + subjectMethodologistCaller = methodologist; }); - async function subject(): Promise { - return await feeExtension.connect(subjectCaller.wallet).updateFeeSplit(subjectNewFeeSplit); + async function subject(caller: Account): Promise { + return await feeExtension.connect(caller.wallet).updateFeeSplit(subjectNewFeeSplit); } - it("should accrue fees and send correct amount to operator and methodologist", async () => { + it("should accrue fees and send correct amount to operator fee recipient and methodologist", async () => { const feeState: any = await setV2Setup.streamingFeeModule.feeStates(setToken.address); const totalSupply = await setToken.totalSupply(); - const txnTimestamp = await getTransactionTimestamp(subject()); + await subject(subjectOperatorCaller); + const txnTimestamp = await getTransactionTimestamp(subject(subjectMethodologistCaller)); const expectedFeeInflation = await getStreamingFee( setV2Setup.streamingFeeModule, @@ -407,15 +647,16 @@ describe("StreamingFeeSplitExtension", () => { const expectedOperatorTake = preciseMul(feeInflation, operatorSplit); const expectedMethodologistTake = feeInflation.sub(expectedOperatorTake); - const operatorBalance = await setToken.balanceOf(operator.address); + const operatorFeeRecipientBalance = await setToken.balanceOf(operatorFeeRecipient.address); const methodologistBalance = await setToken.balanceOf(methodologist.address); - expect(operatorBalance).to.eq(expectedOperatorTake); + expect(operatorFeeRecipientBalance).to.eq(expectedOperatorTake); expect(methodologistBalance).to.eq(expectedMethodologistTake); }); it("sets the new fee split", async () => { - await subject(); + await subject(subjectOperatorCaller); + await subject(subjectMethodologistCaller); const actualFeeSplit = await feeExtension.operatorFeeSplit(); @@ -428,13 +669,72 @@ describe("StreamingFeeSplitExtension", () => { }); it("should revert", async () => { - await expect(subject()).to.be.revertedWith("Fee must be less than 100%"); + await subject(subjectOperatorCaller); + await expect(subject(subjectMethodologistCaller)).to.be.revertedWith("Fee must be less than 100%"); + }); + }); + + context("when a single mutual upgrade party has called the method", async () => { + it("should log the proposed streaming fee hash in the mutualUpgrades mapping", async () => { + const txHash = await subject(subjectOperatorCaller); + + const expectedHash = solidityKeccak256( + ["bytes", "address"], + [txHash.data, subjectOperatorCaller.address] + ); + + const isLogged = await feeExtension.mutualUpgrades(expectedHash); + + expect(isLogged).to.be.true; + }); + }); + + describe("when the caller is not the operator or methodologist", async () => { + beforeEach(async () => { + subjectOperatorCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject(subjectOperatorCaller)).to.be.revertedWith("Must be authorized address"); + }); + }); + }); + + describe("#updateOperatorFeeRecipient", async () => { + let subjectCaller: Account; + let subjectOperatorFeeRecipient: Address; + + beforeEach(async () => { + subjectCaller = operator; + subjectOperatorFeeRecipient = (await getRandomAccount()).address; + }); + + async function subject(): Promise { + return await feeExtension + .connect(subjectCaller.wallet) + .updateOperatorFeeRecipient(subjectOperatorFeeRecipient); + } + + it("sets the new operator fee recipient", async () => { + await subject(); + + const newOperatorFeeRecipient = await feeExtension.operatorFeeRecipient(); + expect(newOperatorFeeRecipient).to.eq(subjectOperatorFeeRecipient); + }); + + describe("when the new operator fee recipient is address zero", async () => { + beforeEach(async () => { + subjectOperatorFeeRecipient = ADDRESS_ZERO; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Zero address not valid"); }); }); describe("when the caller is not the operator", async () => { beforeEach(async () => { - subjectCaller = owner; + subjectCaller = methodologist; }); it("should revert", async () => { diff --git a/test/exchangeIssuance/exchangeIssuance.spec.ts b/test/exchangeIssuance/exchangeIssuance.spec.ts index 2db0cf53..8d63b286 100644 --- a/test/exchangeIssuance/exchangeIssuance.spec.ts +++ b/test/exchangeIssuance/exchangeIssuance.spec.ts @@ -112,7 +112,7 @@ describe("ExchangeIssuance", async () => { }); async function subject(): Promise { - return await deployer.adapters.deployExchangeIssuance( + return await deployer.extensions.deployExchangeIssuance( wethAddress, uniswapFactory.address, uniswapRouter.address, @@ -282,7 +282,7 @@ describe("ExchangeIssuance", async () => { { value: ether(100), gasLimit: 9000000 } ); - exchangeIssuance = await deployer.adapters.deployExchangeIssuance( + exchangeIssuance = await deployer.extensions.deployExchangeIssuance( subjectWethAddress, uniswapFactory.address, uniswapRouter.address, diff --git a/test/exchangeIssuance/exchangeIssuanceV2.spec.ts b/test/exchangeIssuance/exchangeIssuanceV2.spec.ts index e0bd22dc..2ab1cd78 100644 --- a/test/exchangeIssuance/exchangeIssuanceV2.spec.ts +++ b/test/exchangeIssuance/exchangeIssuanceV2.spec.ts @@ -109,7 +109,7 @@ describe("ExchangeIssuanceV2", async () => { }); async function subject(): Promise { - return await deployer.adapters.deployExchangeIssuanceV2( + return await deployer.extensions.deployExchangeIssuanceV2( wethAddress, uniswapFactory.address, uniswapRouter.address, @@ -279,7 +279,7 @@ describe("ExchangeIssuanceV2", async () => { { value: ether(100), gasLimit: 9000000 } ); - exchangeIssuance = await deployer.adapters.deployExchangeIssuanceV2( + exchangeIssuance = await deployer.extensions.deployExchangeIssuanceV2( subjectWethAddress, uniswapFactory.address, uniswapRouter.address, diff --git a/test/integration/fliIntegration.spec.ts b/test/integration/fliIntegration.spec.ts index fd442882..55c160d1 100644 --- a/test/integration/fliIntegration.spec.ts +++ b/test/integration/fliIntegration.spec.ts @@ -11,7 +11,7 @@ import { ExchangeSettings } from "@utils/types"; import { ADDRESS_ZERO, ZERO, ONE, TWO, EMPTY_BYTES, MAX_UINT_256, PRECISE_UNIT, ONE_DAY_IN_SECONDS, ONE_HOUR_IN_SECONDS } from "@utils/constants"; -import { FlexibleLeverageStrategyExtension, BaseManager, StandardTokenMock, WETH9, ChainlinkAggregatorV3Mock } from "@utils/contracts/index"; +import { FlexibleLeverageStrategyExtension, BaseManagerV2, StandardTokenMock, WETH9, ChainlinkAggregatorV3Mock } from "@utils/contracts/index"; import { CompoundLeverageModule, SetToken } from "@utils/contracts/setV2"; import { CEther, CERc20 } from "@utils/contracts/compound"; import DeployHelper from "@utils/deploys"; @@ -105,7 +105,7 @@ describe("FlexibleLeverageStrategyExtension", () => { let flexibleLeverageStrategyExtension: FlexibleLeverageStrategyExtension; let compoundLeverageModule: CompoundLeverageModule; - let baseManager: BaseManager; + let baseManager: BaseManagerV2; let chainlinkETH: ChainlinkAggregatorV3Mock; let chainlinkWBTC: ChainlinkAggregatorV3Mock; @@ -633,11 +633,12 @@ describe("FlexibleLeverageStrategyExtension", () => { [fliSettings.borrowAsset.address] ); - baseManager = await deployer.manager.deployBaseManager( + baseManager = await deployer.manager.deployBaseManagerV2( setToken.address, owner.address, - methodologist.address, + methodologist.address ); + await baseManager.connect(methodologist.wallet).authorizeInitialization(); // Transfer ownership to ic manager await setToken.setManager(baseManager.address); @@ -674,7 +675,7 @@ describe("FlexibleLeverageStrategyExtension", () => { incentivizedLeverageRatio: incentivizedLeverageRatio, }; - flexibleLeverageStrategyExtension = await deployer.adapters.deployFlexibleLeverageStrategyExtension( + flexibleLeverageStrategyExtension = await deployer.extensions.deployFlexibleLeverageStrategyExtension( baseManager.address, strategy, methodology, @@ -685,8 +686,8 @@ describe("FlexibleLeverageStrategyExtension", () => { ); await flexibleLeverageStrategyExtension.updateCallerStatus([owner.address], [true]); - // Add adapter - await baseManager.connect(owner.wallet).addAdapter(flexibleLeverageStrategyExtension.address); + // Add extension + await baseManager.connect(owner.wallet).addExtension(flexibleLeverageStrategyExtension.address); } async function issueFLITokens(collateralCToken: CERc20 | CEther, amount: BigNumber): Promise { diff --git a/test/lib/baseAdapter.spec.ts b/test/lib/baseExtension.spec.ts similarity index 82% rename from test/lib/baseAdapter.spec.ts rename to test/lib/baseExtension.spec.ts index 1d7afe64..d4e7f9bf 100644 --- a/test/lib/baseAdapter.spec.ts +++ b/test/lib/baseExtension.spec.ts @@ -3,7 +3,7 @@ import "module-alias/register"; import { BigNumber } from "@ethersproject/bignumber"; import { Account, Address, Bytes } from "@utils/types"; import { ZERO, ADDRESS_ZERO } from "@utils/constants"; -import { BaseAdapterMock, BaseManager } from "@utils/contracts/index"; +import { BaseExtensionMock, BaseManagerV2 } from "@utils/contracts/index"; import { SetToken } from "@utils/contracts/setV2"; import { ContractCallerMock } from "@utils/contracts/setV2"; @@ -21,7 +21,7 @@ import { ContractTransaction } from "ethers"; const expect = getWaffleExpect(); -describe("BaseAdapter", () => { +describe("BaseExtension", () => { let owner: Account; let methodologist: Account; let otherAccount: Account; @@ -29,8 +29,8 @@ describe("BaseAdapter", () => { let setToken: SetToken; let setV2Setup: SetFixture; - let baseManagerV2: BaseManager; - let baseAdapterMock: BaseAdapterMock; + let baseManagerV2: BaseManagerV2; + let baseExtensionMock: BaseExtensionMock; before(async () => { [ @@ -64,19 +64,20 @@ describe("BaseAdapter", () => { await setV2Setup.streamingFeeModule.initialize(setToken.address, streamingFeeSettings); // Deploy BaseManager - baseManagerV2 = await deployer.manager.deployBaseManager( + baseManagerV2 = await deployer.manager.deployBaseManagerV2( setToken.address, owner.address, - methodologist.address, + methodologist.address ); + await baseManagerV2.connect(methodologist.wallet).authorizeInitialization(); - baseAdapterMock = await deployer.mocks.deployBaseAdapterMock(baseManagerV2.address); + baseExtensionMock = await deployer.mocks.deployBaseExtensionMock(baseManagerV2.address); // Transfer ownership to BaseManager await setToken.setManager(baseManagerV2.address); - await baseManagerV2.addAdapter(baseAdapterMock.address); + await baseManagerV2.addExtension(baseExtensionMock.address); - await baseAdapterMock.updateCallerStatus([owner.address], [true]); + await baseExtensionMock.updateCallerStatus([owner.address], [true]); }); addSnapshotBeforeRestoreAfterEach(); @@ -89,7 +90,7 @@ describe("BaseAdapter", () => { }); async function subject(): Promise { - return baseAdapterMock.connect(subjectCaller.wallet).testOnlyOperator(); + return baseExtensionMock.connect(subjectCaller.wallet).testOnlyOperator(); } it("should succeed without revert", async () => { @@ -115,7 +116,7 @@ describe("BaseAdapter", () => { }); async function subject(): Promise { - return baseAdapterMock.connect(subjectCaller.wallet).testOnlyMethodologist(); + return baseExtensionMock.connect(subjectCaller.wallet).testOnlyMethodologist(); } it("should succeed without revert", async () => { @@ -141,7 +142,7 @@ describe("BaseAdapter", () => { }); async function subject(): Promise { - return baseAdapterMock.connect(subjectCaller.wallet).testOnlyEOA(); + return baseExtensionMock.connect(subjectCaller.wallet).testOnlyEOA(); } it("should succeed without revert", async () => { @@ -158,8 +159,8 @@ describe("BaseAdapter", () => { beforeEach(async () => { contractCaller = await deployer.setV2.deployContractCallerMock(); - subjectTarget = baseAdapterMock.address; - subjectCallData = baseAdapterMock.interface.encodeFunctionData("testOnlyEOA"); + subjectTarget = baseExtensionMock.address; + subjectCallData = baseExtensionMock.interface.encodeFunctionData("testOnlyEOA"); subjectValue = ZERO; }); @@ -185,7 +186,7 @@ describe("BaseAdapter", () => { }); async function subject(): Promise { - return baseAdapterMock.connect(subjectCaller.wallet).testOnlyAllowedCaller(subjectCaller.address); + return baseExtensionMock.connect(subjectCaller.wallet).testOnlyAllowedCaller(subjectCaller.address); } it("should succeed without revert", async () => { @@ -203,7 +204,7 @@ describe("BaseAdapter", () => { describe("when anyoneCallable is flipped to true", async () => { beforeEach(async () => { - await baseAdapterMock.updateAnyoneCallable(true); + await baseExtensionMock.updateAnyoneCallable(true); }); it("should succeed without revert", async () => { @@ -228,7 +229,7 @@ describe("BaseAdapter", () => { }); async function subject(): Promise { - return baseAdapterMock.connect(subjectCaller.wallet).testInvokeManager(subjectModule, subjectCallData); + return baseExtensionMock.connect(subjectCaller.wallet).testInvokeManager(subjectModule, subjectCallData); } it("should call updateFeeRecipient on the streaming fee module from the SetToken", async () => { @@ -252,7 +253,7 @@ describe("BaseAdapter", () => { }); async function subject(): Promise { - return baseAdapterMock.testInvokeManagerTransfer( + return baseExtensionMock.testInvokeManagerTransfer( subjectToken, subjectDestination, subjectAmount @@ -285,17 +286,17 @@ describe("BaseAdapter", () => { }); async function subject(): Promise { - return baseAdapterMock.connect(subjectCaller.wallet).updateCallerStatus(subjectFunctionCallers, subjectStatuses); + return baseExtensionMock.connect(subjectCaller.wallet).updateCallerStatus(subjectFunctionCallers, subjectStatuses); } it("should update the callAllowList", async () => { await subject(); - const callerStatus = await baseAdapterMock.callAllowList(subjectFunctionCallers[0]); + const callerStatus = await baseExtensionMock.callAllowList(subjectFunctionCallers[0]); expect(callerStatus).to.be.true; }); it("should emit CallerStatusUpdated event", async () => { - await expect(subject()).to.emit(baseAdapterMock, "CallerStatusUpdated").withArgs( + await expect(subject()).to.emit(baseExtensionMock, "CallerStatusUpdated").withArgs( subjectFunctionCallers[0], subjectStatuses[0] ); @@ -322,17 +323,17 @@ describe("BaseAdapter", () => { }); async function subject(): Promise { - return baseAdapterMock.connect(subjectCaller.wallet).updateAnyoneCallable(subjectStatus); + return baseExtensionMock.connect(subjectCaller.wallet).updateAnyoneCallable(subjectStatus); } it("should update the anyoneCallable boolean", async () => { await subject(); - const callerStatus = await baseAdapterMock.anyoneCallable(); + const callerStatus = await baseExtensionMock.anyoneCallable(); expect(callerStatus).to.be.true; }); it("should emit AnyoneCallableUpdated event", async () => { - await expect(subject()).to.emit(baseAdapterMock, "AnyoneCallableUpdated").withArgs( + await expect(subject()).to.emit(baseExtensionMock, "AnyoneCallableUpdated").withArgs( subjectStatus ); }); diff --git a/test/manager/baseManager.spec.ts b/test/manager/baseManager.spec.ts index 1a409406..9da44d31 100644 --- a/test/manager/baseManager.spec.ts +++ b/test/manager/baseManager.spec.ts @@ -2,7 +2,7 @@ import "module-alias/register"; import { Address, Account, Bytes } from "@utils/types"; import { ADDRESS_ZERO, ZERO } from "@utils/constants"; -import { BaseManager, BaseAdapterMock } from "@utils/contracts/index"; +import { BaseManager, BaseExtensionMock } from "@utils/contracts/index"; import { SetToken } from "@utils/contracts/setV2"; import DeployHelper from "@utils/deploys"; import { @@ -29,7 +29,7 @@ describe("BaseManager", () => { let setToken: SetToken; let baseManager: BaseManager; - let baseAdapter: BaseAdapterMock; + let baseAdapter: BaseExtensionMock; before(async () => { [ @@ -73,7 +73,7 @@ describe("BaseManager", () => { // Transfer ownership to BaseManager await setToken.setManager(baseManager.address); - baseAdapter = await deployer.mocks.deployBaseAdapterMock(baseManager.address); + baseAdapter = await deployer.mocks.deployBaseExtensionMock(baseManager.address); }); addSnapshotBeforeRestoreAfterEach(); @@ -203,7 +203,7 @@ describe("BaseManager", () => { describe("when adapter has different manager address", async () => { beforeEach(async () => { - subjectAdapter = (await deployer.mocks.deployBaseAdapterMock(await getRandomAddress())).address; + subjectAdapter = (await deployer.mocks.deployBaseExtensionMock(await getRandomAddress())).address; }); it("should revert", async () => { diff --git a/test/manager/baseManagerV2.spec.ts b/test/manager/baseManagerV2.spec.ts new file mode 100644 index 00000000..8eb299e3 --- /dev/null +++ b/test/manager/baseManagerV2.spec.ts @@ -0,0 +1,1642 @@ +import "module-alias/register"; + +import { Address, Account, Bytes } from "@utils/types"; +import { ADDRESS_ZERO, ZERO } from "@utils/constants"; +import { BaseManagerV2, BaseExtensionMock, StreamingFeeSplitExtension } from "@utils/contracts/index"; +import { SetToken } from "@utils/contracts/setV2"; +import DeployHelper from "@utils/deploys"; +import { + addSnapshotBeforeRestoreAfterEach, + ether, + getAccounts, + getSetFixture, + getWaffleExpect, + getRandomAccount, + getRandomAddress +} from "@utils/index"; +import { SetFixture } from "@utils/fixtures"; + +import { solidityKeccak256 } from "ethers/lib/utils"; +import { ContractTransaction } from "ethers"; +import { BigNumber } from "@ethersproject/bignumber"; + +const expect = getWaffleExpect(); + +describe("BaseManagerV2", () => { + let operator: Account; + let methodologist: Account; + let otherAccount: Account; + let newManager: Account; + let setV2Setup: SetFixture; + + let deployer: DeployHelper; + let setToken: SetToken; + + let baseManager: BaseManagerV2; + let baseExtension: BaseExtensionMock; + + async function validateMutualUprade(txHash: ContractTransaction, caller: Address) { + const expectedHash = solidityKeccak256(["bytes", "address"], [txHash.data, caller]); + const isLogged = await baseManager.mutualUpgrades(expectedHash); + expect(isLogged).to.be.true; + } + + before(async () => { + [ + operator, + otherAccount, + newManager, + methodologist, + ] = await getAccounts(); + + deployer = new DeployHelper(operator.wallet); + + setV2Setup = getSetFixture(operator.address); + await setV2Setup.initialize(); + + setToken = await setV2Setup.createSetToken( + [setV2Setup.dai.address], + [ether(1)], + [ + setV2Setup.issuanceModule.address, + setV2Setup.streamingFeeModule.address, + setV2Setup.governanceModule.address, + ] + ); + + // Initialize modules + await setV2Setup.issuanceModule.initialize(setToken.address, ADDRESS_ZERO); + await setV2Setup.governanceModule.initialize(setToken.address); + + const feeRecipient = operator.address; + const maxStreamingFeePercentage = ether(.1); + const streamingFeePercentage = ether(.02); + const streamingFeeSettings = { + feeRecipient, + maxStreamingFeePercentage, + streamingFeePercentage, + lastStreamingFeeTimestamp: ZERO, + }; + await setV2Setup.streamingFeeModule.initialize(setToken.address, streamingFeeSettings); + + // Deploy BaseManager + baseManager = await deployer.manager.deployBaseManagerV2( + setToken.address, + operator.address, + methodologist.address + ); + + // Transfer operatorship to BaseManager + await setToken.setManager(baseManager.address); + + baseExtension = await deployer.mocks.deployBaseExtensionMock(baseManager.address); + }); + + addSnapshotBeforeRestoreAfterEach(); + + describe("#constructor", async () => { + let subjectSetToken: Address; + let subjectOperator: Address; + let subjectMethodologist: Address; + + beforeEach(async () => { + subjectSetToken = setToken.address; + subjectOperator = operator.address; + subjectMethodologist = methodologist.address; + }); + + async function subject(): Promise { + return await deployer.manager.deployBaseManagerV2( + subjectSetToken, + subjectOperator, + subjectMethodologist + ); + } + + it("should set the correct SetToken address", async () => { + const retrievedICManager = await subject(); + + const actualToken = await retrievedICManager.setToken(); + expect (actualToken).to.eq(subjectSetToken); + }); + + it("should set the correct Operator address", async () => { + const retrievedICManager = await subject(); + + const actualOperator = await retrievedICManager.operator(); + expect (actualOperator).to.eq(subjectOperator); + }); + + it("should set the correct Methodologist address", async () => { + const retrievedICManager = await subject(); + + const actualMethodologist = await retrievedICManager.methodologist(); + expect (actualMethodologist).to.eq(subjectMethodologist); + }); + + it("should not be initialized by default", async () => { + const retrievedICManager = await subject(); + + const initialized = await retrievedICManager.initialized(); + expect(initialized).to.be.false; + }); + }); + + describe("#authorizeInitialization", () => { + let subjectCaller: Account; + + beforeEach(async () => { + subjectCaller = methodologist; + }); + + async function subject(): Promise { + return baseManager.connect(subjectCaller.wallet).authorizeInitialization(); + } + + it("sets initialized to true", async() => { + const defaultInitialized = await baseManager.initialized(); + + await subject(); + + const updatedInitialized = await baseManager.initialized(); + + expect(defaultInitialized).to.be.false; + expect(updatedInitialized).to.be.true; + }); + + describe("when the caller is not the methodologist", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be methodologist"); + }); + }); + + describe("when the manager is already initialized", async () => { + beforeEach(async () => { + await subject(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Initialization authorized"); + }); + }); + }); + + describe("#setManager", async () => { + let subjectNewManager: Address; + let subjectCaller: Account; + + beforeEach(async () => { + subjectNewManager = newManager.address; + subjectCaller = operator; + }); + + async function subject(caller: Account): Promise { + return baseManager.connect(caller.wallet).setManager(subjectNewManager); + } + + it("should change the manager address", async () => { + await subject(operator); + await subject(methodologist); + const manager = await setToken.manager(); + + expect(manager).to.eq(newManager.address); + }); + + describe("when a single mutual upgrade party calls", () => { + it("should log the proposed streaming fee hash in the mutualUpgrades mapping", async () => { + const txHash = await subject(operator); + await validateMutualUprade(txHash, operator.address); + }); + }); + + describe("when passed manager is the zero address", async () => { + beforeEach(async () => { + subjectNewManager = ADDRESS_ZERO; + }); + + it("should revert", async () => { + await subject(operator); + await expect(subject(methodologist)).to.be.revertedWith("Zero address not valid"); + }); + }); + + describe("when the caller is not the operator or the methodologist", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject(subjectCaller)).to.be.revertedWith("Must be authorized address"); + }); + }); + }); + + describe("#addExtension", async () => { + let subjectModule: Address; + let subjectExtension: Address; + let subjectCaller: Account; + + beforeEach(async () => { + subjectModule = setV2Setup.streamingFeeModule.address; + subjectExtension = baseExtension.address; + subjectCaller = operator; + }); + + async function subject(): Promise { + return baseManager.connect(subjectCaller.wallet).addExtension(subjectExtension); + } + + it("should add the extension address", async () => { + await subject(); + const extensions = await baseManager.getExtensions(); + + expect(extensions[0]).to.eq(baseExtension.address); + }); + + it("should set the extension mapping", async () => { + await subject(); + const isExtension = await baseManager.isExtension(subjectExtension); + + expect(isExtension).to.be.true; + }); + + it("should emit the correct ExtensionAdded event", async () => { + await expect(subject()).to.emit(baseManager, "ExtensionAdded").withArgs(baseExtension.address); + }); + + describe("when the extension already exists", async () => { + beforeEach(async () => { + await subject(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Extension already exists"); + }); + }); + + describe("when extension has different manager address", async () => { + beforeEach(async () => { + subjectExtension = (await deployer.mocks.deployBaseExtensionMock(await getRandomAddress())).address; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Extension manager invalid"); + }); + }); + + describe("when an emergency is in progress", async () => { + beforeEach(async () => { + baseManager.connect(operator.wallet); + await baseManager.protectModule(subjectModule, []); + await baseManager.emergencyRemoveProtectedModule(subjectModule); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Upgrades paused by emergency"); + }); + }); + + describe("when the caller is not the operator", async () => { + beforeEach(async () => { + subjectCaller = methodologist; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be operator"); + }); + }); + }); + + describe("#removeExtension", async () => { + let subjectModule: Address; + let subjectAdditionalModule: Address; + let subjectExtension: Address; + let subjectAdditionalExtension: Address; + let subjectCaller: Account; + + beforeEach(async () => { + await baseManager.connect(operator.wallet).addExtension(baseExtension.address); + + subjectModule = setV2Setup.streamingFeeModule.address; + subjectAdditionalModule = setV2Setup.issuanceModule.address; + subjectExtension = baseExtension.address; + subjectAdditionalExtension = (await deployer.mocks.deployBaseExtensionMock(baseManager.address)).address; + subjectCaller = operator; + }); + + async function subject(): Promise { + return baseManager.connect(subjectCaller.wallet).removeExtension(subjectExtension); + } + + it("should remove the extension address", async () => { + await subject(); + const extensions = await baseManager.getExtensions(); + + expect(extensions.length).to.eq(0); + }); + + it("should set the extension mapping", async () => { + await subject(); + const isExtension = await baseManager.isExtension(subjectExtension); + + expect(isExtension).to.be.false; + }); + + it("should emit the correct ExtensionRemoved event", async () => { + await expect(subject()).to.emit(baseManager, "ExtensionRemoved").withArgs(baseExtension.address); + }); + + describe("when the extension does not exist", async () => { + beforeEach(async () => { + subjectExtension = await getRandomAddress(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Extension does not exist"); + }); + }); + + describe("when the caller is not the operator", async () => { + beforeEach(async () => { + subjectCaller = methodologist; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be operator"); + }); + }); + + describe("when the extension is authorized for a protected module", () => { + beforeEach(() => { + baseManager.connect(operator.wallet).protectModule(subjectModule, [subjectExtension]); + }); + + it("should revert", async() => { + await expect(subject()).to.be.revertedWith("Extension used by protected module"); + }); + }); + + // This test for the coverage report - hits an alternate branch condition the authorized + // extensions search method.... + describe("when multiple extensionsa are authorized for multiple protected modules", () => { + beforeEach(async () => { + await baseManager.connect(operator.wallet).protectModule(subjectAdditionalModule, [subjectAdditionalExtension]); + await baseManager.connect(operator.wallet).protectModule(subjectModule, [subjectExtension]); + }); + + it("should revert", async() => { + await expect(subject()).to.be.revertedWith("Extension used by protected module"); + }); + }); + }); + + describe("#authorizeExtension", () => { + let subjectModule: Address; + let subjectExtension: Address; + let subjectCaller: Account; + + beforeEach(async () => { + subjectCaller = operator; + subjectModule = setV2Setup.streamingFeeModule.address; + subjectExtension = baseExtension.address; + }); + + async function subject(caller: Account): Promise { + return baseManager.connect(caller.wallet).authorizeExtension(subjectModule, subjectExtension); + } + + describe("when extension is not authorized and already added", () => { + beforeEach(async () => { + await baseManager.connect(operator.wallet).addExtension(subjectExtension); + await baseManager.connect(operator.wallet).protectModule(subjectModule, []); + }); + + it("should authorize the extension", async () => { + const initialAuthorization = await baseManager.isAuthorizedExtension(subjectModule, subjectExtension); + + await subject(operator); + await subject(methodologist); + + const finalAuthorization = await baseManager.isAuthorizedExtension(subjectModule, subjectExtension); + + expect(initialAuthorization).to.be.false; + expect(finalAuthorization).to.be.true; + }); + + it("should emit the correct ExtensionAuthorized event", async () => { + await subject(operator); + + await expect(subject(methodologist)).to + .emit(baseManager, "ExtensionAuthorized") + .withArgs(subjectModule, subjectExtension); + }); + }); + + describe("when extension is not already added to the manager", () => { + beforeEach(async () => { + await baseManager.connect(operator.wallet).protectModule(subjectModule, []); + }); + + it("should add and authorize the extension", async () => { + const initialIsExtension = await baseManager.isExtension(subjectExtension); + const initialAuthorization = await baseManager.isAuthorizedExtension(subjectModule, subjectExtension); + + await subject(operator); + await subject(methodologist); + + const finalIsExtension = await baseManager.isExtension(subjectExtension); + const finalAuthorization = await baseManager.isAuthorizedExtension(subjectModule, subjectExtension); + + expect(initialIsExtension).to.be.false; + expect(initialAuthorization).to.be.false; + expect(finalIsExtension).to.be.true; + expect(finalAuthorization).to.be.true; + }); + }); + + describe("when the extension is already authorized for target module", () => { + beforeEach(async () => { + await baseManager.connect(operator.wallet).addExtension(subjectExtension); + await baseManager.connect(operator.wallet).protectModule(subjectModule, [subjectExtension]); + }); + + it("should revert", async () => { + await subject(operator); + await expect(subject(methodologist)).to.be.revertedWith("Extension already authorized"); + }); + }); + + describe("when target module is not protected", () => { + beforeEach(async () => { + await baseManager.connect(operator.wallet).addExtension(subjectExtension); + }); + + it("should revert", async () => { + const isProtected = await baseManager.protectedModules(subjectModule); + + await subject(operator); + + await expect(isProtected).to.be.false; + await expect(subject(methodologist)).to.be.revertedWith("Module not protected"); + }); + }); + + describe("when a single mutual upgrade party calls", () => { + it("should log the proposed streaming fee hash in the mutualUpgrades mapping", async () => { + const txHash = await subject(operator); + await validateMutualUprade(txHash, operator.address); + }); + }); + + describe("when the caller is not the operator or methodologist", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject(subjectCaller)).to.be.revertedWith("Must be authorized"); + }); + }); + }); + + describe("#revokeExtensionAuthorization", () => { + let subjectModule: Address; + let subjectAdditionalModule: Address; + let subjectExtension: Address; + let subjectCaller: Account; + + beforeEach(async () => { + subjectCaller = operator; + subjectModule = setV2Setup.streamingFeeModule.address; + subjectAdditionalModule = setV2Setup.issuanceModule.address; + subjectExtension = baseExtension.address; + }); + + async function subject(caller: Account): Promise { + return baseManager.connect(caller.wallet).revokeExtensionAuthorization(subjectModule, subjectExtension); + } + + describe("when extension is authorized", () => { + beforeEach(async () => { + await baseManager.connect(operator.wallet).addExtension(subjectExtension); + await baseManager.connect(operator.wallet).protectModule(subjectModule, [subjectExtension]); + }); + + it("should revoke extension authorization", async () => { + const initialAuthorization = await baseManager.isAuthorizedExtension(subjectModule, subjectExtension); + + await subject(operator); + await subject(methodologist); + + const finalAuthorization = await baseManager.isAuthorizedExtension(subjectModule, subjectExtension); + + expect(initialAuthorization).to.be.true; + expect(finalAuthorization).to.be.false; + }); + + it("should emit the correct ExtensionAuthorizationRevoked event", async () => { + await subject(operator); + + await expect(subject(methodologist)).to + .emit(baseManager, "ExtensionAuthorizationRevoked") + .withArgs(subjectModule, subjectExtension); + }); + }); + + describe("when an extension is shared by protected modules", () => { + beforeEach(async () => { + await baseManager.connect(operator.wallet).addExtension(subjectExtension); + await baseManager.connect(operator.wallet).protectModule(subjectAdditionalModule, [subjectExtension]); + await baseManager.connect(operator.wallet).protectModule(subjectModule, [subjectExtension]); + }); + + it("should only revoke authorization for the specified module", async () => { + const initialAuth = await baseManager.isAuthorizedExtension(subjectModule, subjectExtension); + const initialAdditionalAuth = await baseManager.isAuthorizedExtension(subjectAdditionalModule, subjectExtension); + + await subject(operator); + await subject(methodologist); + + const finalAuth = await baseManager.isAuthorizedExtension(subjectModule, subjectExtension); + const finalAdditionalAuth = await baseManager.isAuthorizedExtension(subjectAdditionalModule, subjectExtension); + + expect(initialAuth).to.be.true; + expect(initialAdditionalAuth).to.be.true; + expect(finalAuth).to.be.false; + expect(finalAdditionalAuth).to.be.true; + }); + }); + + describe("when extension is not added to the manager", () => { + beforeEach(async () => { + await baseManager.connect(operator.wallet).protectModule(subjectModule, []); + }); + + it("should revert", async () => { + const initialExtensionStatus = await baseManager.connect(operator.wallet).isExtension(subjectExtension); + + await subject(operator); + + await expect(initialExtensionStatus).to.be.false; + await expect(subject(methodologist)).to.be.revertedWith("Extension does not exist"); + }); + }); + + describe("when the extension is not authorized for target module", () => { + beforeEach(async () => { + await baseManager.connect(operator.wallet).addExtension(subjectExtension); + await baseManager.connect(operator.wallet).protectModule(subjectModule, []); + }); + + it("should revert", async () => { + const initialAuthorization = await baseManager.isAuthorizedExtension(subjectModule, subjectExtension); + + await subject(operator); + await expect(initialAuthorization).to.be.false; + await expect(subject(methodologist)).to.be.revertedWith("Extension not authorized"); + }); + }); + + describe("when target module is not protected", () => { + beforeEach(async () => { + await baseManager.connect(operator.wallet).addExtension(subjectExtension); + }); + + it("should revert", async () => { + const isProtected = await baseManager.protectedModules(subjectModule); + + await subject(operator); + + await expect(isProtected).to.be.false; + await expect(subject(methodologist)).to.be.revertedWith("Module not protected"); + }); + }); + + describe("when a single mutual upgrade party calls", () => { + it("should log the proposed streaming fee hash in the mutualUpgrades mapping", async () => { + const txHash = await subject(operator); + await validateMutualUprade(txHash, operator.address); + }); + }); + + describe("when the caller is not the operator or methodologist", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject(subjectCaller)).to.be.revertedWith("Must be authorized"); + }); + }); + }); + + describe("#addModule", async () => { + let subjectModule: Address; + let subjectCaller: Account; + + beforeEach(async () => { + await setV2Setup.controller.addModule(otherAccount.address); + + subjectModule = otherAccount.address; + subjectCaller = operator; + }); + + async function subject(): Promise { + return baseManager.connect(subjectCaller.wallet).addModule(subjectModule); + } + + it("should add the module to the SetToken", async () => { + await subject(); + const isModule = await setToken.isPendingModule(subjectModule); + expect(isModule).to.eq(true); + }); + + describe("when an emergency is in progress", async () => { + beforeEach(async () => { + subjectModule = setV2Setup.streamingFeeModule.address; + await baseManager.protectModule(subjectModule, []); + await baseManager.emergencyRemoveProtectedModule(subjectModule); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Upgrades paused by emergency"); + }); + }); + + describe("when the caller is not the operator", async () => { + beforeEach(async () => { + subjectCaller = methodologist; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be operator"); + }); + }); + }); + + describe("#emergencyRemoveProtectedModule", () => { + let subjectModule: Address; + let subjectAdditionalModule: Address; + let subjectExtension: Address; + let subjectCaller: Account; + + beforeEach(async () => { + subjectCaller = operator; + subjectModule = setV2Setup.streamingFeeModule.address; + subjectAdditionalModule = setV2Setup.governanceModule.address; // Removable + subjectExtension = baseExtension.address; + }); + + async function subject(): Promise { + return baseManager.connect(subjectCaller.wallet).emergencyRemoveProtectedModule(subjectModule); + } + + describe("when module is protected", async () => { + beforeEach(async () => { + await baseManager.connect(operator.wallet).protectModule(subjectModule, [subjectExtension]); + }); + + it("should remove the module from the set token", async () => { + await subject(); + const isModule = await setToken.isInitializedModule(subjectModule); + expect(isModule).to.eq(false); + }); + + it("should unprotect the module", async () => { + await subject(); + const isProtected = await baseManager.protectedModules(subjectModule); + expect(isProtected).to.be.false; + }); + + it("should clear the protected modules authorized extension registries", async () => { + const initialAuthorizedExtensionsList = await baseManager.getAuthorizedExtensions(subjectModule); + const initialIsAuthorized = await baseManager.isAuthorizedExtension(subjectModule, subjectExtension); + + await subject(); + + const finalAuthorizedExtensionsList = await baseManager.getAuthorizedExtensions(subjectModule); + const finalIsAuthorized = await baseManager.isAuthorizedExtension(subjectModule, subjectExtension); + + expect(initialAuthorizedExtensionsList.length).equals(1); + expect(initialIsAuthorized).to.be.true; + expect(finalAuthorizedExtensionsList.length).equals(0); + expect(finalIsAuthorized).to.be.false; + }); + + it("should not preserve any settings if same module is removed and restored", async () => { + await subject(); + + await baseManager.connect(methodologist.wallet).resolveEmergency(); + await baseManager.connect(operator.wallet).addModule(subjectModule); + + // Invoke initialize on streamingFeeModule + const feeRecipient = operator.address; + const maxStreamingFeePercentage = ether(.1); + const streamingFeePercentage = ether(.02); + const streamingFeeSettings = { + feeRecipient, + maxStreamingFeePercentage, + streamingFeePercentage, + lastStreamingFeeTimestamp: ZERO, + }; + + const initializeData = setV2Setup + .streamingFeeModule + .interface + .encodeFunctionData("initialize", [setToken.address, streamingFeeSettings]); + + await baseManager.connect(methodologist.wallet).authorizeInitialization(); + await baseExtension.interactManager(subjectModule, initializeData); + await baseManager.connect(operator.wallet).protectModule(subjectModule, []); + + const authorizedExtensionsList = await baseManager.getAuthorizedExtensions(subjectModule); + const isAuthorized = await baseManager.isAuthorizedExtension(subjectModule, subjectExtension); + + expect(authorizedExtensionsList.length).equals(0); + expect(isAuthorized).to.be.false; + }); + + it("should increment the emergencies counter", async () => { + const initialEmergencies = await baseManager.emergencies(); + + await subject(); + + const finalEmergencies = await baseManager.emergencies(); + + expect(initialEmergencies.toNumber()).equals(0); + expect(finalEmergencies.toNumber()).equals(1); + }); + + it("should emit the correct EmergencyRemovedProtectedModule event", async () => { + await expect(subject()).to + .emit(baseManager, "EmergencyRemovedProtectedModule") + .withArgs(subjectModule); + }); + }); + + describe("when an emergency is already in progress", async () => { + beforeEach(async () => { + baseManager.connect(operator.wallet); + + await baseManager.protectModule(subjectModule, []); + await baseManager.protectModule(subjectAdditionalModule, []); + await baseManager.emergencyRemoveProtectedModule(subjectAdditionalModule); + }); + + it("should increment the emergencies counter", async () => { + const initialEmergencies = await baseManager.emergencies(); + + await subject(); + + const finalEmergencies = await baseManager.emergencies(); + + expect(initialEmergencies.toNumber()).equals(1); + expect(finalEmergencies.toNumber()).equals(2); + }); + }); + + describe("when module is not protected", () => { + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Module not protected"); + }); + }); + + describe("when the caller is not the operator", async () => { + beforeEach(async () => { + subjectCaller = methodologist; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be operator"); + }); + }); + }); + + describe("#protectModule", () => { + let subjectModule: Address; + let subjectAdditionalModule: Address; + let subjectExtension: Address; + let subjectAuthorizedExtensions: Address[]; + let subjectCaller: Account; + + beforeEach(async () => { + subjectCaller = operator; + subjectModule = setV2Setup.streamingFeeModule.address; + subjectExtension = baseExtension.address; + subjectAdditionalModule = setV2Setup.governanceModule.address; // Removable + subjectAuthorizedExtensions = []; + }); + + async function subject(): Promise { + return baseManager + .connect(subjectCaller.wallet) + .protectModule(subjectModule, subjectAuthorizedExtensions); + } + + describe("when module already added, no extensions", () => { + it("should protect the module", async () => { + const initialIsProtected = await baseManager.protectedModules(subjectModule); + const initialProtectedModulesList = await baseManager.getProtectedModules(); + + await subject(); + + const finalIsProtected = await baseManager.protectedModules(subjectModule); + const finalProtectedModulesList = await baseManager.getProtectedModules(); + + expect(initialIsProtected).to.be.false; + expect(finalIsProtected).to.be.true; + expect(initialProtectedModulesList.length).equals(0); + expect(finalProtectedModulesList.length).equals(1); + }); + }); + + describe("when module already added, with non-added extension", () => { + beforeEach(() => { + subjectAuthorizedExtensions = [subjectExtension]; + }); + it("should add and authorize the extension", async () => { + const initialIsExtension = await baseManager.isExtension(subjectExtension); + const initialIsAuthorizedExtension = await baseManager.isAuthorizedExtension(subjectModule, subjectExtension); + + await subject(); + + const finalIsExtension = await baseManager.isExtension(subjectExtension); + const finalIsAuthorizedExtension = await baseManager.isAuthorizedExtension(subjectModule, subjectExtension); + + expect(initialIsExtension).to.be.false; + expect(finalIsExtension).to.be.true; + expect(initialIsAuthorizedExtension).to.be.false; + expect(finalIsAuthorizedExtension).to.be.true; + }); + + // With extensions... + it("should emit the correct ModuleProtected event", async () => { + await expect(subject()).to + .emit(baseManager, "ModuleProtected") + .withArgs(subjectModule, subjectAuthorizedExtensions); + }); + }); + + describe("when module and extension already added", () => { + beforeEach(async () => { + await baseManager.connect(operator.wallet).addExtension(subjectExtension); + subjectAuthorizedExtensions = [subjectExtension]; + }); + + it("should authorize the extension", async () => { + const initialIsExtension = await baseManager.isExtension(subjectExtension); + const initialIsAuthorizedExtension = await baseManager.isAuthorizedExtension(subjectModule, subjectExtension); + + await subject(); + + const finalIsAuthorizedExtension = await baseManager.isAuthorizedExtension(subjectModule, subjectExtension); + + expect(initialIsExtension).to.be.true; + expect(initialIsAuthorizedExtension).to.be.false; + expect(finalIsAuthorizedExtension).to.be.true; + }); + }); + + describe("when module not added", () => { + beforeEach(async () => { + await baseManager.removeModule(subjectModule); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Module not added yet"); + }); + }); + + describe("when module already protected", () => { + beforeEach(async () => { + await baseManager.protectModule(subjectModule, []); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Module already protected"); + }); + }); + + describe("when an emergency is in progress", async () => { + beforeEach(async () => { + await baseManager.protectModule(subjectAdditionalModule, []); + await baseManager.emergencyRemoveProtectedModule(subjectAdditionalModule); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Upgrades paused by emergency"); + }); + }); + + describe("when the caller is not the operator", async () => { + beforeEach(async () => { + subjectCaller = methodologist; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be operator"); + }); + }); + }); + + describe("#unProtectModule", () => { + let subjectModule: Address; + let subjectExtension: Address; + let subjectCaller: Account; + + beforeEach(async () => { + subjectCaller = methodologist; + subjectModule = setV2Setup.streamingFeeModule.address; + subjectExtension = baseExtension.address; + }); + + async function subject(): Promise { + return baseManager.connect(subjectCaller.wallet).unProtectModule(subjectModule); + } + + describe("when module is protected", () => { + beforeEach(async () => { + await baseManager.connect(operator.wallet).protectModule(subjectModule, [subjectExtension]); + }); + + it("should *not* remove the module from the set token", async () => { + await subject(); + const isModule = await setToken.isInitializedModule(subjectModule); + expect(isModule).to.be.true; + }); + + it("should unprotect the module", async () => { + await subject(); + const isProtected = await baseManager.protectedModules(subjectModule); + expect(isProtected).to.be.false; + }); + + it("should clear the protected modules authorized extension registries", async () => { + const initialAuthorizedExtensionsList = await baseManager.getAuthorizedExtensions(subjectModule); + const initialIsAuthorized = await baseManager.isAuthorizedExtension(subjectModule, subjectExtension); + + await subject(); + + const finalAuthorizedExtensionsList = await baseManager.getAuthorizedExtensions(subjectModule); + const finalIsAuthorized = await baseManager.isAuthorizedExtension(subjectModule, subjectExtension); + + expect(initialAuthorizedExtensionsList.length).equals(1); + expect(initialIsAuthorized).to.be.true; + expect(finalAuthorizedExtensionsList.length).equals(0); + expect(finalIsAuthorized).to.be.false; + }); + + it("should not preserve any settings if same module is removed and restored", async () => { + await subject(); + + // Restore without extension + await baseManager.protectModule(subjectModule, []); + + const authorizedExtensionsList = await baseManager.getAuthorizedExtensions(subjectModule); + const isAuthorized = await baseManager.isAuthorizedExtension(subjectModule, subjectExtension); + + expect(authorizedExtensionsList.length).equals(0); + expect(isAuthorized).to.be.false; + }); + + it("should emit the correct ModuleUnprotected event", async () => { + await expect(subject()).to + .emit(baseManager, "ModuleUnprotected") + .withArgs(subjectModule); + }); + }); + + describe("when module is not protected", () => { + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Module not protected"); + }); + }); + + describe("when the caller is not the methodologist", async () => { + beforeEach(async () => { + subjectCaller = operator; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be methodologist"); + }); + }); + }); + + describe("#replaceProtectedModule", () => { + let subjectOldModule: Address; + let subjectNewModule: Address; + let subjectOldExtension: Address; + let subjectNewExtension: Address; + let subjectCaller: Account; + + beforeEach(async () => { + await setV2Setup.controller.addModule(otherAccount.address); + + subjectCaller = operator; + subjectOldModule = setV2Setup.streamingFeeModule.address; + subjectNewModule = otherAccount.address; + subjectOldExtension = baseExtension.address; + subjectNewExtension = (await deployer.mocks.deployBaseExtensionMock(baseManager.address)).address; + }); + + async function subject(caller: Account): Promise { + return baseManager + .connect(caller.wallet) + .replaceProtectedModule(subjectOldModule, subjectNewModule, [subjectNewExtension]); + } + + describe("when old module is protected", () => { + beforeEach(async () => { + await baseManager.connect(operator.wallet).protectModule(subjectOldModule, [subjectOldExtension]); + }); + + describe("when new module is not added", () => { + it("should add new module to setToken", async () => { + const initialModuleAdded = await setToken.isPendingModule(subjectNewModule); + + await subject(operator); + await subject(methodologist); + + const finalModuleAdded = await setToken.isPendingModule(subjectNewModule); + + expect(initialModuleAdded).to.be.false; + expect(finalModuleAdded).to.be.true; + }); + + it("should remove old module from setToken", async () => { + const initialModuleAdded = await setToken.isInitializedModule(subjectOldModule); + + await subject(operator); + await subject(methodologist); + + const finalModuleAdded = await setToken.isInitializedModule(subjectOldModule); + + expect(initialModuleAdded).to.be.true; + expect(finalModuleAdded).to.be.false; + }); + + it("should protect the module", async () => { + const initialIsProtected = await baseManager.protectedModules(subjectNewModule); + const initialProtectedModulesList = await baseManager.getProtectedModules(); + + await subject(operator); + await subject(methodologist); + + const finalIsProtected = await baseManager.protectedModules(subjectNewModule); + const finalProtectedModulesList = await baseManager.getProtectedModules(); + + expect(initialIsProtected).to.be.false; + expect(finalIsProtected).to.be.true; + expect(initialProtectedModulesList[0]).equals(subjectOldModule); + expect(finalProtectedModulesList[0]).equals(subjectNewModule); + }); + + it("should unprotect the old module", async () => { + await subject(operator); + await subject(methodologist); + + const isProtected = await baseManager.protectedModules(subjectOldModule); + expect(isProtected).to.be.false; + }); + + it("should clear the old modules authorized extension registries", async () => { + const initialAuthorizedExtensionsList = await baseManager.getAuthorizedExtensions(subjectOldModule); + const initialIsAuthorized = await baseManager.isAuthorizedExtension(subjectOldModule, subjectOldExtension); + + await subject(operator); + await subject(methodologist); + + const finalAuthorizedExtensionsList = await baseManager.getAuthorizedExtensions(subjectOldModule); + const finalIsAuthorized = await baseManager.isAuthorizedExtension(subjectOldModule, subjectOldExtension); + + expect(initialAuthorizedExtensionsList.length).equals(1); + expect(initialIsAuthorized).to.be.true; + expect(finalAuthorizedExtensionsList.length).equals(0); + expect(finalIsAuthorized).to.be.false; + }); + + it("should add and authorize the new module extension", async () => { + const initialIsExtension = await baseManager.isExtension(subjectNewExtension); + const initialIsAuthorizedExtension = await baseManager.isAuthorizedExtension( + subjectNewModule, subjectNewExtension + ); + + await subject(operator); + await subject(methodologist); + + const finalIsExtension = await baseManager.isExtension(subjectNewExtension); + const finalIsAuthorizedExtension = await baseManager.isAuthorizedExtension( + subjectNewModule, + subjectNewExtension + ); + + expect(initialIsExtension).to.be.false; + expect(finalIsExtension).to.be.true; + expect(initialIsAuthorizedExtension).to.be.false; + expect(finalIsAuthorizedExtension).to.be.true; + }); + + it("should emit the correct ReplacedProtectedModule event", async () => { + await subject(operator); + + await expect(subject(methodologist)).to + .emit(baseManager, "ReplacedProtectedModule") + .withArgs(subjectOldModule, subjectNewModule, [subjectNewExtension]); + }); + }); + + describe("when the new module is already added", async () => { + beforeEach(async () => { + await baseManager.addModule(subjectNewModule); + }); + + it("should revert", async () => { + await subject(operator); + await expect(subject(methodologist)).to.be.revertedWith("Module must not be added"); + }); + }); + }); + + describe("when old module is not protected", () => { + it("should revert", async () => { + await subject(operator); + await expect(subject(methodologist)).to.be.revertedWith("Module not protected"); + }); + }); + + describe("when a single mutual upgrade party calls", () => { + it("should log the proposed streaming fee hash in the mutualUpgrades mapping", async () => { + const txHash = await subject(operator); + await validateMutualUprade(txHash, operator.address); + }); + }); + + describe("when the caller is not the operator or methodologist", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject(subjectCaller)).to.be.revertedWith("Must be authorized"); + }); + }); + }); + + describe("#emergencyReplaceProtectedModule", () => { + let subjectOldModule: Address; + let subjectAdditionalOldModule: Address; + let subjectNewModule: Address; + let subjectNewExtension: Address; + let subjectCaller: Account; + + beforeEach(async () => { + await setV2Setup.controller.addModule(otherAccount.address); + + subjectCaller = operator; + subjectOldModule = setV2Setup.streamingFeeModule.address; + subjectAdditionalOldModule = setV2Setup.governanceModule.address; // Removable + subjectNewModule = otherAccount.address; + subjectNewExtension = (await deployer.mocks.deployBaseExtensionMock(baseManager.address)).address; + }); + + async function subject(caller: Account): Promise { + return baseManager + .connect(caller.wallet) + .emergencyReplaceProtectedModule(subjectNewModule, [subjectNewExtension]); + } + + describe("when new module is not added", () => { + beforeEach(async () => { + // Trigger emergency + baseManager.connect(operator.wallet); + await baseManager.protectModule(subjectOldModule, []); + await baseManager.emergencyRemoveProtectedModule(subjectOldModule); + }); + + it("should add module to setToken", async () => { + const initialModuleAdded = await setToken.isPendingModule(subjectNewModule); + + await subject(operator); + await subject(methodologist); + + const finalModuleAdded = await setToken.isPendingModule(subjectNewModule); + + expect(initialModuleAdded).to.be.false; + expect(finalModuleAdded).to.be.true; + }); + + it("should protect the module", async () => { + const initialIsProtected = await baseManager.protectedModules(subjectNewModule); + + await subject(operator); + await subject(methodologist); + + const finalIsProtected = await baseManager.protectedModules(subjectNewModule); + + expect(initialIsProtected).to.be.false; + expect(finalIsProtected).to.be.true; + }); + + it("should add and authorize the new module extension", async () => { + const initialIsExtension = await baseManager.isExtension(subjectNewExtension); + const initialIsAuthorizedExtension = await baseManager.isAuthorizedExtension( + subjectNewModule, subjectNewExtension + ); + + await subject(operator); + await subject(methodologist); + + const finalIsExtension = await baseManager.isExtension(subjectNewExtension); + const finalIsAuthorizedExtension = await baseManager.isAuthorizedExtension( + subjectNewModule, + subjectNewExtension + ); + + expect(initialIsExtension).to.be.false; + expect(finalIsExtension).to.be.true; + expect(initialIsAuthorizedExtension).to.be.false; + expect(finalIsAuthorizedExtension).to.be.true; + }); + + it("should decrement the emergencies counter", async() => { + const initialEmergencies = await baseManager.emergencies(); + + await subject(operator); + await subject(methodologist); + + const finalEmergencies = await baseManager.emergencies(); + + expect(initialEmergencies.toNumber()).equals(1); + expect(finalEmergencies.toNumber()).equals(0); + }); + + it("should emit the correct EmergencyReplacedProtectedModule event", async () => { + await subject(operator); + + await expect(subject(methodologist)).to + .emit(baseManager, "EmergencyReplacedProtectedModule") + .withArgs(subjectNewModule, [subjectNewExtension]); + }); + }); + + describe("when the new module is already added", async () => { + beforeEach(async () => { + baseManager.connect(operator.wallet); + await baseManager.addModule(subjectNewModule); + await baseManager.protectModule(subjectOldModule, []); + await baseManager.emergencyRemoveProtectedModule(subjectOldModule); + }); + + it("should revert", async () => { + await subject(operator); + await expect(subject(methodologist)).to.be.revertedWith("Module must not be added"); + }); + }); + + describe("when an emergency is not in progress", async () => { + it("should revert", async () => { + await subject(operator); + await expect(subject(methodologist)).to.be.revertedWith("Not in emergency"); + }); + }); + + describe("when more than one emergency is in progress", async () => { + beforeEach(async () => { + baseManager.connect(operator.wallet); + await baseManager.protectModule(subjectOldModule, []); + await baseManager.protectModule(subjectAdditionalOldModule, []); + await baseManager.emergencyRemoveProtectedModule(subjectOldModule); + await baseManager.emergencyRemoveProtectedModule(subjectAdditionalOldModule); + }); + + it("should remain in an emergency state after replacement", async () => { + const initialEmergencies = await baseManager.emergencies(); + + await subject(operator); + await subject(methodologist); + + const finalEmergencies = await baseManager.emergencies(); + + expect(initialEmergencies.toNumber()).equals(2); + expect(finalEmergencies.toNumber()).equals(1); + }); + }); + + describe("when a single mutual upgrade party calls", () => { + it("should log the proposed streaming fee hash in the mutualUpgrades mapping", async () => { + const txHash = await subject(operator); + await validateMutualUprade(txHash, operator.address); + }); + }); + + describe("when the caller is not the operator or methodologist", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject(subjectCaller)).to.be.revertedWith("Must be authorized"); + }); + }); + }); + + describe("#resolveEmergency", () => { + let subjectModule: Address; + let subjectCaller: Account; + + beforeEach(async () => { + subjectModule = setV2Setup.streamingFeeModule.address; + subjectCaller = methodologist; + }); + + async function subject(): Promise { + return baseManager.connect(subjectCaller.wallet).resolveEmergency(); + } + + describe("when an emergency is in progress", () => { + beforeEach(async () => { + await baseManager.protectModule(subjectModule, []); + await baseManager.emergencyRemoveProtectedModule(subjectModule); + }); + + it("should decrement the emergency counter", async () => { + const initialEmergencies = await baseManager.emergencies(); + + await subject(); + + const finalEmergencies = await baseManager.emergencies(); + + expect(initialEmergencies.toNumber()).equals(1); + expect(finalEmergencies.toNumber()).equals(0); + }); + }); + + describe("when an emergency is *not* in progress", async () => { + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Not in emergency"); + }); + }); + + describe("when the caller is not the methodologist", async () => { + beforeEach(async () => { + subjectCaller = operator; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be methodologist"); + }); + }); + }); + + describe("#interactManager", async () => { + let subjectModule: Address; + let subjectExtension: Address; + let subjectCallData: Bytes; + + beforeEach(async () => { + await baseManager.connect(operator.wallet).addExtension(baseExtension.address); + + subjectModule = setV2Setup.streamingFeeModule.address; + subjectExtension = baseExtension.address; + + // Invoke update fee recipient + subjectCallData = setV2Setup.streamingFeeModule.interface.encodeFunctionData("updateFeeRecipient", [ + setToken.address, + otherAccount.address, + ]); + }); + + async function subject(): Promise { + return baseExtension.interactManager(subjectModule, subjectCallData); + } + + context("when the manager is initialized", () => { + beforeEach(async() => { + await baseManager.connect(methodologist.wallet).authorizeInitialization(); + }); + + it("should call updateFeeRecipient on the streaming fee module from the SetToken", async () => { + await subject(); + const feeStates = await setV2Setup.streamingFeeModule.feeStates(setToken.address); + expect(feeStates.feeRecipient).to.eq(otherAccount.address); + }); + + describe("when the caller is not an extension", async () => { + beforeEach(async () => { + await baseManager.connect(operator.wallet).removeExtension(baseExtension.address); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be extension"); + }); + }); + }); + + context("when the manager is not initialized", () => { + it("updateFeeRecipient should revert", async () => { + expect(subject()).to.be.revertedWith("Manager not initialized"); + }); + }); + + context("when the module is protected and extension is authorized", () => { + beforeEach(async () => { + await baseManager.connect(methodologist.wallet).authorizeInitialization(); + await baseManager.connect(operator.wallet).protectModule(subjectModule, [subjectExtension]); + }); + + it("updateFeeRecipient should succeed", async () => { + await subject(); + const feeStates = await setV2Setup.streamingFeeModule.feeStates(setToken.address); + expect(feeStates.feeRecipient).to.eq(otherAccount.address); + }); + }); + + context("when the module is protected and extension is not authorized", () => { + beforeEach(async () => { + await baseManager.connect(methodologist.wallet).authorizeInitialization(); + await baseManager.connect(operator.wallet).protectModule(subjectModule, []); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Extension not authorized for module"); + }); + }); + }); + + describe("#removeModule", async () => { + let subjectModule: Address; + let subjectExtension: Address; + let subjectCaller: Account; + + beforeEach(async () => { + subjectModule = setV2Setup.streamingFeeModule.address; + subjectExtension = baseExtension.address; + subjectCaller = operator; + }); + + async function subject(): Promise { + return baseManager.connect(subjectCaller.wallet).removeModule(subjectModule); + } + + it("should remove the module from the SetToken", async () => { + await subject(); + const isModule = await setToken.isInitializedModule(subjectModule); + expect(isModule).to.eq(false); + }); + + describe("when the caller is not the operator", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be operator"); + }); + }); + + describe("when the module is protected module", () => { + beforeEach(() => { + baseManager.connect(operator.wallet).protectModule(subjectModule, [subjectExtension]); + }); + + it("should revert", async() => { + await expect(subject()).to.be.revertedWith("Module protected"); + }); + }); + }); + + describe("#setMethodologist", async () => { + let subjectNewMethodologist: Address; + let subjectCaller: Account; + + beforeEach(async () => { + subjectNewMethodologist = await getRandomAddress(); + subjectCaller = methodologist; + }); + + async function subject(): Promise { + return baseManager.connect(subjectCaller.wallet).setMethodologist(subjectNewMethodologist); + } + + it("should set the new methodologist", async () => { + await subject(); + const actualIndexModule = await baseManager.methodologist(); + expect(actualIndexModule).to.eq(subjectNewMethodologist); + }); + + it("should emit the correct MethodologistChanged event", async () => { + await expect(subject()).to.emit(baseManager, "MethodologistChanged").withArgs(methodologist.address, subjectNewMethodologist); + }); + + describe("when the caller is not the methodologist", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be methodologist"); + }); + }); + }); + + describe("#setOperator", async () => { + let subjectNewOperator: Address; + let subjectCaller: Account; + + beforeEach(async () => { + subjectNewOperator = await getRandomAddress(); + subjectCaller = operator; + }); + + async function subject(): Promise { + return baseManager.connect(subjectCaller.wallet).setOperator(subjectNewOperator); + } + + it("should set the new operator", async () => { + await subject(); + const actualIndexModule = await baseManager.operator(); + expect(actualIndexModule).to.eq(subjectNewOperator); + }); + + it("should emit the correct OperatorChanged event", async () => { + await expect(subject()).to.emit(baseManager, "OperatorChanged").withArgs(operator.address, subjectNewOperator); + }); + + describe("when the caller is not the operator", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be operator"); + }); + }); + }); + + describe("E2E: deployment, configuration, methodologist authorization, use", async () => { + let subjectSetToken: Address; + let subjectExtension: StreamingFeeSplitExtension; + let subjectModule: Address; + let subjectOperator: Address; + let subjectMethodologist: Address; + let subjectFeeSplit: BigNumber; + let subjectOperatorFeeRecipient: Address; + let subjectManager: BaseManagerV2; + + beforeEach(async () => { + subjectSetToken = setToken.address; + subjectModule = setV2Setup.streamingFeeModule.address; + subjectOperator = operator.address; + subjectMethodologist = methodologist.address; + subjectFeeSplit = ether(.7); + subjectOperatorFeeRecipient = operator.address; + + // Deploy new manager + subjectManager = await deployer.manager.deployBaseManagerV2( + subjectSetToken, + subjectOperator, + subjectMethodologist + ); + + // Deploy new fee extension + subjectExtension = await deployer.extensions.deployStreamingFeeSplitExtension( + subjectManager.address, + subjectModule, + subjectFeeSplit, + subjectOperatorFeeRecipient + ); + + // Operator protects module and adds extension + await subjectManager.connect(operator.wallet).protectModule(subjectModule, [subjectExtension.address]); + + // Methodologist authorizes new manager + await subjectManager.connect(methodologist.wallet).authorizeInitialization(); + + // Transfer ownership from old to new manager + await baseManager.connect(operator.wallet).setManager(subjectManager.address); + await baseManager.connect(methodologist.wallet).setManager(subjectManager.address); + }); + + // Makes mutual upgrade call which routes call to module via interactManager + async function subject(): Promise { + await subjectExtension.connect(operator.wallet).updateFeeRecipient(subjectExtension.address); + await subjectExtension.connect(methodologist.wallet).updateFeeRecipient(subjectExtension.address); + } + + it("allows protected calls", async() => { + const initialFeeRecipient = (await setV2Setup.streamingFeeModule.feeStates(subjectSetToken)).feeRecipient; + + await subject(); + + const finalFeeRecipient = (await setV2Setup.streamingFeeModule.feeStates(subjectSetToken)).feeRecipient; + + expect(initialFeeRecipient).to.equal(operator.address); + expect(finalFeeRecipient).to.equal(subjectExtension.address); + }); + }); +}); diff --git a/utils/contracts/index.ts b/utils/contracts/index.ts index 0abf6a74..55802deb 100644 --- a/utils/contracts/index.ts +++ b/utils/contracts/index.ts @@ -1,12 +1,14 @@ -export { BaseAdapterMock } from "../../typechain/BaseAdapterMock"; +export { BaseExtensionMock } from "../../typechain/BaseExtensionMock"; export { BaseManager } from "../../typechain/BaseManager"; +export { BaseManagerV2 } from "../../typechain/BaseManagerV2"; export { ChainlinkAggregatorV3Mock } from "../../typechain/ChainlinkAggregatorV3Mock"; +export { DebtIssuanceModule } from "../../typechain/DebtIssuanceModule"; export { ExchangeIssuance } from "../../typechain/ExchangeIssuance"; export { ExchangeIssuanceV2 } from "../../typechain/ExchangeIssuanceV2"; -export { FeeSplitAdapter } from "../../typechain/FeeSplitAdapter"; +export { FeeSplitExtension } from "../../typechain/FeeSplitExtension"; export { FlexibleLeverageStrategyExtension } from "../../typechain/FlexibleLeverageStrategyExtension"; export { GIMExtension } from "../../typechain/GIMExtension"; -export { GovernanceAdapter } from "../../typechain/GovernanceAdapter"; +export { GovernanceExtension } from "../../typechain/GovernanceExtension"; export { GovernanceAdapterMock } from "../../typechain/GovernanceAdapterMock"; export { ICManager } from "../../typechain/ICManager"; export { IndexToken } from "../../typechain/IndexToken"; @@ -17,6 +19,7 @@ export { RewardsDistributionRecipient } from "../../typechain/RewardsDistributio export { StakingRewards } from "../../typechain/StakingRewards"; export { StakingRewardsV2 } from "../../typechain/StakingRewardsV2"; export { StandardTokenMock } from "../../typechain/StandardTokenMock"; +export { StreamingFeeModule } from "../../typechain/StreamingFeeModule"; export { StreamingFeeSplitExtension } from "../../typechain/StreamingFeeSplitExtension"; export { StringArrayUtilsMock } from "../../typechain/StringArrayUtilsMock"; export { SupplyCapAllowedCallerIssuanceHook } from "../../typechain/SupplyCapAllowedCallerIssuanceHook"; @@ -25,3 +28,4 @@ export { TradeAdapterMock } from "../../typechain/TradeAdapterMock"; export { Vesting } from "../../typechain/Vesting"; export { WETH9 } from "../../typechain/WETH9"; export { FLIStrategyExtensionMock } from "../../typechain/FLIStrategyExtensionMock"; + diff --git a/utils/deploys/deployAdapters.ts b/utils/deploys/deployExtensions.ts similarity index 82% rename from utils/deploys/deployAdapters.ts rename to utils/deploys/deployExtensions.ts index 8c0eabc2..59e5e312 100644 --- a/utils/deploys/deployAdapters.ts +++ b/utils/deploys/deployExtensions.ts @@ -4,38 +4,40 @@ import { ExchangeIssuance, ExchangeIssuanceV2, FlexibleLeverageStrategyExtension, - FeeSplitAdapter, + FeeSplitExtension, GIMExtension, - GovernanceAdapter, + GovernanceExtension, StreamingFeeSplitExtension } from "../contracts/index"; import { ExchangeIssuance__factory } from "../../typechain/factories/ExchangeIssuance__factory"; import { ExchangeIssuanceV2__factory } from "../../typechain/factories/ExchangeIssuanceV2__factory"; -import { FeeSplitAdapter__factory } from "../../typechain/factories/FeeSplitAdapter__factory"; +import { FeeSplitExtension__factory } from "../../typechain/factories/FeeSplitExtension__factory"; import { FlexibleLeverageStrategyExtension__factory } from "../../typechain/factories/FlexibleLeverageStrategyExtension__factory"; import { GIMExtension__factory } from "../../typechain/factories/GIMExtension__factory"; -import { GovernanceAdapter__factory } from "../../typechain/factories/GovernanceAdapter__factory"; +import { GovernanceExtension__factory } from "../../typechain/factories/GovernanceExtension__factory"; import { StreamingFeeSplitExtension__factory } from "../../typechain/factories/StreamingFeeSplitExtension__factory"; -export default class DeployAdapters { +export default class DeployExtensions { private _deployerSigner: Signer; constructor(deployerSigner: Signer) { this._deployerSigner = deployerSigner; } - public async deployFeeSplitAdapter( + public async deployFeeSplitExtension( manager: Address, streamingFeeModule: Address, debtIssuanceModule: Address, operatorFeeSplit: BigNumber, - ): Promise { - return await new FeeSplitAdapter__factory(this._deployerSigner).deploy( + operatorFeeRecipient: Address + ): Promise { + return await new FeeSplitExtension__factory(this._deployerSigner).deploy( manager, streamingFeeModule, debtIssuanceModule, - operatorFeeSplit + operatorFeeSplit, + operatorFeeRecipient ); } @@ -43,19 +45,21 @@ export default class DeployAdapters { manager: Address, streamingFeeModule: Address, operatorFeeSplit: BigNumber, + operatorFeeRecipient: Address, ): Promise { return await new StreamingFeeSplitExtension__factory(this._deployerSigner).deploy( manager, streamingFeeModule, - operatorFeeSplit + operatorFeeSplit, + operatorFeeRecipient ); } - public async deployGovernanceAdapter( + public async deployGovernanceExtension( manager: Address, governanceModule: Address, - ): Promise { - return await new GovernanceAdapter__factory(this._deployerSigner).deploy( + ): Promise { + return await new GovernanceExtension__factory(this._deployerSigner).deploy( manager, governanceModule ); diff --git a/utils/deploys/deployManager.ts b/utils/deploys/deployManager.ts index f531c779..60550867 100644 --- a/utils/deploys/deployManager.ts +++ b/utils/deploys/deployManager.ts @@ -1,10 +1,11 @@ import { Signer } from "ethers"; import { BigNumber } from "@ethersproject/bignumber"; import { Address } from "../types"; -import { ICManager, BaseManager } from "../contracts/index"; +import { ICManager, BaseManager, BaseManagerV2 } from "../contracts/index"; import { ICManager__factory } from "../../typechain/factories/ICManager__factory"; import { BaseManager__factory } from "../../typechain/factories/BaseManager__factory"; +import { BaseManagerV2__factory } from "../../typechain/factories/BaseManagerV2__factory"; export default class DeployToken { private _deployerSigner: Signer; @@ -34,12 +35,24 @@ export default class DeployToken { public async deployBaseManager( set: Address, operator: Address, - methodologist: Address, + methodologist: Address ): Promise { return await new BaseManager__factory(this._deployerSigner).deploy( set, operator, - methodologist, + methodologist + ); + } + + public async deployBaseManagerV2( + set: Address, + operator: Address, + methodologist: Address + ): Promise { + return await new BaseManagerV2__factory(this._deployerSigner).deploy( + set, + operator, + methodologist ); } } \ No newline at end of file diff --git a/utils/deploys/deployMocks.ts b/utils/deploys/deployMocks.ts index a69fe56c..e2008070 100644 --- a/utils/deploys/deployMocks.ts +++ b/utils/deploys/deployMocks.ts @@ -1,7 +1,7 @@ import { Signer, BigNumber } from "ethers"; import { Address } from "../types"; import { - BaseAdapterMock, + BaseExtensionMock, FLIStrategyExtensionMock, GovernanceAdapterMock, MutualUpgradeMock, @@ -10,7 +10,7 @@ import { TradeAdapterMock } from "../contracts/index"; -import { BaseAdapterMock__factory } from "../../typechain/factories/BaseAdapterMock__factory"; +import { BaseExtensionMock__factory } from "../../typechain/factories/BaseExtensionMock__factory"; import { ChainlinkAggregatorV3Mock__factory } from "../../typechain/factories/ChainlinkAggregatorV3Mock__factory"; import { FLIStrategyExtensionMock__factory } from "../../typechain/factories/FLIStrategyExtensionMock__factory"; import { GovernanceAdapterMock__factory } from "../../typechain/factories/GovernanceAdapterMock__factory"; @@ -27,8 +27,8 @@ export default class DeployMocks { this._deployerSigner = deployerSigner; } - public async deployBaseAdapterMock(manager: Address): Promise { - return await new BaseAdapterMock__factory(this._deployerSigner).deploy(manager); + public async deployBaseExtensionMock(manager: Address): Promise { + return await new BaseExtensionMock__factory(this._deployerSigner).deploy(manager); } public async deployTradeAdapterMock(): Promise { diff --git a/utils/deploys/index.ts b/utils/deploys/index.ts index 365f6d9d..0e5a5fb3 100644 --- a/utils/deploys/index.ts +++ b/utils/deploys/index.ts @@ -4,7 +4,7 @@ import DeployManager from "./deployManager"; import DeployMocks from "./deployMocks"; import DeployToken from "./deployToken"; import DeploySetV2 from "./deploySetV2"; -import DeployAdapter from "./deployAdapters"; +import DeployExtensions from "./deployExtensions"; import DeployExternalContracts from "./deployExternal"; import DeployHooks from "./deployHooks"; import DeployStaking from "./deployStaking"; @@ -15,7 +15,7 @@ export default class DeployHelper { public setV2: DeploySetV2; public manager: DeployManager; public mocks: DeployMocks; - public adapters: DeployAdapter; + public extensions: DeployExtensions; public external: DeployExternalContracts; public hooks: DeployHooks; public staking: DeployStaking; @@ -26,7 +26,7 @@ export default class DeployHelper { this.setV2 = new DeploySetV2(deployerSigner); this.manager = new DeployManager(deployerSigner); this.mocks = new DeployMocks(deployerSigner); - this.adapters = new DeployAdapter(deployerSigner); + this.extensions = new DeployExtensions(deployerSigner); this.external = new DeployExternalContracts(deployerSigner); this.hooks = new DeployHooks(deployerSigner); this.staking = new DeployStaking(deployerSigner);