From 3779c80a086f3074edca7bb5d2bd728ec006e742 Mon Sep 17 00:00:00 2001 From: Pranav Bhardwaj <84149824+pblivin0x@users.noreply.github.com> Date: Fri, 20 May 2022 12:52:08 -0500 Subject: [PATCH] feat(ProductiveAssetExtensions): Add WrapExtension and ClaimExtension [SIM-270] (#35) * add WrapExtension And ClaimExtension --- contracts/extensions/ClaimExtension.sol | 742 +++++++ contracts/extensions/WrapExtension.sol | 264 +++ contracts/interfaces/IDelegatedManager.sol | 3 +- package.json | 2 +- test/extensions/claimExtension.spec.ts | 2155 ++++++++++++++++++++ test/extensions/wrapExtension.spec.ts | 808 ++++++++ utils/contracts/index.ts | 4 +- utils/deploys/deployGlobalExtensions.ts | 30 +- yarn.lock | 8 +- 9 files changed, 4008 insertions(+), 8 deletions(-) create mode 100644 contracts/extensions/ClaimExtension.sol create mode 100644 contracts/extensions/WrapExtension.sol create mode 100644 test/extensions/claimExtension.spec.ts create mode 100644 test/extensions/wrapExtension.spec.ts diff --git a/contracts/extensions/ClaimExtension.sol b/contracts/extensions/ClaimExtension.sol new file mode 100644 index 0000000..4e476ba --- /dev/null +++ b/contracts/extensions/ClaimExtension.sol @@ -0,0 +1,742 @@ +/* + Copyright 2022 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 { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeMath } from "@openzeppelin/contracts/math/SafeMath.sol"; + +import { IAirdropModule } from "@setprotocol/set-protocol-v2/contracts/interfaces/IAirdropModule.sol"; +import { IClaimAdapter } from "@setprotocol/set-protocol-v2/contracts/interfaces/IClaimAdapter.sol"; +import { IClaimModule } from "@setprotocol/set-protocol-v2/contracts/interfaces/IClaimModule.sol"; +import { IIntegrationRegistry } from "@setprotocol/set-protocol-v2/contracts/interfaces/IIntegrationRegistry.sol"; +import { ISetToken } from "@setprotocol/set-protocol-v2/contracts/interfaces/ISetToken.sol"; +import { PreciseUnitMath } from "@setprotocol/set-protocol-v2/contracts/lib/PreciseUnitMath.sol"; + +import { BaseGlobalExtension } from "../lib/BaseGlobalExtension.sol"; +import { IDelegatedManager } from "../interfaces/IDelegatedManager.sol"; +import { IManagerCore } from "../interfaces/IManagerCore.sol"; + +/** + * @title ClaimExtension + * @author Set Protocol + * + * Smart contract global extension which provides DelegatedManager owner the ability to perform administrative tasks on the AirdropModule + * and the ClaimModule and the DelegatedManager operator(s) the ability to + * - absorb tokens sent to the SetToken into the token's positions + * - claim tokens from external protocols given to a Set as part of participating in incentivized activities of other protocols + * and absorb them into the SetToken's positions in a single transaction + */ +contract ClaimExtension is BaseGlobalExtension { + using PreciseUnitMath for uint256; + using SafeMath for uint256; + + /* ============ Events ============ */ + + event ClaimExtensionInitialized( + address indexed _setToken, + address indexed _delegatedManager + ); + + event FeesDistributed( + address _setToken, // Address of SetToken which generated the airdrop fees + address _token, // Address of the token to distribute + address indexed _ownerFeeRecipient, // Address which receives the owner's take of the fees + address indexed _methodologist, // Address of methodologist + uint256 _ownerTake, // Amount of _token distributed to owner + uint256 _methodologistTake // Amount of _token distributed to methodologist + ); + + /* ============ Modifiers ============ */ + + /** + * Throws if useAssetAllowList is true and one of the assets is not on the asset allow list + */ + modifier onlyAllowedAssets(ISetToken _setToken, address[] memory _assets) { + _validateAllowedAssets(_setToken, _assets); + _; + } + + /** + * Throws if anyoneAbsorb on the AirdropModule is false and caller is not the operator + */ + modifier onlyValidAbsorbCaller(ISetToken _setToken) { + require(_isValidAbsorbCaller(_setToken), "Must be valid AirdropModule absorb caller"); + _; + } + + /** + * Throws if caller is not the operator and either anyoneAbsorb on the AirdropModule or anyoneClaim on the ClaimModule is false + */ + modifier onlyValidClaimAndAbsorbCaller(ISetToken _setToken) { + require(_isValidClaimAndAbsorbCaller(_setToken), "Must be valid AirdropModule absorb and ClaimModule claim caller"); + _; + } + + /* ============ State Variables ============ */ + + // Instance of AirdropModule + IAirdropModule public immutable airdropModule; + + // Instance of ClaimModule + IClaimModule public immutable claimModule; + + // Instance of IntegrationRegistry + IIntegrationRegistry public immutable integrationRegistry; + + /* ============ Constructor ============ */ + + /** + * Instantiate with ManagerCore, AirdropModule, ClaimModule, and Controller addresses. + * + * @param _managerCore Address of ManagerCore contract + * @param _airdropModule Address of AirdropModule contract + * @param _claimModule Address of ClaimModule contract + * @param _integrationRegistry Address of IntegrationRegistry contract + */ + constructor( + IManagerCore _managerCore, + IAirdropModule _airdropModule, + IClaimModule _claimModule, + IIntegrationRegistry _integrationRegistry + ) + public + BaseGlobalExtension(_managerCore) + { + airdropModule = _airdropModule; + claimModule = _claimModule; + integrationRegistry = _integrationRegistry; + } + + /* ============ External Functions ============ */ + + /** + * ANYONE CALLABLE: Distributes airdrop fees accrued to the DelegatedManager. Calculates fees for + * owner and methodologist, and sends to owner fee recipient and methodologist respectively. + * + * @param _setToken Address of SetToken + * @param _token Address of token to distribute + */ + function distributeFees( + ISetToken _setToken, + IERC20 _token + ) + public + { + IDelegatedManager delegatedManager = _manager(_setToken); + + uint256 totalFees = _token.balanceOf(address(delegatedManager)); + + address methodologist = delegatedManager.methodologist(); + address ownerFeeRecipient = delegatedManager.ownerFeeRecipient(); + + uint256 ownerTake = totalFees.preciseMul(delegatedManager.ownerFeeSplit()); + uint256 methodologistTake = totalFees.sub(ownerTake); + + if (ownerTake > 0) { + delegatedManager.transferTokens(address(_token), ownerFeeRecipient, ownerTake); + } + + if (methodologistTake > 0) { + delegatedManager.transferTokens(address(_token), methodologist, methodologistTake); + } + + emit FeesDistributed( + address(_setToken), + address(_token), + ownerFeeRecipient, + methodologist, + ownerTake, + methodologistTake + ); + } + + /** + * ONLY OWNER: Initializes AirdropModule on the SetToken associated with the DelegatedManager. + * + * @param _delegatedManager Instance of the DelegatedManager to initialize the AirdropModule for + * @param _airdropSettings Struct of airdrop setting for Set including accepted airdrops, feeRecipient, + * airdropFee, and indicating if anyone can call an absorb + */ + function initializeAirdropModule( + IDelegatedManager _delegatedManager, + IAirdropModule.AirdropSettings memory _airdropSettings + ) + external + onlyOwnerAndValidManager(_delegatedManager) + { + _initializeAirdropModule(_delegatedManager.setToken(), _delegatedManager, _airdropSettings); + } + + /** + * ONLY OWNER: Initializes ClaimModule on the SetToken associated with the DelegatedManager. + * + * @param _delegatedManager Instance of the DelegatedManager to initialize the ClaimModule for + * @param _anyoneClaim Boolean indicating if anyone can claim or just manager + * @param _rewardPools Addresses of rewardPools that identifies the contract governing claims. Maps to same index integrationNames + * @param _integrationNames Human-readable names matching adapter used to collect claim on pool. Maps to same index in rewardPools + */ + function initializeClaimModule( + IDelegatedManager _delegatedManager, + bool _anyoneClaim, + address[] calldata _rewardPools, + string[] calldata _integrationNames + ) + external + onlyOwnerAndValidManager(_delegatedManager) + { + _initializeClaimModule(_delegatedManager.setToken(), _delegatedManager, _anyoneClaim, _rewardPools, _integrationNames); + } + + /** + * ONLY OWNER: Initializes ClaimExtension to the DelegatedManager. + * + * @param _delegatedManager Instance of the DelegatedManager to initialize + */ + function initializeExtension(IDelegatedManager _delegatedManager) external onlyOwnerAndValidManager(_delegatedManager) { + ISetToken setToken = _delegatedManager.setToken(); + + _initializeExtension(setToken, _delegatedManager); + + emit ClaimExtensionInitialized(address(setToken), address(_delegatedManager)); + } + + /** + * ONLY OWNER: Initializes ClaimExtension to the DelegatedManager and AirdropModule and ClaimModule to the SetToken + * + * @param _delegatedManager Instance of the DelegatedManager to initialize + * @param _airdropSettings Struct of airdrop setting for Set including accepted airdrops, feeRecipient, + * airdropFee, and indicating if anyone can call an absorb + * @param _anyoneClaim Boolean indicating if anyone can claim or just manager + * @param _rewardPools Addresses of rewardPools that identifies the contract governing claims. Maps to same index integrationNames + * @param _integrationNames Human-readable names matching adapter used to collect claim on pool. Maps to same index in rewardPools + */ + function initializeModulesAndExtension( + IDelegatedManager _delegatedManager, + IAirdropModule.AirdropSettings memory _airdropSettings, + bool _anyoneClaim, + address[] calldata _rewardPools, + string[] calldata _integrationNames + ) + external + onlyOwnerAndValidManager(_delegatedManager) + { + ISetToken setToken = _delegatedManager.setToken(); + + _initializeExtension(setToken, _delegatedManager); + _initializeAirdropModule(_delegatedManager.setToken(), _delegatedManager, _airdropSettings); + _initializeClaimModule(_delegatedManager.setToken(), _delegatedManager, _anyoneClaim, _rewardPools, _integrationNames); + + emit ClaimExtensionInitialized(address(setToken), address(_delegatedManager)); + } + + /** + * ONLY MANAGER: Remove an existing SetToken and DelegatedManager tracked by the ClaimExtension + */ + function removeExtension() external override { + IDelegatedManager delegatedManager = IDelegatedManager(msg.sender); + ISetToken setToken = delegatedManager.setToken(); + + _removeExtension(setToken, delegatedManager); + } + + /** + * ONLY VALID ABSORB CALLER: Absorb passed tokens into respective positions. If airdropFee defined, send portion to feeRecipient + * and portion to protocol feeRecipient address. Callable only by operator unless set anyoneAbsorb is true on the AirdropModule. + * + * @param _setToken Address of SetToken + * @param _tokens Array of tokens to absorb + */ + function batchAbsorb( + ISetToken _setToken, + address[] memory _tokens + ) + external + onlyValidAbsorbCaller(_setToken) + onlyAllowedAssets(_setToken, _tokens) + { + _batchAbsorb(_setToken, _tokens); + } + + /** + * ONLY VALID ABSORB CALLER: Absorb specified token into position. If airdropFee defined, send portion to feeRecipient and portion to + * protocol feeRecipient address. Callable only by operator unless anyoneAbsorb is true on the AirdropModule. + * + * @param _setToken Address of SetToken + * @param _token Address of token to absorb + */ + function absorb( + ISetToken _setToken, + IERC20 _token + ) + external + onlyValidAbsorbCaller(_setToken) + onlyAllowedAsset(_setToken, address(_token)) + { + _absorb(_setToken, _token); + } + + /** + * ONLY OWNER: Adds new tokens to be added to positions when absorb is called. + * + * @param _setToken Address of SetToken + * @param _airdrop Component to add to airdrop list + */ + function addAirdrop( + ISetToken _setToken, + IERC20 _airdrop + ) + external + onlyOwner(_setToken) + { + bytes memory callData = abi.encodeWithSelector( + IAirdropModule.addAirdrop.selector, + _setToken, + _airdrop + ); + _invokeManager(_manager(_setToken), address(airdropModule), callData); + } + + /** + * ONLY OWNER: Removes tokens from list to be absorbed. + * + * @param _setToken Address of SetToken + * @param _airdrop Component to remove from airdrop list + */ + function removeAirdrop( + ISetToken _setToken, + IERC20 _airdrop + ) + external + onlyOwner(_setToken) + { + bytes memory callData = abi.encodeWithSelector( + IAirdropModule.removeAirdrop.selector, + _setToken, + _airdrop + ); + _invokeManager(_manager(_setToken), address(airdropModule), callData); + } + + /** + * ONLY OWNER: Update whether manager allows other addresses to call absorb. + * + * @param _setToken Address of SetToken + */ + function updateAnyoneAbsorb( + ISetToken _setToken, + bool _anyoneAbsorb + ) + external + onlyOwner(_setToken) + { + bytes memory callData = abi.encodeWithSelector( + IAirdropModule.updateAnyoneAbsorb.selector, + _setToken, + _anyoneAbsorb + ); + _invokeManager(_manager(_setToken), address(airdropModule), callData); + } + + /** + * ONLY OWNER: Update address AirdropModule manager fees are sent to. + * + * @param _setToken Address of SetToken + * @param _newFeeRecipient Address of new fee recipient + */ + function updateAirdropFeeRecipient( + ISetToken _setToken, + address _newFeeRecipient + ) + external + onlyOwner(_setToken) + { + bytes memory callData = abi.encodeWithSelector( + IAirdropModule.updateFeeRecipient.selector, + _setToken, + _newFeeRecipient + ); + _invokeManager(_manager(_setToken), address(airdropModule), callData); + } + + /** + * ONLY OWNER: Update airdrop fee percentage. + * + * @param _setToken Address of SetToken + * @param _newFee Percentage, in preciseUnits, of new airdrop fee (1e16 = 1%) + */ + function updateAirdropFee( + ISetToken _setToken, + uint256 _newFee + ) + external + onlyOwner(_setToken) + { + bytes memory callData = abi.encodeWithSelector( + IAirdropModule.updateAirdropFee.selector, + _setToken, + _newFee + ); + _invokeManager(_manager(_setToken), address(airdropModule), callData); + } + + /** + * ONLY VALID CLAIM AND ABSORB CALLER: Claim the rewards available on the rewardPool for the specified claim integration and absorb + * the reward token into position. If airdropFee defined, send portion to feeRecipient and portion to protocol feeRecipient address. + * Callable only by operator unless anyoneAbsorb on the AirdropModule and anyoneClaim on the ClaimModule are true. + * + * @param _setToken Address of SetToken + * @param _rewardPool Address of the rewardPool that identifies the contract governing claims + * @param _integrationName ID of claim module integration (mapping on integration registry) + */ + function claimAndAbsorb( + ISetToken _setToken, + address _rewardPool, + string calldata _integrationName + ) + external + onlyValidClaimAndAbsorbCaller(_setToken) + { + IERC20 rewardsToken = _getAndValidateRewardsToken(_setToken, _rewardPool, _integrationName); + + _claim(_setToken, _rewardPool, _integrationName); + + _absorb(_setToken, rewardsToken); + } + + /** + * ONLY VALID CLAIM AND ABSORB CALLER: Claims rewards on all the passed rewardPool/claim integration pairs and absorb the reward tokens + * into positions. If airdropFee defined, send portion of each reward token to feeRecipient and a portion to protocol feeRecipient address. + * Callable only by operator unless anyoneAbsorb on the AirdropModule and anyoneClaim on the ClaimModule are true. + * + * @param _setToken Address of SetToken + * @param _rewardPools Addresses of rewardPools that identifies the contract governing claims. Maps to same index integrationNames + * @param _integrationNames Human-readable names matching adapter used to collect claim on pool. Maps to same index in rewardPools + */ + function batchClaimAndAbsorb( + ISetToken _setToken, + address[] calldata _rewardPools, + string[] calldata _integrationNames + ) + external + onlyValidClaimAndAbsorbCaller(_setToken) + { + address[] storage rewardsTokens; + uint256 numPools = _rewardPools.length; + for (uint256 i = 0; i < numPools; i++) { + IERC20 token = _getAndValidateRewardsToken(_setToken, _rewardPools[i], _integrationNames[i]); + rewardsTokens.push(address(token)); + } + + _batchClaim(_setToken, _rewardPools, _integrationNames); + + _batchAbsorb(_setToken, rewardsTokens); + } + + /** + * ONLY OWNER: Update whether manager allows other addresses to call claim. + * + * @param _setToken Address of SetToken + */ + function updateAnyoneClaim( + ISetToken _setToken, + bool _anyoneClaim + ) + external + onlyOwner(_setToken) + { + bytes memory callData = abi.encodeWithSelector( + IClaimModule.updateAnyoneClaim.selector, + _setToken, + _anyoneClaim + ); + _invokeManager(_manager(_setToken), address(claimModule), callData); + } + + /** + * ONLY OWNER: Adds a new claim integration for an existent rewardPool. If rewardPool doesn't have existing + * claims then rewardPool is added to rewardPoolList. The claim integration is associated to an adapter that + * provides the functionality to claim the rewards for a specific token. + * + * @param _setToken Address of SetToken + * @param _rewardPool Address of the rewardPool that identifies the contract governing claims + * @param _integrationName ID of claim module integration (mapping on integration registry) + */ + function addClaim( + ISetToken _setToken, + address _rewardPool, + string calldata _integrationName + ) + external + onlyOwner(_setToken) + { + bytes memory callData = abi.encodeWithSelector( + IClaimModule.addClaim.selector, + _setToken, + _rewardPool, + _integrationName + ); + _invokeManager(_manager(_setToken), address(claimModule), callData); + } + + /** + * ONLY OWNER: Adds a new rewardPool to the list to perform claims for the SetToken indicating the list of + * claim integrations. Each claim integration is associated to an adapter that provides the functionality to claim + * the rewards for a specific token. + * + * @param _setToken Address of SetToken + * @param _rewardPools Addresses of rewardPools that identifies the contract governing claims. Maps to same + * index integrationNames + * @param _integrationNames Human-readable names matching adapter used to collect claim on pool. Maps to same index + * in rewardPools + */ + function batchAddClaim( + ISetToken _setToken, + address[] calldata _rewardPools, + string[] calldata _integrationNames + ) + external + onlyOwner(_setToken) + { + bytes memory callData = abi.encodeWithSelector( + IClaimModule.batchAddClaim.selector, + _setToken, + _rewardPools, + _integrationNames + ); + _invokeManager(_manager(_setToken), address(claimModule), callData); + } + + /** + * ONLY OWNER: Removes a claim integration from an existent rewardPool. If no claim remains for reward pool then + * reward pool is removed from rewardPoolList. + * + * @param _setToken Address of SetToken + * @param _rewardPool Address of the rewardPool that identifies the contract governing claims + * @param _integrationName ID of claim module integration (mapping on integration registry) + */ + function removeClaim( + ISetToken _setToken, + address _rewardPool, + string calldata _integrationName + ) + external + onlyOwner(_setToken) + { + bytes memory callData = abi.encodeWithSelector( + IClaimModule.removeClaim.selector, + _setToken, + _rewardPool, + _integrationName + ); + _invokeManager(_manager(_setToken), address(claimModule), callData); + } + + /** + * ONLY OWNER: Batch removes claims from SetToken's settings. + * + * @param _setToken Address of SetToken + * @param _rewardPools Addresses of rewardPools that identifies the contract governing claims. Maps to same index + * integrationNames + * @param _integrationNames Human-readable names matching adapter used to collect claim on pool. Maps to same index in + * rewardPools + */ + function batchRemoveClaim( + ISetToken _setToken, + address[] calldata _rewardPools, + string[] calldata _integrationNames + ) + external + onlyOwner(_setToken) + { + bytes memory callData = abi.encodeWithSelector( + IClaimModule.batchRemoveClaim.selector, + _setToken, + _rewardPools, + _integrationNames + ); + _invokeManager(_manager(_setToken), address(claimModule), callData); + } + + + /* ============ Internal Functions ============ */ + + /** + * Internal function to initialize AirdropModule on the SetToken associated with the DelegatedManager. + * + * @param _setToken Instance of the SetToken corresponding to the DelegatedManager + * @param _delegatedManager Instance of the DelegatedManager to initialize the AirdropModule for + * @param _airdropSettings Struct of airdrop setting for Set including accepted airdrops, feeRecipient, + * airdropFee, and indicating if anyone can call an absorb + */ + function _initializeAirdropModule( + ISetToken _setToken, + IDelegatedManager _delegatedManager, + IAirdropModule.AirdropSettings memory _airdropSettings + ) + internal + { + bytes memory callData = abi.encodeWithSelector( + IAirdropModule.initialize.selector, + _setToken, + _airdropSettings + ); + _invokeManager(_delegatedManager, address(airdropModule), callData); + } + + /** + * Internal function to initialize ClaimModule on the SetToken associated with the DelegatedManager. + * + * @param _setToken Instance of the SetToken corresponding to the DelegatedManager + * @param _delegatedManager Instance of the DelegatedManager to initialize the ClaimModule for + * @param _anyoneClaim Boolean indicating if anyone can claim or just manager + * @param _rewardPools Addresses of rewardPools that identifies the contract governing claims. Maps to same index integrationNames + * @param _integrationNames Human-readable names matching adapter used to collect claim on pool. Maps to same index in rewardPools + */ + function _initializeClaimModule( + ISetToken _setToken, + IDelegatedManager _delegatedManager, + bool _anyoneClaim, + address[] calldata _rewardPools, + string[] calldata _integrationNames + ) + internal + { + bytes memory callData = abi.encodeWithSelector( + IClaimModule.initialize.selector, + _setToken, + _anyoneClaim, + _rewardPools, + _integrationNames + ); + _invokeManager(_delegatedManager, address(claimModule), callData); + } + + /** + * Must have all assets on asset allow list or useAssetAllowlist to be false + */ + function _validateAllowedAssets(ISetToken _setToken, address[] memory _assets) internal view { + IDelegatedManager manager = _manager(_setToken); + if (manager.useAssetAllowlist()) { + uint256 assetsLength = _assets.length; + for (uint i = 0; i < assetsLength; i++) { + require(manager.assetAllowlist(_assets[i]), "Must be allowed asset"); + } + } + } + + /** + * AirdropModule anyoneAbsorb setting must be true or must be operator + * + * @param _setToken Instance of the SetToken corresponding to the DelegatedManager + */ + function _isValidAbsorbCaller(ISetToken _setToken) internal view returns(bool) { + return airdropModule.airdropSettings(_setToken).anyoneAbsorb || _manager(_setToken).operatorAllowlist(msg.sender); + } + + /** + * Must be operator or must have both AirdropModule anyoneAbsorb and ClaimModule anyoneClaim + * + * @param _setToken Instance of the SetToken corresponding to the DelegatedManager + */ + function _isValidClaimAndAbsorbCaller(ISetToken _setToken) internal view returns(bool) { + return ( + (claimModule.anyoneClaim(_setToken) && airdropModule.airdropSettings(_setToken).anyoneAbsorb) + || _manager(_setToken).operatorAllowlist(msg.sender) + ); + } + + /** + * Absorb specified token into position. If airdropFee defined, send portion to feeRecipient and portion to protocol feeRecipient address. + * + * @param _setToken Address of SetToken + * @param _token Address of token to absorb + */ + function _absorb(ISetToken _setToken, IERC20 _token) internal { + bytes memory callData = abi.encodeWithSelector( + IAirdropModule.absorb.selector, + _setToken, + _token + ); + _invokeManager(_manager(_setToken), address(airdropModule), callData); + } + + /** + * Absorb passed tokens into respective positions. If airdropFee defined, send portion to feeRecipient and portion to protocol feeRecipient address. + * + * @param _setToken Address of SetToken + * @param _tokens Array of tokens to absorb + */ + function _batchAbsorb(ISetToken _setToken, address[] memory _tokens) internal { + bytes memory callData = abi.encodeWithSelector( + IAirdropModule.batchAbsorb.selector, + _setToken, + _tokens + ); + _invokeManager(_manager(_setToken), address(airdropModule), callData); + } + + /** + * Claim the rewards available on the rewardPool for the specified claim integration and absorb the reward token into position. + * + * @param _setToken Address of SetToken + * @param _rewardPool Address of the rewardPool that identifies the contract governing claims + * @param _integrationName ID of claim module integration (mapping on integration registry) + */ + function _claim(ISetToken _setToken, address _rewardPool, string calldata _integrationName) internal { + bytes memory callData = abi.encodeWithSelector( + IClaimModule.claim.selector, + _setToken, + _rewardPool, + _integrationName + ); + _invokeManager(_manager(_setToken), address(claimModule), callData); + } + + /** + * Claims rewards on all the passed rewardPool/claim integration pairs and absorb the reward tokens into positions. + * + * @param _setToken Address of SetToken + * @param _rewardPools Addresses of rewardPools that identifies the contract governing claims. Maps to same index integrationNames + * @param _integrationNames Human-readable names matching adapter used to collect claim on pool. Maps to same index in rewardPools + */ + function _batchClaim(ISetToken _setToken, address[] calldata _rewardPools, string[] calldata _integrationNames) internal { + bytes memory callData = abi.encodeWithSelector( + IClaimModule.batchClaim.selector, + _setToken, + _rewardPools, + _integrationNames + ); + _invokeManager(_manager(_setToken), address(claimModule), callData); + } + + /** + * Get the rewards token from the rewardPool and integrationName and check if it is an allowed asset on the DelegatedManager + * + * @param _setToken Address of SetToken + * @param _rewardPool Address of the rewardPool that identifies the contract governing claims + * @param _integrationName ID of claim module integration (mapping on integration registry) + */ + function _getAndValidateRewardsToken(ISetToken _setToken, address _rewardPool, string calldata _integrationName) internal view returns(IERC20) { + IClaimAdapter adapter = IClaimAdapter(integrationRegistry.getIntegrationAdapter(address(claimModule), _integrationName)); + IERC20 rewardsToken = adapter.getTokenAddress(_rewardPool); + require(_manager(_setToken).isAllowedAsset(address(rewardsToken)), "Must be allowed asset"); + return rewardsToken; + } +} \ No newline at end of file diff --git a/contracts/extensions/WrapExtension.sol b/contracts/extensions/WrapExtension.sol new file mode 100644 index 0000000..5beee34 --- /dev/null +++ b/contracts/extensions/WrapExtension.sol @@ -0,0 +1,264 @@ +/* + Copyright 2022 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 { ISetToken } from "@setprotocol/set-protocol-v2/contracts/interfaces/ISetToken.sol"; +import { IWETH } from "@setprotocol/set-protocol-v2/contracts/interfaces/external/IWETH.sol"; +import { IWrapModuleV2 } from "@setprotocol/set-protocol-v2/contracts/interfaces/IWrapModuleV2.sol"; + +import { BaseGlobalExtension } from "../lib/BaseGlobalExtension.sol"; +import { IDelegatedManager } from "../interfaces/IDelegatedManager.sol"; +import { IManagerCore } from "../interfaces/IManagerCore.sol"; + +/** + * @title WrapExtension + * @author Set Protocol + * + * Smart contract global extension which provides DelegatedManager operator(s) the ability to wrap ERC20 and Ether positions + * via third party protocols. + * + * Some examples of wrap actions include wrapping, DAI to cDAI (Compound) or Dai to aDai (AAVE). + */ +contract WrapExtension is BaseGlobalExtension { + + /* ============ Events ============ */ + + event WrapExtensionInitialized( + address indexed _setToken, + address indexed _delegatedManager + ); + + /* ============ State Variables ============ */ + + // Instance of WrapModuleV2 + IWrapModuleV2 public immutable wrapModule; + + /* ============ Constructor ============ */ + + /** + * Instantiate with ManagerCore address and WrapModuleV2 address. + * + * @param _managerCore Address of ManagerCore contract + * @param _wrapModule Address of WrapModuleV2 contract + */ + constructor( + IManagerCore _managerCore, + IWrapModuleV2 _wrapModule + ) + public + BaseGlobalExtension(_managerCore) + { + wrapModule = _wrapModule; + } + + /* ============ External Functions ============ */ + + /** + * ONLY OWNER: Initializes WrapModuleV2 on the SetToken associated with the DelegatedManager. + * + * @param _delegatedManager Instance of the DelegatedManager to initialize the WrapModuleV2 for + */ + function initializeModule(IDelegatedManager _delegatedManager) external onlyOwnerAndValidManager(_delegatedManager) { + _initializeModule(_delegatedManager.setToken(), _delegatedManager); + } + + /** + * ONLY OWNER: Initializes WrapExtension to the DelegatedManager. + * + * @param _delegatedManager Instance of the DelegatedManager to initialize + */ + function initializeExtension(IDelegatedManager _delegatedManager) external onlyOwnerAndValidManager(_delegatedManager) { + ISetToken setToken = _delegatedManager.setToken(); + + _initializeExtension(setToken, _delegatedManager); + + emit WrapExtensionInitialized(address(setToken), address(_delegatedManager)); + } + + /** + * ONLY OWNER: Initializes WrapExtension to the DelegatedManager and TradeModule to the SetToken + * + * @param _delegatedManager Instance of the DelegatedManager to initialize + */ + function initializeModuleAndExtension(IDelegatedManager _delegatedManager) external onlyOwnerAndValidManager(_delegatedManager){ + ISetToken setToken = _delegatedManager.setToken(); + + _initializeExtension(setToken, _delegatedManager); + _initializeModule(setToken, _delegatedManager); + + emit WrapExtensionInitialized(address(setToken), address(_delegatedManager)); + } + + /** + * ONLY MANAGER: Remove an existing SetToken and DelegatedManager tracked by the WrapExtension + */ + function removeExtension() external override { + IDelegatedManager delegatedManager = IDelegatedManager(msg.sender); + ISetToken setToken = delegatedManager.setToken(); + + _removeExtension(setToken, delegatedManager); + } + + /** + * ONLY OPERATOR: Instructs the SetToken to wrap an underlying asset into a wrappedToken via a specified adapter. + * + * @param _setToken Instance of the SetToken + * @param _underlyingToken Address of the component to be wrapped + * @param _wrappedToken Address of the desired wrapped token + * @param _underlyingUnits Quantity of underlying units in Position units + * @param _integrationName Name of wrap module integration (mapping on integration registry) + * @param _wrapData Arbitrary bytes to pass into the WrapV2Adapter + */ + function wrap( + ISetToken _setToken, + address _underlyingToken, + address _wrappedToken, + uint256 _underlyingUnits, + string calldata _integrationName, + bytes memory _wrapData + ) + external + onlyOperator(_setToken) + onlyAllowedAsset(_setToken, _wrappedToken) + { + bytes memory callData = abi.encodeWithSelector( + IWrapModuleV2.wrap.selector, + _setToken, + _underlyingToken, + _wrappedToken, + _underlyingUnits, + _integrationName, + _wrapData + ); + _invokeManager(_manager(_setToken), address(wrapModule), callData); + } + + /** + * ONLY OPERATOR: Instructs the SetToken to wrap Ether into a wrappedToken via a specified adapter. Since SetTokens + * only hold WETH, in order to support protocols that collateralize with Ether the SetToken's WETH must be unwrapped + * first before sending to the external protocol. + * + * @param _setToken Instance of the SetToken + * @param _wrappedToken Address of the desired wrapped token + * @param _underlyingUnits Quantity of underlying units in Position units + * @param _integrationName Name of wrap module integration (mapping on integration registry) + * @param _wrapData Arbitrary bytes to pass into the WrapV2Adapter + */ + function wrapWithEther( + ISetToken _setToken, + address _wrappedToken, + uint256 _underlyingUnits, + string calldata _integrationName, + bytes memory _wrapData + ) + external + onlyOperator(_setToken) + onlyAllowedAsset(_setToken, _wrappedToken) + { + bytes memory callData = abi.encodeWithSelector( + IWrapModuleV2.wrapWithEther.selector, + _setToken, + _wrappedToken, + _underlyingUnits, + _integrationName, + _wrapData + ); + _invokeManager(_manager(_setToken), address(wrapModule), callData); + } + + /** + * ONLY OPERATOR: Instructs the SetToken to unwrap a wrapped asset into its underlying via a specified adapter. + * + * @param _setToken Instance of the SetToken + * @param _underlyingToken Address of the underlying asset + * @param _wrappedToken Address of the component to be unwrapped + * @param _wrappedUnits Quantity of wrapped tokens in Position units + * @param _integrationName ID of wrap module integration (mapping on integration registry) + * @param _unwrapData Arbitrary bytes to pass into the WrapV2Adapter + */ + function unwrap( + ISetToken _setToken, + address _underlyingToken, + address _wrappedToken, + uint256 _wrappedUnits, + string calldata _integrationName, + bytes memory _unwrapData + ) + external + onlyOperator(_setToken) + onlyAllowedAsset(_setToken, _underlyingToken) + { + bytes memory callData = abi.encodeWithSelector( + IWrapModuleV2.unwrap.selector, + _setToken, + _underlyingToken, + _wrappedToken, + _wrappedUnits, + _integrationName, + _unwrapData + ); + _invokeManager(_manager(_setToken), address(wrapModule), callData); + } + + /** + * ONLY OPERATOR: Instructs the SetToken to unwrap a wrapped asset collateralized by Ether into Wrapped Ether. Since + * external protocol will send back Ether that Ether must be Wrapped into WETH in order to be accounted for by SetToken. + * + * @param _setToken Instance of the SetToken + * @param _wrappedToken Address of the component to be unwrapped + * @param _wrappedUnits Quantity of wrapped tokens in Position units + * @param _integrationName ID of wrap module integration (mapping on integration registry) + * @param _unwrapData Arbitrary bytes to pass into the WrapV2Adapter + */ + function unwrapWithEther( + ISetToken _setToken, + address _wrappedToken, + uint256 _wrappedUnits, + string calldata _integrationName, + bytes memory _unwrapData + ) + external + onlyOperator(_setToken) + onlyAllowedAsset(_setToken, address(wrapModule.weth())) + { + bytes memory callData = abi.encodeWithSelector( + IWrapModuleV2.unwrapWithEther.selector, + _setToken, + _wrappedToken, + _wrappedUnits, + _integrationName, + _unwrapData + ); + _invokeManager(_manager(_setToken), address(wrapModule), callData); + } + + /* ============ Internal Functions ============ */ + + /** + * Internal function to initialize WrapModuleV2 on the SetToken associated with the DelegatedManager. + * + * @param _setToken Instance of the SetToken corresponding to the DelegatedManager + * @param _delegatedManager Instance of the DelegatedManager to initialize the WrapModuleV2 for + */ + function _initializeModule(ISetToken _setToken, IDelegatedManager _delegatedManager) internal { + bytes memory callData = abi.encodeWithSelector(IWrapModuleV2.initialize.selector, _setToken); + _invokeManager(_delegatedManager, address(wrapModule), callData); + } +} \ No newline at end of file diff --git a/contracts/interfaces/IDelegatedManager.sol b/contracts/interfaces/IDelegatedManager.sol index d646aec..4f544f8 100644 --- a/contracts/interfaces/IDelegatedManager.sol +++ b/contracts/interfaces/IDelegatedManager.sol @@ -33,7 +33,7 @@ interface IDelegatedManager { function updateOwnerFeeRecipient(address _newFeeRecipient) external; function setMethodologist(address _newMethodologist) external; - + function transferOwnership(address _owner) external; function setToken() external view returns(ISetToken); @@ -41,6 +41,7 @@ interface IDelegatedManager { function methodologist() external view returns(address); function operatorAllowlist(address _operator) external view returns(bool); function assetAllowlist(address _asset) external view returns(bool); + function useAssetAllowlist() external view returns(bool); function isAllowedAsset(address _asset) external view returns(bool); function isPendingExtension(address _extension) external view returns(bool); function isInitializedExtension(address _extension) external view returns(bool); diff --git a/package.json b/package.json index 49137c9..8331da2 100644 --- a/package.json +++ b/package.json @@ -91,7 +91,7 @@ "web3": "^1.2.9" }, "dependencies": { - "@setprotocol/set-protocol-v2": "0.10.0-hhat.1", + "@setprotocol/set-protocol-v2": "^0.10.3-hhat.1", "@uniswap/v3-sdk": "^3.5.1", "ethers": "5.5.2", "fs-extra": "^5.0.0", diff --git a/test/extensions/claimExtension.spec.ts b/test/extensions/claimExtension.spec.ts new file mode 100644 index 0000000..b1dd55a --- /dev/null +++ b/test/extensions/claimExtension.spec.ts @@ -0,0 +1,2155 @@ +import "module-alias/register"; + +import { BigNumber, Contract } from "ethers"; +import { Address, Account } from "@utils/types"; +import { ADDRESS_ZERO, ZERO, PRECISE_UNIT } from "@utils/constants"; +import { + DelegatedManager, + ClaimExtension, + ManagerCore, +} from "@utils/contracts/index"; +import { + SetToken, + AirdropModule, + ClaimModule, + ClaimAdapterMock +} from "@setprotocol/set-protocol-v2/utils/contracts"; +import DeployHelper from "@utils/deploys"; +import { + addSnapshotBeforeRestoreAfterEach, + ether, + getAccounts, + getWaffleExpect, + getRandomAddress, + preciseMul, + preciseDiv +} from "@utils/index"; +import { SystemFixture } from "@setprotocol/set-protocol-v2/dist/utils/fixtures"; +import { getSystemFixture, getRandomAccount } from "@setprotocol/set-protocol-v2/dist/utils/test"; +import { ContractTransaction } from "ethers"; +import { AirdropSettings } from "@setprotocol/set-protocol-v2/dist/utils/types"; + +const expect = getWaffleExpect(); + +describe("ClaimExtension", () => { + let owner: Account; + let methodologist: Account; + let operator: Account; + let ownerFeeRecipient: Account; + let factory: Account; + + let deployer: DeployHelper; + let setToken: SetToken; + let setV2Setup: SystemFixture; + + let managerCore: ManagerCore; + let delegatedManager: DelegatedManager; + let claimExtension: ClaimExtension; + let ownerFeeSplit: BigNumber; + + let airdropModule: AirdropModule; + let claimModule: ClaimModule; + let claimAdapterMockOne: ClaimAdapterMock; + let claimAdapterMockTwo: ClaimAdapterMock; + const claimAdapterMockIntegrationNameOne: string = "MOCK_CLAIM_ONE"; + const claimAdapterMockIntegrationNameTwo: string = "MOCK_CLAIM_TWO"; + + before(async () => { + [ + owner, + methodologist, + operator, + ownerFeeRecipient, + factory + ] = await getAccounts(); + + deployer = new DeployHelper(owner.wallet); + + setV2Setup = getSystemFixture(owner.address); + await setV2Setup.initialize(); + + airdropModule = await deployer.setDeployer.modules.deployAirdropModule(setV2Setup.controller.address); + await setV2Setup.controller.addModule(airdropModule.address); + + claimModule = await deployer.setDeployer.modules.deployClaimModule(setV2Setup.controller.address); + await setV2Setup.controller.addModule(claimModule.address); + claimAdapterMockOne = await deployer.setDeployer.mocks.deployClaimAdapterMock(); + await setV2Setup.integrationRegistry.addIntegration( + claimModule.address, + claimAdapterMockIntegrationNameOne, + claimAdapterMockOne.address + ); + claimAdapterMockTwo = await deployer.setDeployer.mocks.deployClaimAdapterMock(); + await setV2Setup.integrationRegistry.addIntegration( + claimModule.address, + claimAdapterMockIntegrationNameTwo, + claimAdapterMockTwo.address + ); + + managerCore = await deployer.managerCore.deployManagerCore(); + + claimExtension = await deployer.globalExtensions.deployClaimExtension( + managerCore.address, + airdropModule.address, + claimModule.address, + setV2Setup.integrationRegistry.address + ); + + setToken = await setV2Setup.createSetToken( + [setV2Setup.weth.address], + [ether(1)], + [setV2Setup.issuanceModule.address, airdropModule.address, claimModule.address] + ); + + await setV2Setup.issuanceModule.initialize(setToken.address, ADDRESS_ZERO); + + delegatedManager = await deployer.manager.deployDelegatedManager( + setToken.address, + factory.address, + methodologist.address, + [claimExtension.address], + [operator.address], + [setV2Setup.usdc.address, setV2Setup.weth.address], + true + ); + + ownerFeeSplit = ether(0.6); + await delegatedManager.connect(owner.wallet).updateOwnerFeeSplit(ownerFeeSplit); + await delegatedManager.connect(methodologist.wallet).updateOwnerFeeSplit(ownerFeeSplit); + await delegatedManager.connect(owner.wallet).updateOwnerFeeRecipient(ownerFeeRecipient.address); + + await setToken.setManager(delegatedManager.address); + + await managerCore.initialize([claimExtension.address], [factory.address]); + await managerCore.connect(factory.wallet).addManager(delegatedManager.address); + }); + + addSnapshotBeforeRestoreAfterEach(); + + describe("#constructor", async () => { + let subjectManagerCore: Address; + let subjectAirdropModule: Address; + let subjectClaimModule: Address; + let subjectIntegrationRegistry: Address; + + beforeEach(async () => { + subjectManagerCore = managerCore.address; + subjectAirdropModule = airdropModule.address; + subjectClaimModule = claimModule.address; + subjectIntegrationRegistry = setV2Setup.integrationRegistry.address; + }); + + async function subject(): Promise { + return await deployer.globalExtensions.deployClaimExtension( + subjectManagerCore, + subjectAirdropModule, + subjectClaimModule, + subjectIntegrationRegistry + ); + } + + it("should set the correct AirdropModule address", async () => { + const claimExtension = await subject(); + + const storedModule = await claimExtension.airdropModule(); + expect(storedModule).to.eq(subjectAirdropModule); + }); + + it("should set the correct ClaimModule address", async () => { + const claimExtension = await subject(); + + const storedModule = await claimExtension.claimModule(); + expect(storedModule).to.eq(subjectClaimModule); + }); + + it("should set the correct IntegrationRegistry address", async () => { + const claimExtension = await subject(); + + const storedIntegrationRegistry = await claimExtension.integrationRegistry(); + expect(storedIntegrationRegistry).to.eq(subjectIntegrationRegistry); + }); + }); + + describe("#initializeAirdropModule", async () => { + let airdrops: Address[]; + let airdropFee: BigNumber; + let anyoneAbsorb: boolean; + let airdropFeeRecipient: Address; + + let subjectDelegatedManager: Address; + let subjectAirdropSettings: AirdropSettings; + let subjectCaller: Account; + + before(async () => { + airdrops = [setV2Setup.usdc.address, setV2Setup.weth.address]; + airdropFee = ether(.2); + anyoneAbsorb = true; + airdropFeeRecipient = delegatedManager.address; + }); + + beforeEach(async () => { + await claimExtension.connect(owner.wallet).initializeExtension(delegatedManager.address); + + subjectDelegatedManager = delegatedManager.address; + subjectAirdropSettings = { + airdrops, + feeRecipient: airdropFeeRecipient, + airdropFee, + anyoneAbsorb + } as AirdropSettings; + subjectCaller = owner; + }); + + async function subject(): Promise { + return claimExtension.connect(subjectCaller.wallet).initializeAirdropModule( + subjectDelegatedManager, + subjectAirdropSettings + ); + } + + it("should initialize the AirdropModule on the SetToken", async () => { + await subject(); + + const isModuleInitialized: Boolean = await setToken.isInitializedModule(airdropModule.address); + expect(isModuleInitialized).to.eq(true); + }); + + it("should set the correct airdrops and anyoneAbsorb fields", async () => { + await subject(); + + const airdropSettings: any = await airdropModule.airdropSettings(setToken.address); + const airdrops = await airdropModule.getAirdrops(setToken.address); + + expect(JSON.stringify(airdrops)).to.eq(JSON.stringify(airdrops)); + expect(airdropSettings.airdropFee).to.eq(airdropFee); + expect(airdropSettings.anyoneAbsorb).to.eq(anyoneAbsorb); + }); + + it("should set the correct isAirdrop state", async () => { + await subject(); + + const wethIsAirdrop = await airdropModule.isAirdrop(setToken.address, setV2Setup.weth.address); + const usdcIsAirdrop = await airdropModule.isAirdrop(setToken.address, setV2Setup.usdc.address); + + expect(wethIsAirdrop).to.be.true; + expect(usdcIsAirdrop).to.be.true; + }); + + describe("when the sender is not the owner", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be owner"); + }); + }); + + describe("when the module is not pending or initialized", async () => { + beforeEach(async () => { + await subject(); + await delegatedManager.connect(owner.wallet).removeExtensions([claimExtension.address]); + await delegatedManager.connect(owner.wallet).setManager(owner.address); + await setToken.connect(owner.wallet).removeModule(airdropModule.address); + await setToken.connect(owner.wallet).setManager(delegatedManager.address); + await delegatedManager.connect(owner.wallet).addExtensions([claimExtension.address]); + await claimExtension.connect(owner.wallet).initializeExtension(delegatedManager.address); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be pending initialization"); + }); + }); + + describe("when the module is already initialized", async () => { + beforeEach(async () => { + await subject(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be pending initialization"); + }); + }); + + describe("when the extension is not pending or initialized", async () => { + beforeEach(async () => { + await delegatedManager.connect(owner.wallet).removeExtensions([claimExtension.address]); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be initialized extension"); + }); + }); + + describe("when the extension is pending", async () => { + beforeEach(async () => { + await delegatedManager.connect(owner.wallet).removeExtensions([claimExtension.address]); + await delegatedManager.connect(owner.wallet).addExtensions([claimExtension.address]); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be initialized extension"); + }); + }); + + describe("when the manager is not a ManagerCore-enabled manager", async () => { + beforeEach(async () => { + await managerCore.connect(owner.wallet).removeManager(delegatedManager.address); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be ManagerCore-enabled manager"); + }); + }); + }); + + describe("#initializeClaimModule", async () => { + let subjectDelegatedManager: Address; + let subjectRewardPools: Address[]; + let subjectIntegrations: string[]; + let subjectAnyoneClaim: boolean; + let subjectCaller: Account; + + beforeEach(async () => { + await claimExtension.connect(owner.wallet).initializeExtension(delegatedManager.address); + + subjectDelegatedManager = delegatedManager.address; + subjectRewardPools = [await getRandomAddress(), await getRandomAddress()]; + subjectIntegrations = [claimAdapterMockIntegrationNameOne, claimAdapterMockIntegrationNameTwo]; + subjectAnyoneClaim = true; + subjectCaller = owner; + }); + + async function subject(): Promise { + return claimExtension.connect(subjectCaller.wallet).initializeClaimModule( + subjectDelegatedManager, + subjectAnyoneClaim, + subjectRewardPools, + subjectIntegrations + ); + } + + it("should initialize the ClaimModule on the SetToken", async () => { + await subject(); + + const isModuleInitialized: Boolean = await setToken.isInitializedModule(claimModule.address); + expect(isModuleInitialized).to.eq(true); + }); + + it("should set the anyoneClaim field", async () => { + const anyoneClaimBefore = await claimModule.anyoneClaim(setToken.address); + expect(anyoneClaimBefore).to.eq(false); + + await subject(); + + const anyoneClaim = await claimModule.anyoneClaim(setToken.address); + expect(anyoneClaim).to.eq(true); + }); + + it("should add the rewardPools to the rewardPoolList", async () => { + expect((await claimModule.getRewardPools(setToken.address)).length).to.eq(0); + + await subject(); + + const rewardPools = await claimModule.getRewardPools(setToken.address); + expect(rewardPools[0]).to.eq(subjectRewardPools[0]); + expect(rewardPools[1]).to.eq(subjectRewardPools[1]); + }); + + it("should add all new integrations for the rewardPools", async () => { + await subject(); + + const rewardPoolOneClaims = await claimModule.getRewardPoolClaims(setToken.address, subjectRewardPools[0]); + const rewardPoolTwoClaims = await claimModule.getRewardPoolClaims(setToken.address, subjectRewardPools[1]); + expect(rewardPoolOneClaims[0]).to.eq(claimAdapterMockOne.address); + expect(rewardPoolTwoClaims[0]).to.eq(claimAdapterMockTwo.address); + }); + + describe("when the sender is not the owner", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be owner"); + }); + }); + + describe("when the module is not pending or initialized", async () => { + beforeEach(async () => { + await subject(); + await delegatedManager.connect(owner.wallet).removeExtensions([claimExtension.address]); + await delegatedManager.connect(owner.wallet).setManager(owner.address); + await setToken.connect(owner.wallet).removeModule(claimModule.address); + await setToken.connect(owner.wallet).setManager(delegatedManager.address); + await delegatedManager.connect(owner.wallet).addExtensions([claimExtension.address]); + await claimExtension.connect(owner.wallet).initializeExtension(delegatedManager.address); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be pending initialization"); + }); + }); + + describe("when the module is already initialized", async () => { + beforeEach(async () => { + await subject(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be pending initialization"); + }); + }); + + describe("when the extension is not pending or initialized", async () => { + beforeEach(async () => { + await delegatedManager.connect(owner.wallet).removeExtensions([claimExtension.address]); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be initialized extension"); + }); + }); + + describe("when the extension is pending", async () => { + beforeEach(async () => { + await delegatedManager.connect(owner.wallet).removeExtensions([claimExtension.address]); + await delegatedManager.connect(owner.wallet).addExtensions([claimExtension.address]); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be initialized extension"); + }); + }); + + describe("when the manager is not a ManagerCore-enabled manager", async () => { + beforeEach(async () => { + await managerCore.connect(owner.wallet).removeManager(delegatedManager.address); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be ManagerCore-enabled manager"); + }); + }); + }); + + describe("#initializeExtension", async () => { + let subjectDelegatedManager: Address; + let subjectCaller: Account; + + beforeEach(async () => { + subjectDelegatedManager = delegatedManager.address; + subjectCaller = owner; + }); + + async function subject(): Promise { + return claimExtension.connect(subjectCaller.wallet).initializeExtension(subjectDelegatedManager); + } + + it("should store the correct SetToken and DelegatedManager on the ClaimExtension", async () => { + await subject(); + + const storedDelegatedManager: Address = await claimExtension.setManagers(setToken.address); + expect(storedDelegatedManager).to.eq(delegatedManager.address); + }); + + it("should initialize the ClaimExtension on the DelegatedManager", async () => { + await subject(); + + const isExtensionInitialized: Boolean = await delegatedManager.isInitializedExtension(claimExtension.address); + expect(isExtensionInitialized).to.eq(true); + }); + + it("should emit the correct ClaimExtensionInitialized event", async () => { + await expect(subject()).to.emit(claimExtension, "ClaimExtensionInitialized").withArgs( + setToken.address, + delegatedManager.address + ); + }); + + describe("when the sender is not the owner", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be owner"); + }); + }); + + describe("when the extension is not pending or initialized", async () => { + beforeEach(async () => { + await claimExtension.connect(owner.wallet).initializeExtension(delegatedManager.address); + await delegatedManager.connect(owner.wallet).removeExtensions([claimExtension.address]); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Extension must be pending"); + }); + }); + + describe("when the extension is already initialized", async () => { + beforeEach(async () => { + await claimExtension.connect(owner.wallet).initializeExtension(delegatedManager.address); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Extension must be pending"); + }); + }); + + describe("when the manager is not a ManagerCore-enabled manager", async () => { + beforeEach(async () => { + await managerCore.connect(owner.wallet).removeManager(delegatedManager.address); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be ManagerCore-enabled manager"); + }); + }); + }); + + describe("#initializeModulesAndExtension", async () => { + let airdrops: Address[]; + let airdropFee: BigNumber; + let anyoneAbsorb: boolean; + let airdropFeeRecipient: Address; + + let subjectDelegatedManager: Address; + let subjectAirdropSettings: AirdropSettings; + let subjectRewardPools: Address[]; + let subjectIntegrations: string[]; + let subjectAnyoneClaim: boolean; + let subjectCaller: Account; + + before(async () => { + airdrops = [setV2Setup.usdc.address, setV2Setup.weth.address]; + airdropFee = ether(.2); + anyoneAbsorb = true; + airdropFeeRecipient = delegatedManager.address; + }); + + beforeEach(async () => { + subjectDelegatedManager = delegatedManager.address; + subjectAirdropSettings = { + airdrops, + feeRecipient: airdropFeeRecipient, + airdropFee, + anyoneAbsorb + } as AirdropSettings; + subjectRewardPools = [await getRandomAddress(), await getRandomAddress()]; + subjectIntegrations = [claimAdapterMockIntegrationNameOne, claimAdapterMockIntegrationNameTwo]; + subjectAnyoneClaim = true; + subjectCaller = owner; + }); + + async function subject(): Promise { + return claimExtension.connect(subjectCaller.wallet).initializeModulesAndExtension( + subjectDelegatedManager, + subjectAirdropSettings, + subjectAnyoneClaim, + subjectRewardPools, + subjectIntegrations + ); + } + + it("should initialize the AirdropModule and ClaimModule on the SetToken", async () => { + await subject(); + + const isAirdropModuleInitialized: Boolean = await setToken.isInitializedModule(airdropModule.address); + const isClaimModuleInitialized: Boolean = await setToken.isInitializedModule(claimModule.address); + expect(isAirdropModuleInitialized).to.eq(true); + expect(isClaimModuleInitialized).to.eq(true); + }); + + it("should set the correct airdrops and anyoneAbsorb fields", async () => { + await subject(); + + const airdropSettings: any = await airdropModule.airdropSettings(setToken.address); + const airdrops = await airdropModule.getAirdrops(setToken.address); + + expect(JSON.stringify(airdrops)).to.eq(JSON.stringify(airdrops)); + expect(airdropSettings.airdropFee).to.eq(airdropFee); + expect(airdropSettings.anyoneAbsorb).to.eq(anyoneAbsorb); + }); + + it("should set the correct isAirdrop state", async () => { + await subject(); + + const wethIsAirdrop = await airdropModule.isAirdrop(setToken.address, setV2Setup.weth.address); + const usdcIsAirdrop = await airdropModule.isAirdrop(setToken.address, setV2Setup.usdc.address); + + expect(wethIsAirdrop).to.be.true; + expect(usdcIsAirdrop).to.be.true; + }); + + it("should set the anyoneClaim field", async () => { + const anyoneClaimBefore = await claimModule.anyoneClaim(setToken.address); + expect(anyoneClaimBefore).to.eq(false); + + await subject(); + + const anyoneClaim = await claimModule.anyoneClaim(setToken.address); + expect(anyoneClaim).to.eq(true); + }); + + it("should add the rewardPools to the rewardPoolList", async () => { + expect((await claimModule.getRewardPools(setToken.address)).length).to.eq(0); + + await subject(); + + const rewardPools = await claimModule.getRewardPools(setToken.address); + expect(rewardPools[0]).to.eq(subjectRewardPools[0]); + expect(rewardPools[1]).to.eq(subjectRewardPools[1]); + }); + + it("should add all new integrations for the rewardPools", async () => { + await subject(); + + const rewardPoolOneClaims = await claimModule.getRewardPoolClaims(setToken.address, subjectRewardPools[0]); + const rewardPoolTwoClaims = await claimModule.getRewardPoolClaims(setToken.address, subjectRewardPools[1]); + expect(rewardPoolOneClaims[0]).to.eq(claimAdapterMockOne.address); + expect(rewardPoolTwoClaims[0]).to.eq(claimAdapterMockTwo.address); + }); + + it("should store the correct SetToken and DelegatedManager on the ClaimExtension", async () => { + await subject(); + + const storedDelegatedManager: Address = await claimExtension.setManagers(setToken.address); + expect(storedDelegatedManager).to.eq(delegatedManager.address); + }); + + it("should initialize the ClaimExtension on the DelegatedManager", async () => { + await subject(); + + const isExtensionInitialized: Boolean = await delegatedManager.isInitializedExtension(claimExtension.address); + expect(isExtensionInitialized).to.eq(true); + }); + + it("should emit the correct ClaimExtensionInitialized event", async () => { + await expect(subject()).to.emit(claimExtension, "ClaimExtensionInitialized").withArgs( + setToken.address, + delegatedManager.address + ); + }); + + describe("when the sender is not the owner", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be owner"); + }); + }); + + describe("when the AirdropModule is not pending or initialized", async () => { + beforeEach(async () => { + await claimExtension.connect(owner.wallet).initializeExtension(delegatedManager.address); + await claimExtension.connect(owner.wallet).initializeAirdropModule( + delegatedManager.address, + { + airdrops, + feeRecipient: airdropFeeRecipient, + airdropFee, + anyoneAbsorb + } as AirdropSettings + ); + await delegatedManager.connect(owner.wallet).removeExtensions([claimExtension.address]); + await delegatedManager.connect(owner.wallet).setManager(owner.address); + await setToken.connect(owner.wallet).removeModule(airdropModule.address); + await setToken.connect(owner.wallet).setManager(delegatedManager.address); + await delegatedManager.connect(owner.wallet).addExtensions([claimExtension.address]); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be pending initialization"); + }); + }); + + describe("when the AirdropModule is already initialized", async () => { + beforeEach(async () => { + await claimExtension.connect(owner.wallet).initializeExtension(delegatedManager.address); + await claimExtension.connect(owner.wallet).initializeAirdropModule( + delegatedManager.address, + { + airdrops, + feeRecipient: airdropFeeRecipient, + airdropFee, + anyoneAbsorb + } as AirdropSettings + ); + await delegatedManager.connect(owner.wallet).removeExtensions([claimExtension.address]); + await delegatedManager.connect(owner.wallet).addExtensions([claimExtension.address]); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be pending initialization"); + }); + }); + + describe("when the ClaimModule is not pending or initialized", async () => { + beforeEach(async () => { + await claimExtension.connect(owner.wallet).initializeExtension(delegatedManager.address); + await claimExtension.connect(owner.wallet).initializeClaimModule( + delegatedManager.address, + true, + [await getRandomAddress(), await getRandomAddress()], + [claimAdapterMockIntegrationNameOne, claimAdapterMockIntegrationNameTwo] + ); + await delegatedManager.connect(owner.wallet).removeExtensions([claimExtension.address]); + await delegatedManager.connect(owner.wallet).setManager(owner.address); + await setToken.connect(owner.wallet).removeModule(claimModule.address); + await setToken.connect(owner.wallet).setManager(delegatedManager.address); + await delegatedManager.connect(owner.wallet).addExtensions([claimExtension.address]); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be pending initialization"); + }); + }); + + describe("when the ClaimModule is already initialized", async () => { + beforeEach(async () => { + await claimExtension.connect(owner.wallet).initializeExtension(delegatedManager.address); + await claimExtension.connect(owner.wallet).initializeClaimModule( + delegatedManager.address, + true, + [await getRandomAddress(), await getRandomAddress()], + [claimAdapterMockIntegrationNameOne, claimAdapterMockIntegrationNameTwo] + ); + await delegatedManager.connect(owner.wallet).removeExtensions([claimExtension.address]); + await delegatedManager.connect(owner.wallet).addExtensions([claimExtension.address]); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be pending initialization"); + }); + }); + + describe("when the extension is not pending or initialized", async () => { + beforeEach(async () => { + await claimExtension.connect(owner.wallet).initializeExtension(delegatedManager.address); + await delegatedManager.connect(owner.wallet).removeExtensions([claimExtension.address]); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Extension must be pending"); + }); + }); + + describe("when the extension is already initialized", async () => { + beforeEach(async () => { + await claimExtension.connect(owner.wallet).initializeExtension(delegatedManager.address); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Extension must be pending"); + }); + }); + + describe("when the manager is not a ManagerCore-enabled manager", async () => { + beforeEach(async () => { + await managerCore.connect(owner.wallet).removeManager(delegatedManager.address); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be ManagerCore-enabled manager"); + }); + }); + }); + + describe("#removeExtension", async () => { + let subjectManager: Contract; + let subjectClaimExtension: Address[]; + let subjectCaller: Account; + + beforeEach(async () => { + await claimExtension.connect(owner.wallet).initializeExtension(delegatedManager.address); + + subjectManager = delegatedManager; + subjectClaimExtension = [claimExtension.address]; + subjectCaller = owner; + }); + + async function subject(): Promise { + return subjectManager.connect(subjectCaller.wallet).removeExtensions(subjectClaimExtension); + } + + it("should clear SetToken and DelegatedManager from ClaimExtension state", async () => { + await subject(); + + const storedDelegatedManager: Address = await claimExtension.setManagers(setToken.address); + expect(storedDelegatedManager).to.eq(ADDRESS_ZERO); + }); + + it("should emit the correct ExtensionRemoved event", async () => { + await expect(subject()).to.emit(claimExtension, "ExtensionRemoved").withArgs( + setToken.address, + delegatedManager.address + ); + }); + + describe("when the caller is not the SetToken manager", async () => { + beforeEach(async () => { + subjectManager = await deployer.mocks.deployManagerMock(setToken.address); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be Manager"); + }); + }); + }); + + context("when the ClaimExtension, AirdropModule, and ClaimModule are initialized", async () => { + let airdrops: Address[]; + let airdropFee: BigNumber; + let anyoneAbsorb: boolean; + let airdropFeeRecipient: Address; + + let rewardPools: Address[]; + let integrations: string[]; + let anyoneClaim: boolean; + + let protocolFee: BigNumber; + + before(async () => { + airdrops = [setV2Setup.usdc.address, setV2Setup.weth.address]; + airdropFee = ether(.2); + anyoneAbsorb = false; + airdropFeeRecipient = delegatedManager.address; + + rewardPools = [await getRandomAddress(), await getRandomAddress()]; + integrations = [claimAdapterMockIntegrationNameOne, claimAdapterMockIntegrationNameTwo]; + anyoneClaim = false; + + claimExtension.connect(owner.wallet).initializeModulesAndExtension( + delegatedManager.address, + { + airdrops, + feeRecipient: airdropFeeRecipient, + airdropFee, + anyoneAbsorb + }, + anyoneClaim, + rewardPools, + integrations + ); + + protocolFee = ether(.15); + await setV2Setup.controller.addFee(airdropModule.address, ZERO, protocolFee); + + await setV2Setup.issuanceModule.issue(setToken.address, ether(1), owner.address); + }); + + describe("#distributeFees", async () => { + let numTokens: BigNumber; + let subjectToken: Address; + let subjectSetToken: Address; + + beforeEach(async () => { + numTokens = ether(1); + await setV2Setup.dai.transfer(delegatedManager.address, numTokens); + + subjectToken = setV2Setup.dai.address; + subjectSetToken = setToken.address; + }); + + async function subject(): Promise { + return await claimExtension.distributeFees(subjectSetToken, subjectToken); + } + + it("should send correct amount of fees to owner fee recipient and methodologist", async () => { + const ownerFeeRecipientBalanceBefore = await setV2Setup.dai.balanceOf(ownerFeeRecipient.address); + const methodologistBalanceBefore = await setV2Setup.dai.balanceOf(methodologist.address); + + await subject(); + + const expectedOwnerTake = preciseMul(numTokens, ownerFeeSplit); + const expectedMethodologistTake = numTokens.sub(expectedOwnerTake); + + const ownerFeeRecipientBalanceAfter = await setV2Setup.dai.balanceOf(ownerFeeRecipient.address); + const methodologistBalanceAfter = await setV2Setup.dai.balanceOf(methodologist.address); + + const ownerFeeRecipientBalanceIncrease = ownerFeeRecipientBalanceAfter.sub(ownerFeeRecipientBalanceBefore); + const methodologistBalanceIncrease = methodologistBalanceAfter.sub(methodologistBalanceBefore); + + expect(ownerFeeRecipientBalanceIncrease).to.eq(expectedOwnerTake); + expect(methodologistBalanceIncrease).to.eq(expectedMethodologistTake); + }); + + it("should emit the correct FeesDistributed event", async () => { + const expectedOwnerTake = preciseMul(numTokens, ownerFeeSplit); + const expectedMethodologistTake = numTokens.sub(expectedOwnerTake); + + await expect(subject()).to.emit(claimExtension, "FeesDistributed").withArgs( + setToken.address, + setV2Setup.dai.address, + ownerFeeRecipient.address, + methodologist.address, + expectedOwnerTake, + expectedMethodologistTake + );; + }); + + describe("when methodologist fees are 0", async () => { + beforeEach(async () => { + await delegatedManager.connect(owner.wallet).updateOwnerFeeSplit(ether(1)); + await delegatedManager.connect(methodologist.wallet).updateOwnerFeeSplit(ether(1)); + }); + + it("should not send fees to methodologist", async () => { + const preMethodologistBalance = await setV2Setup.dai.balanceOf(methodologist.address); + + await subject(); + + const postMethodologistBalance = await setV2Setup.dai.balanceOf(methodologist.address); + expect(postMethodologistBalance.sub(preMethodologistBalance)).to.eq(ZERO); + }); + }); + + describe("when owner fees are 0", async () => { + beforeEach(async () => { + await delegatedManager.connect(owner.wallet).updateOwnerFeeSplit(ZERO); + await delegatedManager.connect(methodologist.wallet).updateOwnerFeeSplit(ZERO); + }); + + it("should not send fees to owner fee recipient", async () => { + const preOwnerFeeRecipientBalance = await setV2Setup.dai.balanceOf(owner.address); + + await subject(); + + const postOwnerFeeRecipientBalance = await setV2Setup.dai.balanceOf(owner.address); + expect(postOwnerFeeRecipientBalance.sub(preOwnerFeeRecipientBalance)).to.eq(ZERO); + }); + }); + }); + + describe("#batchAbsorb", async () => { + let airdropOne: BigNumber; + let airdropTwo: BigNumber; + + let subjectSetToken: Address; + let subjectTokens: Address[]; + let subjectCaller: Account; + + beforeEach(async () => { + airdropOne = ether(100); + airdropTwo = ether(1); + + await setV2Setup.usdc.transfer(setToken.address, airdropOne); + await setV2Setup.weth.transfer(setToken.address, airdropTwo); + + subjectSetToken = setToken.address; + subjectTokens = [setV2Setup.usdc.address, setV2Setup.weth.address]; + subjectCaller = operator; + }); + + async function subject(): Promise { + return claimExtension.connect(subjectCaller.wallet).batchAbsorb( + subjectSetToken, + subjectTokens + ); + } + + it("should create the correct new usdc position", async () => { + const balanceBefore = await setV2Setup.usdc.balanceOf(setToken.address); + expect(balanceBefore).to.eq(airdropOne); + + await subject(); + + const totalSupply = await setToken.totalSupply(); + const actualBalanceAfter = await setV2Setup.usdc.balanceOf(setToken.address); + + const expectedBalanceAfter = airdropOne.sub(preciseMul(airdropOne, airdropFee)); + expect(actualBalanceAfter).to.eq(expectedBalanceAfter); + + const positions = await setToken.getPositions(); + const expectedUnitAfter = preciseDiv(expectedBalanceAfter, totalSupply); + expect(positions[1].component).to.eq(setV2Setup.usdc.address); + expect(positions[1].unit).to.eq(expectedUnitAfter); + }); + + it("should transfer the correct usdc amount to the setToken feeRecipient", async () => { + const balanceBefore = await setV2Setup.usdc.balanceOf(setToken.address); + expect(balanceBefore).to.eq(airdropOne); + + await subject(); + + const actualManagerTake = await setV2Setup.usdc.balanceOf(delegatedManager.address); + const expectedManagerTake = preciseMul(preciseMul(airdropOne, airdropFee), PRECISE_UNIT.sub(protocolFee)); + expect(actualManagerTake).to.eq(expectedManagerTake); + }); + + it("should transfer the correct usdc amount to the protocol feeRecipient", async () => { + const balanceBefore = await setV2Setup.usdc.balanceOf(setToken.address); + expect(balanceBefore).to.eq(airdropOne); + + await subject(); + + const actualProtocolTake = await setV2Setup.usdc.balanceOf(setV2Setup.feeRecipient); + const expectedProtocolTake = preciseMul(preciseMul(airdropOne, airdropFee), protocolFee); + expect(actualProtocolTake).to.eq(expectedProtocolTake); + }); + + it("should emit the correct ComponentAbsorbed event for USDC", async () => { + const expectedManagerTake = preciseMul(preciseMul(airdropOne, airdropFee), PRECISE_UNIT.sub(protocolFee)); + const expectedProtocolTake = preciseMul(preciseMul(airdropOne, airdropFee), protocolFee); + await expect(subject()).to.emit(airdropModule, "ComponentAbsorbed").withArgs( + setToken.address, + setV2Setup.usdc.address, + airdropOne, + expectedManagerTake, + expectedProtocolTake + ); + }); + + it("should add the correct amount to the existing weth position", async () => { + const totalSupply = await setToken.totalSupply(); + const prePositions = await setToken.getPositions(); + const knownBalance = preciseMul(prePositions[0].unit, totalSupply); + const balanceBefore = await setV2Setup.weth.balanceOf(setToken.address); + expect(airdropTwo).to.eq(balanceBefore.sub(knownBalance)); + + await subject(); + + const expectedAirdropAmount = airdropTwo.sub(preciseMul(airdropTwo, airdropFee)); + const expectedBalanceAfter = knownBalance.add(expectedAirdropAmount); + + const actualBalanceAfter = await setV2Setup.weth.balanceOf(setToken.address); + expect(actualBalanceAfter).to.eq(expectedBalanceAfter); + + const postPositions = await setToken.getPositions(); + expect(postPositions[0].unit).to.eq(preciseDiv(expectedBalanceAfter, totalSupply)); + }); + + it("should transfer the correct weth amount to the setToken feeRecipient", async () => { + const totalSupply = await setToken.totalSupply(); + const prePositions = await setToken.getPositions(); + const preDropBalance = preciseMul(prePositions[0].unit, totalSupply); + const balance = await setV2Setup.weth.balanceOf(setToken.address); + + await subject(); + + const airdroppedTokens = balance.sub(preDropBalance); + const expectedManagerTake = preciseMul(preciseMul(airdroppedTokens, airdropFee), PRECISE_UNIT.sub(protocolFee)); + + const actualManagerTake = await setV2Setup.weth.balanceOf(delegatedManager.address); + expect(actualManagerTake).to.eq(expectedManagerTake); + }); + + it("should transfer the correct weth amount to the protocol feeRecipient", async () => { + const totalSupply = await setToken.totalSupply(); + const prePositions = await setToken.getPositions(); + const preDropBalance = preciseMul(prePositions[0].unit, totalSupply); + const balance = await setV2Setup.weth.balanceOf(setToken.address); + + await subject(); + + const airdroppedTokens = balance.sub(preDropBalance); + const expectedProtocolTake = preciseMul(preciseMul(airdroppedTokens, airdropFee), protocolFee); + + const actualProtocolTake = await setV2Setup.weth.balanceOf(setV2Setup.feeRecipient); + expect(actualProtocolTake).to.eq(expectedProtocolTake); + }); + + it("should emit the correct ComponentAbsorbed event for WETH", async () => { + const totalSupply = await setToken.totalSupply(); + const prePositions = await setToken.getPositions(); + const preDropBalance = preciseMul(prePositions[0].unit, totalSupply); + const balance = await setV2Setup.weth.balanceOf(setToken.address); + + const airdroppedTokens = balance.sub(preDropBalance); + const expectedManagerTake = preciseMul(preciseMul(airdroppedTokens, airdropFee), PRECISE_UNIT.sub(protocolFee)); + const expectedProtocolTake = preciseMul(preciseMul(airdroppedTokens, airdropFee), protocolFee); + await expect(subject()).to.emit(airdropModule, "ComponentAbsorbed").withArgs( + setToken.address, + setV2Setup.weth.address, + airdroppedTokens, + expectedManagerTake, + expectedProtocolTake + ); + }); + + describe("when anyoneAbsorb is false and the caller is not the DelegatedManager operator", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be valid AirdropModule absorb caller"); + }); + }); + + describe("when anyoneAbsorb is true and the caller is not the DelegatedManager operator", async () => { + beforeEach(async () => { + await claimExtension.connect(owner.wallet).updateAnyoneAbsorb(setToken.address, true); + + subjectCaller = await getRandomAccount(); + }); + + it("should create the correct new usdc position", async () => { + const balanceBefore = await setV2Setup.usdc.balanceOf(setToken.address); + expect(balanceBefore).to.eq(airdropOne); + + await subject(); + + const totalSupply = await setToken.totalSupply(); + const actualBalanceAfter = await setV2Setup.usdc.balanceOf(setToken.address); + + const expectedBalanceAfter = airdropOne.sub(preciseMul(airdropOne, airdropFee)); + expect(actualBalanceAfter).to.eq(expectedBalanceAfter); + + const positions = await setToken.getPositions(); + const expectedUnitAfter = preciseDiv(expectedBalanceAfter, totalSupply); + expect(positions[1].component).to.eq(setV2Setup.usdc.address); + expect(positions[1].unit).to.eq(expectedUnitAfter); + }); + + it("should add the correct amount to the existing weth position", async () => { + const totalSupply = await setToken.totalSupply(); + const prePositions = await setToken.getPositions(); + const knownBalance = preciseMul(prePositions[0].unit, totalSupply); + const balanceBefore = await setV2Setup.weth.balanceOf(setToken.address); + expect(airdropTwo).to.eq(balanceBefore.sub(knownBalance)); + + await subject(); + + const expectedAirdropAmount = airdropTwo.sub(preciseMul(airdropTwo, airdropFee)); + const expectedBalanceAfter = knownBalance.add(expectedAirdropAmount); + + const actualBalanceAfter = await setV2Setup.weth.balanceOf(setToken.address); + expect(actualBalanceAfter).to.eq(expectedBalanceAfter); + + const postPositions = await setToken.getPositions(); + expect(postPositions[0].unit).to.eq(preciseDiv(expectedBalanceAfter, totalSupply)); + }); + }); + + describe("when a passed token is not an allowed asset", async () => { + beforeEach(async () => { + subjectTokens = [setV2Setup.usdc.address, setV2Setup.wbtc.address]; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be allowed asset"); + }); + }); + + describe("when useAssetAllowlist is false and a passed token is not on allowed asset list", async () => { + beforeEach(async () => { + await delegatedManager.connect(owner.wallet).removeAllowedAssets([setV2Setup.usdc.address]); + await delegatedManager.connect(owner.wallet).updateUseAssetAllowlist(false); + + await setV2Setup.wbtc.transfer(setToken.address, ether(1)); + + await claimExtension.connect(owner.wallet).addAirdrop(setToken.address, setV2Setup.wbtc.address); + + subjectTokens = [setV2Setup.usdc.address, setV2Setup.wbtc.address]; + }); + + it("should create the correct new usdc position", async () => { + const totalSupply = await setToken.totalSupply(); + const preDropBalance = ZERO; + const balance = await setV2Setup.usdc.balanceOf(setToken.address); + + await subject(); + + const airdroppedTokens = balance.sub(preDropBalance); + const netBalance = balance.sub(preciseMul(airdroppedTokens, airdropFee)); + + const positions = await setToken.getPositions(); + expect(positions[1].unit).to.eq(preciseDiv(netBalance, totalSupply)); + }); + }); + }); + + describe("#absorb", async () => { + let airdropOne: BigNumber; + + let subjectSetToken: Address; + let subjectToken: Address; + let subjectCaller: Account; + + beforeEach(async () => { + airdropOne = ether(100); + await setV2Setup.usdc.transfer(setToken.address, airdropOne); + + subjectSetToken = setToken.address; + subjectToken = setV2Setup.usdc.address; + subjectCaller = operator; + }); + + async function subject(): Promise { + return claimExtension.connect(subjectCaller.wallet).absorb( + subjectSetToken, + subjectToken + ); + } + + it("should create the correct new usdc position", async () => { + const balanceBefore = await setV2Setup.usdc.balanceOf(setToken.address); + expect(balanceBefore).to.eq(airdropOne); + + await subject(); + + const totalSupply = await setToken.totalSupply(); + const actualBalanceAfter = await setV2Setup.usdc.balanceOf(setToken.address); + + const expectedBalanceAfter = airdropOne.sub(preciseMul(airdropOne, airdropFee)); + expect(actualBalanceAfter).to.eq(expectedBalanceAfter); + + const positions = await setToken.getPositions(); + const expectedUnitAfter = preciseDiv(expectedBalanceAfter, totalSupply); + expect(positions[1].component).to.eq(setV2Setup.usdc.address); + expect(positions[1].unit).to.eq(expectedUnitAfter); + }); + + it("should transfer the correct usdc amount to the setToken feeRecipient", async () => { + const balanceBefore = await setV2Setup.usdc.balanceOf(setToken.address); + expect(balanceBefore).to.eq(airdropOne); + + await subject(); + + const actualManagerTake = await setV2Setup.usdc.balanceOf(delegatedManager.address); + const expectedManagerTake = preciseMul(preciseMul(airdropOne, airdropFee), PRECISE_UNIT.sub(protocolFee)); + expect(actualManagerTake).to.eq(expectedManagerTake); + }); + + it("should transfer the correct usdc amount to the protocol feeRecipient", async () => { + const balanceBefore = await setV2Setup.usdc.balanceOf(setToken.address); + expect(balanceBefore).to.eq(airdropOne); + + await subject(); + + const actualProtocolTake = await setV2Setup.usdc.balanceOf(setV2Setup.feeRecipient); + const expectedProtocolTake = preciseMul(preciseMul(airdropOne, airdropFee), protocolFee); + expect(actualProtocolTake).to.eq(expectedProtocolTake); + }); + + it("should emit the correct ComponentAbsorbed event for USDC", async () => { + const expectedManagerTake = preciseMul(preciseMul(airdropOne, airdropFee), PRECISE_UNIT.sub(protocolFee)); + const expectedProtocolTake = preciseMul(preciseMul(airdropOne, airdropFee), protocolFee); + await expect(subject()).to.emit(airdropModule, "ComponentAbsorbed").withArgs( + setToken.address, + setV2Setup.usdc.address, + airdropOne, + expectedManagerTake, + expectedProtocolTake + ); + }); + + describe("when anyoneAbsorb is false and the caller is not the DelegatedManager operator", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be valid AirdropModule absorb caller"); + }); + }); + + describe("when anyoneAbsorb is true and the caller is not the DelegatedManager operator", async () => { + beforeEach(async () => { + await claimExtension.connect(owner.wallet).updateAnyoneAbsorb(setToken.address, true); + + subjectCaller = await getRandomAccount(); + }); + + it("should create the correct new usdc position", async () => { + const balanceBefore = await setV2Setup.usdc.balanceOf(setToken.address); + expect(balanceBefore).to.eq(airdropOne); + + await subject(); + + const totalSupply = await setToken.totalSupply(); + const actualBalanceAfter = await setV2Setup.usdc.balanceOf(setToken.address); + + const expectedBalanceAfter = airdropOne.sub(preciseMul(airdropOne, airdropFee)); + expect(actualBalanceAfter).to.eq(expectedBalanceAfter); + + const positions = await setToken.getPositions(); + const expectedUnitAfter = preciseDiv(expectedBalanceAfter, totalSupply); + expect(positions[1].component).to.eq(setV2Setup.usdc.address); + expect(positions[1].unit).to.eq(expectedUnitAfter); + }); + }); + + describe("when passed token is not an allowed asset", async () => { + beforeEach(async () => { + subjectToken = setV2Setup.wbtc.address; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be allowed asset"); + }); + }); + }); + + describe("#addAirdrop", async () => { + let subjectSetToken: Address; + let subjectAirdrop: Address; + let subjectCaller: Account; + + beforeEach(async () => { + subjectSetToken = setToken.address; + subjectAirdrop = setV2Setup.wbtc.address; + subjectCaller = owner; + }); + + async function subject(): Promise { + return claimExtension.connect(subjectCaller.wallet).addAirdrop( + subjectSetToken, + subjectAirdrop + ); + } + + it("should add the new token", async () => { + await subject(); + + const airdrops = await airdropModule.getAirdrops(setToken.address); + const isAirdrop = await airdropModule.isAirdrop(setToken.address, setV2Setup.wbtc.address); + expect(airdrops[2]).to.eq(setV2Setup.wbtc.address); + expect(isAirdrop).to.be.true; + }); + + describe("when the sender is not the owner", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be owner"); + }); + }); + }); + + describe("#removeAirdrop", async () => { + let subjectSetToken: Address; + let subjectAirdrop: Address; + let subjectCaller: Account; + + beforeEach(async () => { + subjectSetToken = setToken.address; + subjectAirdrop = setV2Setup.usdc.address; + subjectCaller = owner; + }); + + async function subject(): Promise { + return claimExtension.connect(subjectCaller.wallet).removeAirdrop( + subjectSetToken, + subjectAirdrop + ); + } + + it("should remove the token", async () => { + await subject(); + + const airdrops = await airdropModule.getAirdrops(setToken.address); + const isAirdrop = await airdropModule.isAirdrop(subjectSetToken, subjectAirdrop); + expect(airdrops).to.not.contain(subjectAirdrop); + expect(isAirdrop).to.be.false; + }); + + describe("when the sender is not the owner", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be owner"); + }); + }); + }); + + describe("#updateAnyoneAbsorb", async () => { + let subjectSetToken: Address; + let subjectAnyoneAbsorb: boolean; + let subjectCaller: Account; + + beforeEach(async () => { + subjectSetToken = setToken.address; + subjectAnyoneAbsorb = true; + subjectCaller = owner; + }); + + async function subject(): Promise { + return claimExtension.connect(subjectCaller.wallet).updateAnyoneAbsorb( + subjectSetToken, + subjectAnyoneAbsorb + ); + } + + it("should flip the anyoneAbsorb indicator", async () => { + await subject(); + + const airdropSettings = await airdropModule.airdropSettings(setToken.address); + expect(airdropSettings.anyoneAbsorb).to.be.true; + }); + + describe("when the sender is not the owner", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be owner"); + }); + }); + }); + + describe("#updateAirdropFeeRecipient", async () => { + let subjectSetToken: Address; + let subjectNewFeeRecipient: Address; + let subjectCaller: Account; + + beforeEach(async () => { + subjectSetToken = setToken.address; + subjectNewFeeRecipient = await getRandomAddress(); + subjectCaller = owner; + }); + + async function subject(): Promise { + return claimExtension.connect(subjectCaller.wallet).updateAirdropFeeRecipient( + subjectSetToken, + subjectNewFeeRecipient + ); + } + + it("should change the fee recipient to the new address", async () => { + await subject(); + + const airdropSettings = await airdropModule.airdropSettings(setToken.address); + expect(airdropSettings.feeRecipient).to.eq(subjectNewFeeRecipient); + }); + + describe("when the sender is not the owner", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be owner"); + }); + }); + }); + + describe("#updateAirdropFee", async () => { + let subjectSetToken: Address; + let subjectNewFee: BigNumber; + let subjectCaller: Account; + + beforeEach(async () => { + await setV2Setup.usdc.transfer(setToken.address, ether(1)); + + subjectSetToken = setToken.address; + subjectNewFee = ether(.5); + subjectCaller = owner; + }); + + async function subject(): Promise { + return claimExtension.connect(subjectCaller.wallet).updateAirdropFee( + subjectSetToken, + subjectNewFee + ); + } + + it("should create the correct new usdc position", async () => { + const totalSupply = await setToken.totalSupply(); + const preDropBalance = ZERO; + const balance = await setV2Setup.usdc.balanceOf(setToken.address); + + await subject(); + + const airdroppedTokens = balance.sub(preDropBalance); + const netBalance = balance.sub(preciseMul(airdroppedTokens, airdropFee)); + + const positions = await setToken.getPositions(); + expect(positions[1].unit).to.eq(preciseDiv(netBalance, totalSupply)); + }); + + it("should set the new fee", async () => { + await subject(); + + const airdropSettings = await airdropModule.airdropSettings(setToken.address); + expect(airdropSettings.airdropFee).to.eq(subjectNewFee); + }); + + describe("when the sender is not the owner", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be owner"); + }); + }); + }); + + describe("#claimAndAbsorb", async () => { + let rewards: BigNumber; + + let subjectSetToken: Address; + let subjectRewardPool: Address; + let subjectIntegration: string; + let subjectCaller: Account; + + beforeEach(async () => { + rewards = ether(1); + await claimAdapterMockOne.setRewards(rewards); + + await claimExtension.connect(owner.wallet).addAirdrop( + setToken.address, + claimAdapterMockOne.address + ); + + await delegatedManager.connect(owner.wallet).addAllowedAssets([claimAdapterMockOne.address]); + + subjectSetToken = setToken.address; + subjectRewardPool = rewardPools[0]; + subjectIntegration = claimAdapterMockIntegrationNameOne; + subjectCaller = operator; + }); + + async function subject(): Promise { + return claimExtension.connect(subjectCaller.wallet).claimAndAbsorb( + subjectSetToken, + subjectRewardPool, + subjectIntegration + ); + } + + it("emits the correct RewardClaimed event", async () => { + await expect(subject()).to.emit(claimModule, "RewardClaimed").withArgs( + subjectSetToken, + subjectRewardPool, + claimAdapterMockOne.address, + rewards + ); + }); + + it("should claim the rewards and create the correct new reward token position", async () => { + const balanceBefore = await claimAdapterMockOne.balanceOf(setToken.address); + expect(balanceBefore).to.eq(ZERO); + + await subject(); + + const totalSupply = await setToken.totalSupply(); + const actualBalanceAfter = await claimAdapterMockOne.balanceOf(setToken.address); + + const expectedBalanceAfter = rewards.sub(preciseMul(rewards, airdropFee)); + expect(actualBalanceAfter).to.eq(expectedBalanceAfter); + + const positions = await setToken.getPositions(); + const expectedUnitAfter = preciseDiv(expectedBalanceAfter, totalSupply); + expect(positions[1].component).to.eq(claimAdapterMockOne.address); + expect(positions[1].unit).to.eq(expectedUnitAfter); + }); + + it("should transfer the correct rewards amount to the setToken feeRecipient", async () => { + const balanceBefore = await claimAdapterMockOne.balanceOf(delegatedManager.address); + expect(balanceBefore).to.eq(ZERO); + + await subject(); + + const actualManagerTake = await claimAdapterMockOne.balanceOf(delegatedManager.address); + const expectedManagerTake = preciseMul(preciseMul(rewards, airdropFee), PRECISE_UNIT.sub(protocolFee)); + + expect(actualManagerTake).to.eq(expectedManagerTake); + }); + + it("should transfer the correct rewards amount to the protocol feeRecipient", async () => { + const balanceBefore = await claimAdapterMockOne.balanceOf(setV2Setup.feeRecipient); + expect(balanceBefore).to.eq(ZERO); + + await subject(); + + const actualProtocolTake = await claimAdapterMockOne.balanceOf(setV2Setup.feeRecipient); + const expectedProtocolTake = preciseMul(preciseMul(rewards, airdropFee), protocolFee); + expect(actualProtocolTake).to.eq(expectedProtocolTake); + }); + + it("should emit the correct ComponentAbsorbed event for rewards", async () => { + const expectedManagerTake = preciseMul(preciseMul(rewards, airdropFee), PRECISE_UNIT.sub(protocolFee)); + const expectedProtocolTake = preciseMul(preciseMul(rewards, airdropFee), protocolFee); + await expect(subject()).to.emit(airdropModule, "ComponentAbsorbed").withArgs( + setToken.address, + claimAdapterMockOne.address, + rewards, + expectedManagerTake, + expectedProtocolTake + ); + }); + + describe("when anyoneClaim and anyoneAbsorb are false and the caller is not the DelegatedManager operator", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be valid AirdropModule absorb and ClaimModule claim caller"); + }); + }); + + describe("when anyoneClaim and anyoneAbsorb is true and the caller is not the DelegatedManager operator", async () => { + beforeEach(async () => { + await claimExtension.connect(owner.wallet).updateAnyoneClaim(setToken.address, true); + await claimExtension.connect(owner.wallet).updateAnyoneAbsorb(setToken.address, true); + + subjectCaller = await getRandomAccount(); + }); + + it("should claim the rewards and create the correct new reward token position", async () => { + const balanceBefore = await claimAdapterMockOne.balanceOf(setToken.address); + expect(balanceBefore).to.eq(ZERO); + + await subject(); + + const totalSupply = await setToken.totalSupply(); + const actualBalanceAfter = await claimAdapterMockOne.balanceOf(setToken.address); + + const expectedBalanceAfter = rewards.sub(preciseMul(rewards, airdropFee)); + expect(actualBalanceAfter).to.eq(expectedBalanceAfter); + + const positions = await setToken.getPositions(); + const expectedUnitAfter = preciseDiv(expectedBalanceAfter, totalSupply); + expect(positions[1].component).to.eq(claimAdapterMockOne.address); + expect(positions[1].unit).to.eq(expectedUnitAfter); + }); + }); + + describe("when the rewards token is not an allowed asset", async () => { + beforeEach(async () => { + await delegatedManager.connect(owner.wallet).removeAllowedAssets([claimAdapterMockOne.address]); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be allowed asset"); + }); + }); + }); + + describe("#batchClaimAndAbsorb", async () => { + let rewardsOne: BigNumber; + let rewardsTwo: BigNumber; + + let subjectSetToken: Address; + let subjectRewardPools: Address[]; + let subjectIntegrations: string[]; + let subjectCaller: Account; + + beforeEach(async () => { + rewardsOne = ether(1); + rewardsTwo = ether(2); + await claimAdapterMockOne.setRewards(rewardsOne); + await claimAdapterMockTwo.setRewards(rewardsTwo); + + await claimExtension.connect(owner.wallet).addAirdrop( + setToken.address, + claimAdapterMockOne.address + ); + + await claimExtension.connect(owner.wallet).addAirdrop( + setToken.address, + claimAdapterMockTwo.address + ); + + await delegatedManager.connect(owner.wallet).addAllowedAssets( + [claimAdapterMockOne.address, claimAdapterMockTwo.address] + ); + + subjectSetToken = setToken.address; + subjectRewardPools = rewardPools; + subjectIntegrations = [claimAdapterMockIntegrationNameOne, claimAdapterMockIntegrationNameTwo]; + subjectCaller = operator; + }); + + async function subject(): Promise { + return claimExtension.connect(subjectCaller.wallet).batchClaimAndAbsorb( + subjectSetToken, + subjectRewardPools, + subjectIntegrations + ); + } + + it("emits the correct first RewardClaimed events", async () => { + await expect(subject()).to.emit(claimModule, "RewardClaimed").withArgs( + subjectSetToken, + subjectRewardPools[0], + claimAdapterMockOne.address, + rewardsOne + ); + }); + + it("emits the correct second RewardClaimed events", async () => { + await expect(subject()).to.emit(claimModule, "RewardClaimed").withArgs( + subjectSetToken, + subjectRewardPools[1], + claimAdapterMockTwo.address, + rewardsTwo + ); + }); + + it("should claim the rewards and create the correct new reward token positions", async () => { + const balanceOneBefore = await claimAdapterMockOne.balanceOf(setToken.address); + const balanceTwoBefore = await claimAdapterMockTwo.balanceOf(setToken.address); + expect(balanceOneBefore).to.eq(ZERO); + expect(balanceTwoBefore).to.eq(ZERO); + + await subject(); + + const totalSupply = await setToken.totalSupply(); + const actualBalanceOneAfter = await claimAdapterMockOne.balanceOf(setToken.address); + const actualBalanceTwoAfter = await claimAdapterMockTwo.balanceOf(setToken.address); + + const expectedBalanceOneAfter = rewardsOne.sub(preciseMul(rewardsOne, airdropFee)); + const expectedBalanceTwoAfter = rewardsTwo.sub(preciseMul(rewardsTwo, airdropFee)); + expect(actualBalanceOneAfter).to.eq(expectedBalanceOneAfter); + expect(actualBalanceTwoAfter).to.eq(expectedBalanceTwoAfter); + + const positions = await setToken.getPositions(); + const expectedUnitOneAfter = preciseDiv(expectedBalanceOneAfter, totalSupply); + const expectedUnitTwoAfter = preciseDiv(expectedBalanceTwoAfter, totalSupply); + expect(positions[1].component).to.eq(claimAdapterMockOne.address); + expect(positions[2].component).to.eq(claimAdapterMockTwo.address); + expect(positions[1].unit).to.eq(expectedUnitOneAfter); + expect(positions[2].unit).to.eq(expectedUnitTwoAfter); + }); + + it("should transfer the correct rewards amounts to the setToken feeRecipient", async () => { + const balanceOneBefore = await claimAdapterMockOne.balanceOf(delegatedManager.address); + const balanceTwoBefore = await claimAdapterMockTwo.balanceOf(delegatedManager.address); + expect(balanceOneBefore).to.eq(ZERO); + expect(balanceTwoBefore).to.eq(ZERO); + + await subject(); + + const actualManagerTakeOne = await claimAdapterMockOne.balanceOf(delegatedManager.address); + const expectedManagerTakeOne = preciseMul(preciseMul(rewardsOne, airdropFee), PRECISE_UNIT.sub(protocolFee)); + + const actualManagerTakeTwo = await claimAdapterMockTwo.balanceOf(delegatedManager.address); + const expectedManagerTakeTwo = preciseMul(preciseMul(rewardsTwo, airdropFee), PRECISE_UNIT.sub(protocolFee)); + + expect(actualManagerTakeOne).to.eq(expectedManagerTakeOne); + expect(actualManagerTakeTwo).to.eq(expectedManagerTakeTwo); + }); + + it("should transfer the correct rewards amounts to the protocol feeRecipient", async () => { + const balanceOneBefore = await claimAdapterMockOne.balanceOf(setV2Setup.feeRecipient); + const balanceTwoBefore = await claimAdapterMockTwo.balanceOf(setV2Setup.feeRecipient); + expect(balanceOneBefore).to.eq(ZERO); + expect(balanceTwoBefore).to.eq(ZERO); + + await subject(); + + const actualProtocolTakeOne = await claimAdapterMockOne.balanceOf(setV2Setup.feeRecipient); + const expectedProtocolTakeOne = preciseMul(preciseMul(rewardsOne, airdropFee), protocolFee); + + const actualProtocolTakeTwo = await claimAdapterMockTwo.balanceOf(setV2Setup.feeRecipient); + const expectedProtocolTakeTwo = preciseMul(preciseMul(rewardsTwo, airdropFee), protocolFee); + + expect(actualProtocolTakeOne).to.eq(expectedProtocolTakeOne); + expect(actualProtocolTakeTwo).to.eq(expectedProtocolTakeTwo); + }); + + it("should emit the correct ComponentAbsorbed event for the first rewards", async () => { + const expectedManagerTakeOne = preciseMul(preciseMul(rewardsOne, airdropFee), PRECISE_UNIT.sub(protocolFee)); + const expectedProtocolTakeOne = preciseMul(preciseMul(rewardsOne, airdropFee), protocolFee); + await expect(subject()).to.emit(airdropModule, "ComponentAbsorbed").withArgs( + setToken.address, + claimAdapterMockOne.address, + rewardsOne, + expectedManagerTakeOne, + expectedProtocolTakeOne + ); + }); + + it("should emit the correct ComponentAbsorbed event for the second rewards", async () => { + const expectedManagerTakeTwo = preciseMul(preciseMul(rewardsTwo, airdropFee), PRECISE_UNIT.sub(protocolFee)); + const expectedProtocolTakeTwo = preciseMul(preciseMul(rewardsTwo, airdropFee), protocolFee); + await expect(subject()).to.emit(airdropModule, "ComponentAbsorbed").withArgs( + setToken.address, + claimAdapterMockTwo.address, + rewardsTwo, + expectedManagerTakeTwo, + expectedProtocolTakeTwo + ); + }); + + describe("when anyoneClaim and anyoneAbsorb are false and the caller is not the DelegatedManager operator", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be valid AirdropModule absorb and ClaimModule claim caller"); + }); + }); + + describe("when anyoneClaim and anyoneAbsorb is true and the caller is not the DelegatedManager operator", async () => { + beforeEach(async () => { + await claimExtension.connect(owner.wallet).updateAnyoneClaim(setToken.address, true); + await claimExtension.connect(owner.wallet).updateAnyoneAbsorb(setToken.address, true); + + subjectCaller = await getRandomAccount(); + }); + + it("should claim the rewards and create the correct new reward token positions", async () => { + const balanceOneBefore = await claimAdapterMockOne.balanceOf(setToken.address); + const balanceTwoBefore = await claimAdapterMockTwo.balanceOf(setToken.address); + expect(balanceOneBefore).to.eq(ZERO); + expect(balanceTwoBefore).to.eq(ZERO); + + await subject(); + + const totalSupply = await setToken.totalSupply(); + const actualBalanceOneAfter = await claimAdapterMockOne.balanceOf(setToken.address); + const actualBalanceTwoAfter = await claimAdapterMockTwo.balanceOf(setToken.address); + + const expectedBalanceOneAfter = rewardsOne.sub(preciseMul(rewardsOne, airdropFee)); + const expectedBalanceTwoAfter = rewardsTwo.sub(preciseMul(rewardsTwo, airdropFee)); + expect(actualBalanceOneAfter).to.eq(expectedBalanceOneAfter); + expect(actualBalanceTwoAfter).to.eq(expectedBalanceTwoAfter); + + const positions = await setToken.getPositions(); + const expectedUnitOneAfter = preciseDiv(expectedBalanceOneAfter, totalSupply); + const expectedUnitTwoAfter = preciseDiv(expectedBalanceTwoAfter, totalSupply); + expect(positions[1].component).to.eq(claimAdapterMockOne.address); + expect(positions[2].component).to.eq(claimAdapterMockTwo.address); + expect(positions[1].unit).to.eq(expectedUnitOneAfter); + expect(positions[2].unit).to.eq(expectedUnitTwoAfter); + }); + }); + + describe("when the rewards token is not an allowed asset", async () => { + beforeEach(async () => { + await delegatedManager.connect(owner.wallet).removeAllowedAssets([claimAdapterMockTwo.address]); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be allowed asset"); + }); + }); + }); + + describe("#updateAnyoneClaim", async () => { + let subjectSetToken: Address; + let subjectAnyoneClaim: boolean; + let subjectCaller: Account; + + beforeEach(async () => { + subjectSetToken = setToken.address; + subjectAnyoneClaim = true; + subjectCaller = owner; + }); + + async function subject(): Promise { + return claimExtension.connect(subjectCaller.wallet).updateAnyoneClaim( + subjectSetToken, + subjectAnyoneClaim + ); + } + + it("should change the anyoneClaim indicator", async () => { + const anyoneClaimBefore = await claimModule.anyoneClaim(subjectSetToken); + expect(anyoneClaimBefore).to.eq(false); + + await subject(); + + const anyoneClaim = await claimModule.anyoneClaim(subjectSetToken); + expect(anyoneClaim).to.eq(true); + + subjectAnyoneClaim = false; + await subject(); + + const anyoneClaimAfter = await claimModule.anyoneClaim(subjectSetToken); + expect(anyoneClaimAfter).to.eq(false); + }); + + describe("when the sender is not the owner", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be owner"); + }); + }); + }); + + describe("#addClaim", async () => { + let subjectSetToken: Address; + let subjectRewardPool: Address; + let subjectIntegration: string; + let subjectCaller: Account; + + beforeEach(async () => { + subjectSetToken = setToken.address; + subjectRewardPool = await getRandomAddress(); + subjectIntegration = claimAdapterMockIntegrationNameTwo; + subjectCaller = owner; + }); + + async function subject(): Promise { + return claimExtension.connect(subjectCaller.wallet).addClaim( + subjectSetToken, + subjectRewardPool, + subjectIntegration + ); + } + + it("should add the rewardPool to the rewardPoolList and rewardPoolStatus", async () => { + expect(await claimModule.isRewardPool(subjectSetToken, subjectRewardPool)).to.be.false; + + await subject(); + + expect(await claimModule.isRewardPool(subjectSetToken, subjectRewardPool)).to.be.true; + expect(await claimModule.rewardPoolList(subjectSetToken, 2)).to.eq(subjectRewardPool); + }); + + it("should add new integration for the rewardPool", async () => { + const rewardPoolClaimsBefore = await claimModule.getRewardPoolClaims(setToken.address, subjectRewardPool); + const isIntegrationAddedBefore = await claimModule.claimSettingsStatus(setToken.address, subjectRewardPool, claimAdapterMockTwo.address); + expect(rewardPoolClaimsBefore.length).to.eq(0); + expect(isIntegrationAddedBefore).to.be.false; + + await subject(); + + const rewardPoolClaims = await claimModule.getRewardPoolClaims(setToken.address, subjectRewardPool); + const isIntegrationAdded = await claimModule.claimSettingsStatus(setToken.address, subjectRewardPool, claimAdapterMockTwo.address); + expect(rewardPoolClaims.length).to.eq(1); + expect(rewardPoolClaims[0]).to.eq(claimAdapterMockTwo.address); + expect(isIntegrationAdded).to.be.true; + }); + + describe("when the sender is not the owner", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be owner"); + }); + }); + }); + + describe("#batchAddClaim", async () => { + let subjectSetToken: Address; + let subjectRewardPools: Address[]; + let subjectIntegrations: string[]; + let subjectCaller: Account; + + beforeEach(async () => { + subjectSetToken = setToken.address; + const [rewardPoolOne, rewardPoolTwo] = [await getRandomAddress(), await getRandomAddress()]; + subjectRewardPools = [rewardPoolOne, rewardPoolOne, rewardPoolTwo]; + subjectIntegrations = [claimAdapterMockIntegrationNameOne, claimAdapterMockIntegrationNameTwo, claimAdapterMockIntegrationNameOne]; + subjectCaller = owner; + }); + + async function subject(): Promise { + return claimExtension.connect(subjectCaller.wallet).batchAddClaim( + subjectSetToken, + subjectRewardPools, + subjectIntegrations + ); + } + + it("should add the rewardPools to the rewardPoolList", async () => { + const isFirstAddedBefore = await claimModule.rewardPoolStatus(subjectSetToken, subjectRewardPools[0]); + const isSecondAddedBefore = await claimModule.rewardPoolStatus(subjectSetToken, subjectRewardPools[2]); + expect((await claimModule.getRewardPools(subjectSetToken)).length).to.eq(2); + expect(isFirstAddedBefore).to.be.false; + expect(isSecondAddedBefore).to.be.false; + + await subject(); + + const rewardPools = await claimModule.getRewardPools(subjectSetToken); + const isFirstAdded = await claimModule.rewardPoolStatus(subjectSetToken, subjectRewardPools[0]); + const isSecondAdded = await claimModule.rewardPoolStatus(subjectSetToken, subjectRewardPools[2]); + expect(rewardPools.length).to.eq(4); + expect(rewardPools[2]).to.eq(subjectRewardPools[0]); + expect(rewardPools[3]).to.eq(subjectRewardPools[2]); + expect(isFirstAdded).to.be.true; + expect(isSecondAdded).to.be.true; + }); + + it("should add all new integrations for the rewardPools", async () => { + await subject(); + + const rewardPoolOneClaims = await claimModule.getRewardPoolClaims(setToken.address, subjectRewardPools[0]); + const rewardPoolTwoClaims = await claimModule.getRewardPoolClaims(setToken.address, subjectRewardPools[2]); + const isFirstIntegrationAddedPool1 = await claimModule.claimSettingsStatus( + setToken.address, + subjectRewardPools[0], + claimAdapterMockOne.address + ); + const isSecondIntegrationAddedPool1 = await claimModule.claimSettingsStatus( + setToken.address, + subjectRewardPools[1], + claimAdapterMockTwo.address + ); + const isIntegrationAddedPool2 = await claimModule.claimSettingsStatus( + setToken.address, + subjectRewardPools[0], + claimAdapterMockOne.address + ); + expect(rewardPoolOneClaims[0]).to.eq(claimAdapterMockOne.address); + expect(rewardPoolOneClaims[1]).to.eq(claimAdapterMockTwo.address); + expect(rewardPoolTwoClaims[0]).to.eq(claimAdapterMockOne.address); + expect(isFirstIntegrationAddedPool1).to.be.true; + expect(isSecondIntegrationAddedPool1).to.be.true; + expect(isIntegrationAddedPool2).to.be.true; + }); + + describe("when the sender is not the owner", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be owner"); + }); + }); + }); + + describe("#removeClaim", async () => { + let subjectSetToken: Address; + let subjectRewardPool: Address; + let subjectIntegration: string; + let subjectCaller: Account; + + beforeEach(async () => { + subjectSetToken = setToken.address; + subjectRewardPool = await getRandomAddress(); + subjectIntegration = claimAdapterMockIntegrationNameOne; + subjectCaller = owner; + + await claimExtension.connect(subjectCaller.wallet).addClaim( + subjectSetToken, + subjectRewardPool, + subjectIntegration + ); + }); + + async function subject(): Promise { + return claimExtension.connect(subjectCaller.wallet).removeClaim( + subjectSetToken, + subjectRewardPool, + subjectIntegration + ); + } + + it("should remove the adapter associated to the reward pool", async () => { + const rewardPoolClaimsBefore = await claimModule.getRewardPoolClaims(setToken.address, subjectRewardPool); + const isAdapterAddedBefore = await claimModule.claimSettingsStatus(setToken.address, subjectRewardPool, claimAdapterMockOne.address); + expect(rewardPoolClaimsBefore.length).to.eq(1); + expect(isAdapterAddedBefore).to.be.true; + + await subject(); + + const rewardPoolClaims = await claimModule.getRewardPoolClaims(setToken.address, subjectRewardPool); + const isAdapterAdded = await claimModule.claimSettingsStatus(setToken.address, subjectRewardPool, claimAdapterMockOne.address); + expect(rewardPoolClaims.length).to.eq(0); + expect(isAdapterAdded).to.be.false; + }); + + it("should remove the rewardPool from the rewardPoolStatus", async () => { + expect(await claimModule.isRewardPool(setToken.address, subjectRewardPool)).to.be.true; + + await subject(); + + expect(await claimModule.isRewardPool(setToken.address, subjectRewardPool)).to.be.false; + }); + + describe("when the sender is not the owner", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be owner"); + }); + }); + }); + + describe("#batchRemoveClaim", async () => { + let subjectSetToken: Address; + let subjectRewardPools: Address[]; + let subjectIntegrations: string[]; + let subjectCaller: Account; + + beforeEach(async () => { + subjectSetToken = setToken.address; + subjectRewardPools = [await getRandomAddress(), await getRandomAddress()]; + subjectIntegrations = [claimAdapterMockIntegrationNameOne, claimAdapterMockIntegrationNameTwo]; + subjectCaller = owner; + + await claimExtension.connect(subjectCaller.wallet).batchAddClaim( + subjectSetToken, + subjectRewardPools, + subjectIntegrations + ); + }); + + async function subject(): Promise { + return claimExtension.connect(subjectCaller.wallet).batchRemoveClaim( + subjectSetToken, + subjectRewardPools, + subjectIntegrations + ); + } + + it("should remove the adapter associated to the reward pool", async () => { + const rewardPoolOneClaimsBefore = await claimModule.getRewardPoolClaims(setToken.address, subjectRewardPools[0]); + const rewardPoolTwoClaimsBefore = await claimModule.getRewardPoolClaims(setToken.address, subjectRewardPools[1]); + const isRewardPoolOneAdapterOneBefore = await claimModule.claimSettingsStatus( + setToken.address, + subjectRewardPools[0], + claimAdapterMockOne.address + ); + const isRewardPoolTwoAdapterTwoBefore = await claimModule.claimSettingsStatus( + setToken.address, + subjectRewardPools[1], + claimAdapterMockTwo.address + ); + expect(rewardPoolOneClaimsBefore.length).to.eq(1); + expect(rewardPoolTwoClaimsBefore.length).to.eq(1); + expect(isRewardPoolOneAdapterOneBefore).to.be.true; + expect(isRewardPoolTwoAdapterTwoBefore).to.be.true; + + await subject(); + + const rewardPoolOneClaims = await claimModule.getRewardPoolClaims(setToken.address, subjectRewardPools[0]); + const rewardPoolTwoClaims = await claimModule.getRewardPoolClaims(setToken.address, subjectRewardPools[1]); + const isRewardPoolOneAdapterOne = await claimModule.claimSettingsStatus(setToken.address, subjectRewardPools[0], claimAdapterMockOne.address); + const isRewardPoolTwoAdapterTwo = await claimModule.claimSettingsStatus(setToken.address, subjectRewardPools[1], claimAdapterMockTwo.address); + expect(rewardPoolOneClaims.length).to.eq(0); + expect(rewardPoolTwoClaims.length).to.eq(0); + expect(isRewardPoolOneAdapterOne).to.be.false; + expect(isRewardPoolTwoAdapterTwo).to.be.false; + + }); + + it("should remove the rewardPool from the rewardPoolStatus", async () => { + expect(await claimModule.isRewardPool(subjectSetToken, subjectRewardPools[0])).to.be.true; + expect(await claimModule.isRewardPool(subjectSetToken, subjectRewardPools[1])).to.be.true; + + await subject(); + + expect(await claimModule.isRewardPool(subjectSetToken, subjectRewardPools[0])).to.be.false; + expect(await claimModule.isRewardPool(subjectSetToken, subjectRewardPools[1])).to.be.false; + }); + + describe("when the sender is not the owner", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be owner"); + }); + }); + }); + }); +}); \ No newline at end of file diff --git a/test/extensions/wrapExtension.spec.ts b/test/extensions/wrapExtension.spec.ts new file mode 100644 index 0000000..12835fd --- /dev/null +++ b/test/extensions/wrapExtension.spec.ts @@ -0,0 +1,808 @@ +import "module-alias/register"; + +import { BigNumber, Contract } from "ethers"; +import { Address, Account } from "@utils/types"; +import { ADDRESS_ZERO, ZERO_BYTES } from "@utils/constants"; +import { + DelegatedManager, + WrapExtension, + ManagerCore, +} from "@utils/contracts/index"; +import { + SetToken, + WrapModuleV2, + WrapV2AdapterMock +} from "@setprotocol/set-protocol-v2/utils/contracts"; +import DeployHelper from "@utils/deploys"; +import { + addSnapshotBeforeRestoreAfterEach, + ether, + getAccounts, + getWaffleExpect, + preciseMul, + getProvider +} from "@utils/index"; +import { SystemFixture } from "@setprotocol/set-protocol-v2/dist/utils/fixtures"; +import { getSystemFixture, getRandomAccount } from "@setprotocol/set-protocol-v2/dist/utils/test"; +import { ContractTransaction } from "ethers"; + +const expect = getWaffleExpect(); + +describe("WrapExtension", () => { + let owner: Account; + let methodologist: Account; + let operator: Account; + let factory: Account; + + let deployer: DeployHelper; + let setToken: SetToken; + let setV2Setup: SystemFixture; + + let managerCore: ManagerCore; + let delegatedManager: DelegatedManager; + let wrapExtension: WrapExtension; + + let wrapModule: WrapModuleV2; + let wrapAdapterMock: WrapV2AdapterMock; + const wrapAdapterMockIntegrationName: string = "MOCK_WRAPPER_V2"; + + before(async () => { + [ + owner, + methodologist, + operator, + factory + ] = await getAccounts(); + + deployer = new DeployHelper(owner.wallet); + + setV2Setup = getSystemFixture(owner.address); + await setV2Setup.initialize(); + + wrapModule = await deployer.setDeployer.modules.deployWrapModuleV2(setV2Setup.controller.address, setV2Setup.weth.address); + await setV2Setup.controller.addModule(wrapModule.address); + + wrapAdapterMock = await deployer.setDeployer.mocks.deployWrapV2AdapterMock(); + + await setV2Setup.integrationRegistry.addIntegration( + wrapModule.address, + wrapAdapterMockIntegrationName, + wrapAdapterMock.address + ); + + managerCore = await deployer.managerCore.deployManagerCore(); + + wrapExtension = await deployer.globalExtensions.deployWrapExtension( + managerCore.address, + wrapModule.address + ); + + setToken = await setV2Setup.createSetToken( + [setV2Setup.weth.address], + [ether(1)], + [setV2Setup.issuanceModule.address, wrapModule.address] + ); + + await setV2Setup.issuanceModule.initialize(setToken.address, ADDRESS_ZERO); + + delegatedManager = await deployer.manager.deployDelegatedManager( + setToken.address, + factory.address, + methodologist.address, + [wrapExtension.address], + [operator.address], + [setV2Setup.dai.address, setV2Setup.weth.address, setV2Setup.wbtc.address], + true + ); + + await setToken.setManager(delegatedManager.address); + + await managerCore.initialize([wrapExtension.address], [factory.address]); + await managerCore.connect(factory.wallet).addManager(delegatedManager.address); + }); + + addSnapshotBeforeRestoreAfterEach(); + + describe("#constructor", async () => { + let subjectManagerCore: Address; + let subjectWrapModule: Address; + + beforeEach(async () => { + subjectManagerCore = managerCore.address; + subjectWrapModule = wrapModule.address; + }); + + async function subject(): Promise { + return await deployer.globalExtensions.deployWrapExtension( + subjectManagerCore, + subjectWrapModule + ); + } + + it("should set the correct WrapModuleV2 address", async () => { + const wrapExtension = await subject(); + + const storedModule = await wrapExtension.wrapModule(); + expect(storedModule).to.eq(subjectWrapModule); + }); + }); + + describe("#initializeModule", async () => { + let subjectDelegatedManager: Address; + let subjectCaller: Account; + + beforeEach(async () => { + await wrapExtension.connect(owner.wallet).initializeExtension(delegatedManager.address); + + subjectDelegatedManager = delegatedManager.address; + subjectCaller = owner; + }); + + async function subject(): Promise { + return wrapExtension.connect(subjectCaller.wallet).initializeModule(subjectDelegatedManager); + } + + it("should initialize the module on the SetToken", async () => { + await subject(); + + const isModuleInitialized: Boolean = await setToken.isInitializedModule(wrapModule.address); + expect(isModuleInitialized).to.eq(true); + }); + + describe("when the sender is not the owner", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be owner"); + }); + }); + + describe("when the module is not pending or initialized", async () => { + beforeEach(async () => { + await subject(); + await delegatedManager.connect(owner.wallet).removeExtensions([wrapExtension.address]); + await delegatedManager.connect(owner.wallet).setManager(owner.address); + await setToken.connect(owner.wallet).removeModule(wrapModule.address); + await setToken.connect(owner.wallet).setManager(delegatedManager.address); + await delegatedManager.connect(owner.wallet).addExtensions([wrapExtension.address]); + await wrapExtension.connect(owner.wallet).initializeExtension(delegatedManager.address); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be pending initialization"); + }); + }); + + describe("when the module is already initialized", async () => { + beforeEach(async () => { + await subject(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be pending initialization"); + }); + }); + + describe("when the extension is not pending or initialized", async () => { + beforeEach(async () => { + await delegatedManager.connect(owner.wallet).removeExtensions([wrapExtension.address]); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be initialized extension"); + }); + }); + + describe("when the extension is pending", async () => { + beforeEach(async () => { + await delegatedManager.connect(owner.wallet).removeExtensions([wrapExtension.address]); + await delegatedManager.connect(owner.wallet).addExtensions([wrapExtension.address]); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be initialized extension"); + }); + }); + + describe("when the manager is not a ManagerCore-enabled manager", async () => { + beforeEach(async () => { + await managerCore.connect(owner.wallet).removeManager(delegatedManager.address); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be ManagerCore-enabled manager"); + }); + }); + }); + + describe("#initializeExtension", async () => { + let subjectDelegatedManager: Address; + let subjectCaller: Account; + + beforeEach(async () => { + subjectDelegatedManager = delegatedManager.address; + subjectCaller = owner; + }); + + async function subject(): Promise { + return wrapExtension.connect(subjectCaller.wallet).initializeExtension(subjectDelegatedManager); + } + + it("should store the correct SetToken and DelegatedManager on the WrapExtension", async () => { + await subject(); + + const storedDelegatedManager: Address = await wrapExtension.setManagers(setToken.address); + expect(storedDelegatedManager).to.eq(delegatedManager.address); + }); + + it("should initialize the WrapExtension on the DelegatedManager", async () => { + await subject(); + + const isExtensionInitialized: Boolean = await delegatedManager.isInitializedExtension(wrapExtension.address); + expect(isExtensionInitialized).to.eq(true); + }); + + it("should emit the correct WrapExtensionInitialized event", async () => { + await expect(subject()).to.emit(wrapExtension, "WrapExtensionInitialized").withArgs( + setToken.address, + delegatedManager.address + ); + }); + + describe("when the sender is not the owner", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be owner"); + }); + }); + + describe("when the extension is not pending or initialized", async () => { + beforeEach(async () => { + await wrapExtension.connect(owner.wallet).initializeExtension(delegatedManager.address); + await delegatedManager.connect(owner.wallet).removeExtensions([wrapExtension.address]); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Extension must be pending"); + }); + }); + + describe("when the extension is already initialized", async () => { + beforeEach(async () => { + await wrapExtension.connect(owner.wallet).initializeExtension(delegatedManager.address); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Extension must be pending"); + }); + }); + + describe("when the manager is not a ManagerCore-enabled manager", async () => { + beforeEach(async () => { + await managerCore.connect(owner.wallet).removeManager(delegatedManager.address); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be ManagerCore-enabled manager"); + }); + }); + }); + + describe("#initializeModuleAndExtension", async () => { + let subjectDelegatedManager: Address; + let subjectCaller: Account; + + beforeEach(async () => { + subjectDelegatedManager = delegatedManager.address; + subjectCaller = owner; + }); + + async function subject(): Promise { + return wrapExtension.connect(subjectCaller.wallet).initializeModuleAndExtension(subjectDelegatedManager); + } + + it("should initialize the module on the SetToken", async () => { + await subject(); + + const isModuleInitialized: Boolean = await setToken.isInitializedModule(wrapModule.address); + expect(isModuleInitialized).to.eq(true); + }); + + it("should store the correct SetToken and DelegatedManager on the WrapExtension", async () => { + await subject(); + + const storedDelegatedManager: Address = await wrapExtension.setManagers(setToken.address); + expect(storedDelegatedManager).to.eq(delegatedManager.address); + }); + + it("should initialize the WrapExtension on the DelegatedManager", async () => { + await subject(); + + const isExtensionInitialized: Boolean = await delegatedManager.isInitializedExtension(wrapExtension.address); + expect(isExtensionInitialized).to.eq(true); + }); + + it("should emit the correct WrapExtensionInitialized event", async () => { + await expect(subject()).to.emit(wrapExtension, "WrapExtensionInitialized").withArgs( + setToken.address, + delegatedManager.address + ); + }); + + describe("when the sender is not the owner", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be owner"); + }); + }); + + describe("when the module is not pending or initialized", async () => { + beforeEach(async () => { + await wrapExtension.connect(owner.wallet).initializeModuleAndExtension(delegatedManager.address); + await delegatedManager.connect(owner.wallet).removeExtensions([wrapExtension.address]); + await delegatedManager.connect(owner.wallet).setManager(owner.address); + await setToken.connect(owner.wallet).removeModule(wrapModule.address); + await setToken.connect(owner.wallet).setManager(delegatedManager.address); + await delegatedManager.connect(owner.wallet).addExtensions([wrapExtension.address]); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be pending initialization"); + }); + }); + + describe("when the module is already initialized", async () => { + beforeEach(async () => { + await wrapExtension.connect(owner.wallet).initializeModuleAndExtension(delegatedManager.address); + await delegatedManager.connect(owner.wallet).removeExtensions([wrapExtension.address]); + await delegatedManager.connect(owner.wallet).addExtensions([wrapExtension.address]); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be pending initialization"); + }); + }); + + describe("when the extension is not pending or initialized", async () => { + beforeEach(async () => { + await wrapExtension.connect(owner.wallet).initializeExtension(delegatedManager.address); + await delegatedManager.connect(owner.wallet).removeExtensions([wrapExtension.address]); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Extension must be pending"); + }); + }); + + describe("when the extension is already initialized", async () => { + beforeEach(async () => { + await wrapExtension.connect(owner.wallet).initializeExtension(delegatedManager.address); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Extension must be pending"); + }); + }); + + describe("when the manager is not a ManagerCore-enabled manager", async () => { + beforeEach(async () => { + await managerCore.connect(owner.wallet).removeManager(delegatedManager.address); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be ManagerCore-enabled manager"); + }); + }); + }); + + describe("#removeExtension", async () => { + let subjectManager: Contract; + let subjectWrapExtension: Address[]; + let subjectCaller: Account; + + beforeEach(async () => { + await wrapExtension.connect(owner.wallet).initializeExtension(delegatedManager.address); + + subjectManager = delegatedManager; + subjectWrapExtension = [wrapExtension.address]; + subjectCaller = owner; + }); + + async function subject(): Promise { + return subjectManager.connect(subjectCaller.wallet).removeExtensions(subjectWrapExtension); + } + + it("should clear SetToken and DelegatedManager from WrapExtension state", async () => { + await subject(); + + const storedDelegatedManager: Address = await wrapExtension.setManagers(setToken.address); + expect(storedDelegatedManager).to.eq(ADDRESS_ZERO); + }); + + it("should emit the correct ExtensionRemoved event", async () => { + await expect(subject()).to.emit(wrapExtension, "ExtensionRemoved").withArgs( + setToken.address, + delegatedManager.address + ); + }); + + describe("when the caller is not the SetToken manager", async () => { + beforeEach(async () => { + subjectManager = await deployer.mocks.deployManagerMock(setToken.address); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be Manager"); + }); + }); + }); + + context("when the WrapExtension is initialized and SetToken has been issued", async () => { + let setTokensIssued: BigNumber; + + before(async () => { + wrapExtension.connect(owner.wallet).initializeModuleAndExtension(delegatedManager.address); + + // Issue some Sets + setTokensIssued = ether(10); + const underlyingRequired = setTokensIssued; + await setV2Setup.weth.approve(setV2Setup.issuanceModule.address, underlyingRequired); + await setV2Setup.issuanceModule.issue(setToken.address, setTokensIssued, owner.address); + }); + + describe("#wrap", async () => { + let subjectSetToken: Address; + let subjectUnderlyingToken: Address; + let subjectWrappedToken: Address; + let subjectUnderlyingUnits: BigNumber; + let subjectIntegrationName: string; + let subjectWrapData: string; + let subjectCaller: Account; + + beforeEach(async () => { + await delegatedManager.connect(owner.wallet).addAllowedAssets([wrapAdapterMock.address]); + + subjectSetToken = setToken.address; + subjectUnderlyingToken = setV2Setup.weth.address; + subjectWrappedToken = wrapAdapterMock.address; + subjectUnderlyingUnits = ether(1); + subjectIntegrationName = wrapAdapterMockIntegrationName; + subjectWrapData = ZERO_BYTES; + subjectCaller = operator; + }); + + async function subject(): Promise { + return wrapExtension.connect(subjectCaller.wallet).wrap( + subjectSetToken, + subjectUnderlyingToken, + subjectWrappedToken, + subjectUnderlyingUnits, + subjectIntegrationName, + subjectWrapData + ); + } + + it("should mint the correct wrapped asset to the SetToken", async () => { + await subject(); + const wrappedBalance = await wrapAdapterMock.balanceOf(setToken.address); + const expectedTokenBalance = setTokensIssued; + expect(wrappedBalance).to.eq(expectedTokenBalance); + }); + + it("should reduce the correct quantity of the underlying quantity", async () => { + const previousUnderlyingBalance = await setV2Setup.weth.balanceOf(setToken.address); + + await subject(); + const underlyingTokenBalance = await setV2Setup.weth.balanceOf(setToken.address); + const expectedUnderlyingBalance = previousUnderlyingBalance.sub(setTokensIssued); + expect(underlyingTokenBalance).to.eq(expectedUnderlyingBalance); + }); + + it("remove the underlying position and replace with the wrapped token position", async () => { + await subject(); + + const positions = await setToken.getPositions(); + const receivedWrappedTokenPosition = positions[0]; + + expect(positions.length).to.eq(1); + expect(receivedWrappedTokenPosition.component).to.eq(subjectWrappedToken); + expect(receivedWrappedTokenPosition.unit).to.eq(subjectUnderlyingUnits); + }); + + 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 approved operator"); + }); + }); + + describe("when the wrapped token is not an allowed asset", async () => { + beforeEach(async () => { + await delegatedManager.connect(owner.wallet).removeAllowedAssets([wrapAdapterMock.address]); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be allowed asset"); + }); + }); + }); + + describe("#wrapWithEther", async () => { + let subjectSetToken: Address; + let subjectWrappedToken: Address; + let subjectUnderlyingUnits: BigNumber; + let subjectIntegrationName: string; + let subjectWrapData: string; + let subjectCaller: Account; + + beforeEach(async () => { + await delegatedManager.connect(owner.wallet).addAllowedAssets([wrapAdapterMock.address]); + + subjectSetToken = setToken.address; + subjectWrappedToken = wrapAdapterMock.address; + subjectUnderlyingUnits = ether(1); + subjectIntegrationName = wrapAdapterMockIntegrationName; + subjectWrapData = ZERO_BYTES; + subjectCaller = operator; + }); + + async function subject(): Promise { + return wrapExtension.connect(subjectCaller.wallet).wrapWithEther( + subjectSetToken, + subjectWrappedToken, + subjectUnderlyingUnits, + subjectIntegrationName, + subjectWrapData + ); + } + + it("should mint the correct wrapped asset to the SetToken", async () => { + await subject(); + const wrappedBalance = await wrapAdapterMock.balanceOf(setToken.address); + const expectedTokenBalance = setTokensIssued; + expect(wrappedBalance).to.eq(expectedTokenBalance); + }); + + it("should reduce the correct quantity of WETH", async () => { + const previousUnderlyingBalance = await setV2Setup.weth.balanceOf(setToken.address); + + await subject(); + const underlyingTokenBalance = await setV2Setup.weth.balanceOf(setToken.address); + const expectedUnderlyingBalance = previousUnderlyingBalance.sub(setTokensIssued); + expect(underlyingTokenBalance).to.eq(expectedUnderlyingBalance); + }); + + it("should send the correct quantity of ETH to the external protocol", async () => { + const provider = getProvider(); + const preEthBalance = await provider.getBalance(wrapAdapterMock.address); + + await subject(); + + const postEthBalance = await provider.getBalance(wrapAdapterMock.address); + expect(postEthBalance).to.eq(preEthBalance.add(preciseMul(subjectUnderlyingUnits, setTokensIssued))); + }); + + it("removes the underlying position and replace with the wrapped token position", async () => { + await subject(); + + const positions = await setToken.getPositions(); + const receivedWrappedTokenPosition = positions[0]; + + expect(positions.length).to.eq(1); + expect(receivedWrappedTokenPosition.component).to.eq(subjectWrappedToken); + expect(receivedWrappedTokenPosition.unit).to.eq(subjectUnderlyingUnits); + }); + + 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 approved operator"); + }); + }); + + describe("when the wrapped token is not an allowed asset", async () => { + beforeEach(async () => { + await delegatedManager.connect(owner.wallet).removeAllowedAssets([wrapAdapterMock.address]); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be allowed asset"); + }); + }); + }); + + describe("#unwrap", async () => { + let subjectSetToken: Address; + let subjectUnderlyingToken: Address; + let subjectWrappedToken: Address; + let subjectWrappedTokenUnits: BigNumber; + let subjectIntegrationName: string; + let subjectUnwrapData: string; + let subjectCaller: Account; + + let wrappedQuantity: BigNumber; + + beforeEach(async () => { + subjectSetToken = setToken.address; + subjectUnderlyingToken = setV2Setup.weth.address; + subjectWrappedToken = wrapAdapterMock.address; + subjectWrappedTokenUnits = ether(0.5); + subjectIntegrationName = wrapAdapterMockIntegrationName; + subjectUnwrapData = ZERO_BYTES; + subjectCaller = operator; + + wrappedQuantity = ether(1); + + await delegatedManager.connect(owner.wallet).addAllowedAssets([wrapAdapterMock.address]); + + await wrapExtension.connect(operator.wallet).wrap( + subjectSetToken, + subjectUnderlyingToken, + subjectWrappedToken, + wrappedQuantity, + subjectIntegrationName, + ZERO_BYTES + ); + }); + + async function subject(): Promise { + return wrapExtension.connect(subjectCaller.wallet).unwrap( + subjectSetToken, + subjectUnderlyingToken, + subjectWrappedToken, + subjectWrappedTokenUnits, + subjectIntegrationName, + subjectUnwrapData + ); + } + + it("should burn the correct wrapped asset to the SetToken", async () => { + await subject(); + const newWrappedBalance = await wrapAdapterMock.balanceOf(setToken.address); + const expectedTokenBalance = preciseMul(setTokensIssued, wrappedQuantity.sub(subjectWrappedTokenUnits)); + expect(newWrappedBalance).to.eq(expectedTokenBalance); + }); + + it("should properly update the underlying and wrapped token units", async () => { + await subject(); + + const positions = await setToken.getPositions(); + const [receivedWrappedPosition, receivedUnderlyingPosition] = positions; + + expect(positions.length).to.eq(2); + expect(receivedWrappedPosition.component).to.eq(subjectWrappedToken); + expect(receivedWrappedPosition.unit).to.eq(ether(0.5)); + + expect(receivedUnderlyingPosition.component).to.eq(subjectUnderlyingToken); + expect(receivedUnderlyingPosition.unit).to.eq(ether(0.5)); + }); + + 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 approved operator"); + }); + }); + + describe("when the underlying token is not an allowed asset", async () => { + beforeEach(async () => { + await delegatedManager.connect(owner.wallet).removeAllowedAssets([setV2Setup.weth.address]); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be allowed asset"); + }); + }); + }); + + describe("#unwrapWithEther", async () => { + let subjectSetToken: Address; + let subjectWrappedToken: Address; + let subjectWrappedTokenUnits: BigNumber; + let subjectIntegrationName: string; + let subjectUnwrapData: string; + let subjectCaller: Account; + + let wrappedQuantity: BigNumber; + + beforeEach(async () => { + subjectSetToken = setToken.address; + subjectWrappedToken = wrapAdapterMock.address; + subjectWrappedTokenUnits = ether(0.5); + subjectIntegrationName = wrapAdapterMockIntegrationName; + subjectUnwrapData = ZERO_BYTES; + subjectCaller = operator; + + wrappedQuantity = ether(1); + + await delegatedManager.connect(owner.wallet).addAllowedAssets([wrapAdapterMock.address]); + + await wrapExtension.connect(operator.wallet).wrapWithEther( + subjectSetToken, + subjectWrappedToken, + wrappedQuantity, + subjectIntegrationName, + ZERO_BYTES + ); + }); + + async function subject(): Promise { + return wrapExtension.connect(subjectCaller.wallet).unwrapWithEther( + subjectSetToken, + subjectWrappedToken, + subjectWrappedTokenUnits, + subjectIntegrationName, + subjectUnwrapData + ); + } + + it("should burn the correct wrapped asset to the SetToken", async () => { + await subject(); + const newWrappedBalance = await wrapAdapterMock.balanceOf(setToken.address); + const expectedTokenBalance = preciseMul(setTokensIssued, wrappedQuantity.sub(subjectWrappedTokenUnits)); + expect(newWrappedBalance).to.eq(expectedTokenBalance); + }); + + it("should properly update the underlying and wrapped token units", async () => { + await subject(); + + const positions = await setToken.getPositions(); + const [receivedWrappedPosition, receivedUnderlyingPosition] = positions; + + expect(positions.length).to.eq(2); + expect(receivedWrappedPosition.component).to.eq(subjectWrappedToken); + expect(receivedWrappedPosition.unit).to.eq(ether(0.5)); + + expect(receivedUnderlyingPosition.component).to.eq(setV2Setup.weth.address); + expect(receivedUnderlyingPosition.unit).to.eq(ether(0.5)); + }); + + it("should have sent the correct quantity of ETH to the SetToken", async () => { + const provider = getProvider(); + const preEthBalance = await provider.getBalance(wrapAdapterMock.address); + + await subject(); + + const postEthBalance = await provider.getBalance(wrapAdapterMock.address); + expect(postEthBalance).to.eq(preEthBalance.sub(preciseMul(subjectWrappedTokenUnits, setTokensIssued))); + }); + + 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 approved operator"); + }); + }); + + describe("when the underlying token is not an allowed asset", async () => { + beforeEach(async () => { + await delegatedManager.connect(owner.wallet).removeAllowedAssets([setV2Setup.weth.address]); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be allowed asset"); + }); + }); + }); + }); +}); \ No newline at end of file diff --git a/utils/contracts/index.ts b/utils/contracts/index.ts index 3282be6..3bfe196 100644 --- a/utils/contracts/index.ts +++ b/utils/contracts/index.ts @@ -18,4 +18,6 @@ export { TradeExtension } from "../../typechain/TradeExtension"; export { IssuanceExtension } from "../../typechain/IssuanceExtension"; export { StreamingFeeSplitExtension } from "../../typechain/StreamingFeeSplitExtension"; export { BatchTradeExtension } from "../../typechain/BatchTradeExtension"; -export { TradeAdapterMock } from "../../typechain/TradeAdapterMock"; \ No newline at end of file +export { TradeAdapterMock } from "../../typechain/TradeAdapterMock"; +export { WrapExtension } from "../../typechain/WrapExtension"; +export { ClaimExtension } from "../../typechain/ClaimExtension"; \ No newline at end of file diff --git a/utils/deploys/deployGlobalExtensions.ts b/utils/deploys/deployGlobalExtensions.ts index f30c8f7..e470582 100644 --- a/utils/deploys/deployGlobalExtensions.ts +++ b/utils/deploys/deployGlobalExtensions.ts @@ -2,15 +2,19 @@ import { Signer } from "ethers"; import { Address } from "../types"; import { BatchTradeExtension, + ClaimExtension, IssuanceExtension, StreamingFeeSplitExtension, - TradeExtension + TradeExtension, + WrapExtension } from "../contracts/index"; import { BatchTradeExtension__factory } from "../../typechain/factories/BatchTradeExtension__factory"; +import { ClaimExtension__factory } from "../../typechain/factories/ClaimExtension__factory"; import { IssuanceExtension__factory } from "../../typechain/factories/IssuanceExtension__factory"; import { StreamingFeeSplitExtension__factory } from "../../typechain/factories/StreamingFeeSplitExtension__factory"; import { TradeExtension__factory } from "../../typechain/factories/TradeExtension__factory"; +import { WrapExtension__factory } from "../../typechain/factories/WrapExtension__factory"; export default class DeployGlobalExtensions { private _deployerSigner: Signer; @@ -31,6 +35,20 @@ export default class DeployGlobalExtensions { ); } + public async deployClaimExtension( + managerCore: Address, + airdropModule: Address, + claimModule: Address, + integrationRegistry: Address + ): Promise { + return await new ClaimExtension__factory(this._deployerSigner).deploy( + managerCore, + airdropModule, + claimModule, + integrationRegistry + ); + } + public async deployIssuanceExtension( managerCore: Address, basicIssuanceModule: Address @@ -60,4 +78,14 @@ export default class DeployGlobalExtensions { tradeModule, ); } + + public async deployWrapExtension( + managerCore: Address, + wrapModule: Address + ): Promise { + return await new WrapExtension__factory(this._deployerSigner).deploy( + managerCore, + wrapModule, + ); + } } \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index e08b0a2..e10a19c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1503,10 +1503,10 @@ "@sentry/types" "5.29.2" tslib "^1.9.3" -"@setprotocol/set-protocol-v2@0.10.0-hhat.1": - version "0.10.0-hhat.1" - resolved "https://registry.yarnpkg.com/@setprotocol/set-protocol-v2/-/set-protocol-v2-0.10.0-hhat.1.tgz#933dc1ad9599ddf39796ce5785e0cd4ec0e7ba5d" - integrity sha512-wjTIHUzsAmL8u01XsOALJp62ceznVPK420zfpNK6Kv/mK56UKZWq3ptzHbnhjujU1vDfiNylH+enEHjUYiBogQ== +"@setprotocol/set-protocol-v2@^0.10.3-hhat.1": + version "0.10.3-hhat.1" + resolved "https://registry.yarnpkg.com/@setprotocol/set-protocol-v2/-/set-protocol-v2-0.10.3-hhat.1.tgz#5115dabb7c112e15ab362f106ef0e842c9fdfa94" + integrity sha512-vQwFeCmC8dNE5J1OXsHcJIWIbB0qoXjopFKpVu+OXxTfcoKh1k/zVgq+khQvPnughGE56c2pqspAa8XA9nMLYw== dependencies: "@uniswap/v3-sdk" "^3.5.1" ethers "^5.5.2"