diff --git a/contracts/protocol/modules/GeneralIndexModuleV2.sol b/contracts/protocol/modules/GeneralIndexModuleV2.sol new file mode 100644 index 000000000..5a2c5c614 --- /dev/null +++ b/contracts/protocol/modules/GeneralIndexModuleV2.sol @@ -0,0 +1,1221 @@ +/* + Copyright 2021 Set Labs Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + SPDX-License-Identifier: Apache License, Version 2.0 +*/ + +pragma solidity 0.6.10; +pragma experimental "ABIEncoderV2"; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { Math } from "@openzeppelin/contracts/math/Math.sol"; +import { ReentrancyGuard } from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import { SafeCast } from "@openzeppelin/contracts/utils/SafeCast.sol"; +import { SafeMath } from "@openzeppelin/contracts/math/SafeMath.sol"; + +import { AddressArrayUtils } from "../../lib/AddressArrayUtils.sol"; +import { IController } from "../../interfaces/IController.sol"; +import { IIndexExchangeAdapter } from "../../interfaces/IIndexExchangeAdapter.sol"; +import { Invoke } from "../lib/Invoke.sol"; +import { ISetToken } from "../../interfaces/ISetToken.sol"; +import { IWETH } from "../../interfaces/external/IWETH.sol"; +import { ModuleBase } from "../lib/ModuleBase.sol"; +import { Position } from "../lib/Position.sol"; +import { PreciseUnitMath } from "../../lib/PreciseUnitMath.sol"; +import { Uint256ArrayUtils } from "../../lib/Uint256ArrayUtils.sol"; + + +/** + * @title GeneralIndexModuleV2 + * @author Set Protocol + * + * Smart contract that facilitates rebalances for indices. Manager can update allocation by calling startRebalance(). + * There is no "end" to a rebalance, however once there are no more tokens to sell the rebalance is effectively over + * until the manager calls startRebalance() again with a new allocation. Once a new allocation is passed in, allowed + * traders can submit rebalance transactions by calling trade() and specifying the component they wish to rebalance. + * All parameterizations for a trade are set by the manager ahead of time, including max trade size, coolOffPeriod bet- + * ween trades, and exchange to trade on. WETH is used as the quote asset for all trades, near the end of rebalance + * tradeRemaingingWETH() or raiseAssetTargets() can be called to clean up any excess WETH positions. Once a component's + * target allocation is met any further attempted trades of that component will revert. + * + * SECURITY ASSUMPTION: + * - Works with following modules: StreamingFeeModule, BasicIssuanceModule (any other module additions to Sets using + * this module need to be examined separately) + * + * Note: TradeModuleV2 will allow governance to specify a protocol fee and rebate split percentage + * which is sent to the manager's address. The fee rebates will be automatic per trade. + * + */ +contract GeneralIndexModuleV2 is ModuleBase, ReentrancyGuard { + using SafeCast for int256; + using SafeCast for uint256; + using SafeMath for uint256; + using Position for uint256; + using Math for uint256; + using Position for ISetToken; + using Invoke for ISetToken; + using AddressArrayUtils for address[]; + using AddressArrayUtils for IERC20[]; + using Uint256ArrayUtils for uint256[]; + + /* ============ Struct ============ */ + + struct TradeExecutionParams { + uint256 targetUnit; // Target unit of component for Set + uint256 maxSize; // Max trade size in precise units + uint256 coolOffPeriod; // Required time between trades for the asset + uint256 lastTradeTimestamp; // Timestamp of last trade + string exchangeName; // Name of exchange adapter + bytes exchangeData; // Arbitrary data that can be used to encode exchange specific settings (fee tier) or features (multi-hop) + } + + struct TradePermissionInfo { + bool anyoneTrade; // Boolean indicating if anyone can execute a trade + address[] tradersHistory; // Tracks permissioned traders to be deleted on module removal + mapping(address => bool) tradeAllowList; // Mapping indicating which addresses are allowed to execute trade + } + + struct RebalanceInfo { + uint256 positionMultiplier; // Position multiplier at the beginning of rebalance + uint256 raiseTargetPercentage; // Amount to raise all unit targets by if allowed (in precise units) + address[] rebalanceComponents; // Array of components involved in rebalance + } + + struct TradeInfo { + ISetToken setToken; // Instance of SetToken + IIndexExchangeAdapter exchangeAdapter; // Instance of Exchange Adapter + address sendToken; // Address of token being sold + address receiveToken; // Address of token being bought + bool isSendTokenFixed; // Boolean indicating fixed asset is send token + uint256 setTotalSupply; // Total supply of Set (in precise units) + uint256 totalFixedQuantity; // Total quantity of fixed asset being traded + uint256 sendQuantity; // Units of component sent to the exchange + uint256 floatingQuantityLimit; // Max/min amount of floating token spent/received during trade + uint256 preTradeSendTokenBalance; // Total initial balance of token being sold + uint256 preTradeReceiveTokenBalance; // Total initial balance of token being bought + bytes exchangeData; // Arbitrary data for executing trade on given exchange + } + + /* ============ Events ============ */ + + event TradeMaximumUpdated(ISetToken indexed _setToken, address indexed _component, uint256 _newMaximum); + event AssetExchangeUpdated(ISetToken indexed _setToken, address indexed _component, string _newExchangeName); + event CoolOffPeriodUpdated(ISetToken indexed _setToken, address indexed _component, uint256 _newCoolOffPeriod); + event ExchangeDataUpdated(ISetToken indexed _setToken, address indexed _component, bytes _newExchangeData); + event RaiseTargetPercentageUpdated(ISetToken indexed _setToken, uint256 indexed _raiseTargetPercentage); + event AssetTargetsRaised(ISetToken indexed _setToken, uint256 indexed positionMultiplier); + + event AnyoneTradeUpdated(ISetToken indexed _setToken, bool indexed _status); + event TraderStatusUpdated(ISetToken indexed _setToken, address indexed _trader, bool _status); + + event TradeExecuted( + ISetToken indexed _setToken, + address indexed _sellComponent, + address indexed _buyComponent, + IIndexExchangeAdapter _exchangeAdapter, + address _executor, + uint256 _netAmountSold, + uint256 _netAmountReceived, + uint256 _protocolFee, + uint256 _managerRebate + ); + + event RebalanceStarted( + ISetToken indexed _setToken, + address[] aggregateComponents, + uint256[] aggregateTargetUnits, + uint256 indexed positionMultiplier + ); + + event FeeRecipientUpdated(ISetToken indexed _setToken, address _newFeeRecipient); + + /* ============ Constants ============ */ + + uint256 constant internal GENERAL_INDEX_MODULE_V2_TOTAL_FEE_INDEX = 0; // 0 index stores the total fee % charged in the trade function + uint256 constant internal GENERAL_INDEX_MODULE_V2_MANAGER_REBATE_SPLIT_INDEX = 1; // 1 index stores the % of total fees that the manager receives back as rebates + + /* ============ State Variables ============ */ + + mapping(ISetToken => address) public managerRebateRecipient; // Mapping to efficiently identify a manager rebate recipient address for a given SetToken + mapping(ISetToken => mapping(IERC20 => TradeExecutionParams)) public executionInfo; // Mapping of SetToken to execution parameters of each asset on SetToken + mapping(ISetToken => TradePermissionInfo) public permissionInfo; // Mapping of SetToken to trading permissions + mapping(ISetToken => RebalanceInfo) public rebalanceInfo; // Mapping of SetToken to relevant data for current rebalance + IWETH public immutable weth; // Weth contract address + + /* ============ Modifiers ============ */ + + modifier onlyAllowedTrader(ISetToken _setToken) { + _validateOnlyAllowedTrader(_setToken); + _; + } + + modifier onlyEOAIfUnrestricted(ISetToken _setToken) { + _validateOnlyEOAIfUnrestricted(_setToken); + _; + } + + /* ============ Constructor ============ */ + + constructor(IController _controller, IWETH _weth) public ModuleBase(_controller) { + weth = _weth; + } + + /* ============ External Functions ============ */ + + /** + * MANAGER ONLY: Changes the target allocation of the Set, opening it up for trading by the Sets designated traders. The manager + * must pass in any new components and their target units (units defined by the amount of that component the manager wants in 10**18 + * units of a SetToken). Old component target units must be passed in, in the current order of the components array on the + * SetToken. If a component is being removed it's index in the _oldComponentsTargetUnits should be set to 0. Additionally, the + * positionMultiplier is passed in, in order to adjust the target units in the event fees are accrued or some other activity occurs + * that changes the positionMultiplier of the Set. This guarantees the same relative allocation between all the components. + * + * @param _setToken Address of the SetToken to be rebalanced + * @param _newComponents Array of new components to add to allocation + * @param _newComponentsTargetUnits Array of target units at end of rebalance for new components, maps to same index of _newComponents array + * @param _oldComponentsTargetUnits Array of target units at end of rebalance for old component, maps to same index of + * _setToken.getComponents() array, if component being removed set to 0. + * @param _positionMultiplier Position multiplier when target units were calculated, needed in order to adjust target units + * if fees accrued + */ + function startRebalance( + ISetToken _setToken, + address[] calldata _newComponents, + uint256[] calldata _newComponentsTargetUnits, + uint256[] calldata _oldComponentsTargetUnits, + uint256 _positionMultiplier + ) + external + onlyManagerAndValidSet(_setToken) + { + ( address[] memory aggregateComponents, uint256[] memory aggregateTargetUnits ) = _getAggregateComponentsAndUnits( + _setToken.getComponents(), + _newComponents, + _newComponentsTargetUnits, + _oldComponentsTargetUnits + ); + + for (uint256 i = 0; i < aggregateComponents.length; i++) { + require(!_setToken.hasExternalPosition(aggregateComponents[i]), "External positions not allowed"); + executionInfo[_setToken][IERC20(aggregateComponents[i])].targetUnit = aggregateTargetUnits[i]; + } + + rebalanceInfo[_setToken].rebalanceComponents = aggregateComponents; + rebalanceInfo[_setToken].positionMultiplier = _positionMultiplier; + + emit RebalanceStarted(_setToken, aggregateComponents, aggregateTargetUnits, _positionMultiplier); + } + + /** + * ACCESS LIMITED: Calling trade() pushes the current component units closer to the target units defined by the manager in startRebalance(). + * Only approved addresses can call, if anyoneTrade is false then contracts are allowed to call otherwise calling address must be EOA. + * + * Trade can be called at anytime but will revert if the passed component's target unit is met or cool off period hasn't passed. Trader can pass + * in a max/min amount of ETH spent/received in the trade based on if the component is being bought/sold in order to prevent sandwich attacks. + * The parameters defined by the manager are used to determine which exchange will be used and the size of the trade. Trade size will default + * to max trade size unless the max trade size would exceed the target, then an amount that would match the target unit is traded. Protocol fees, + * if enabled, are collected in the token received in a trade. + * + * @param _setToken Address of the SetToken + * @param _component Address of SetToken component to trade + * @param _ethQuantityLimit Max/min amount of ETH spent/received during trade + */ + function trade( + ISetToken _setToken, + IERC20 _component, + uint256 _ethQuantityLimit + ) + external + nonReentrant + onlyAllowedTrader(_setToken) + onlyEOAIfUnrestricted(_setToken) + virtual + { + _validateTradeParameters(_setToken, _component); + + TradeInfo memory tradeInfo = _createTradeInfo(_setToken, _component, _ethQuantityLimit); + + _executeTrade(tradeInfo); + + (uint256 protocolFee, uint256 managerRebate) = _accrueFees(tradeInfo); + + (uint256 netSendAmount, uint256 netReceiveAmount) = _updatePositionStateAndTimestamp(tradeInfo, _component); + + emit TradeExecuted( + tradeInfo.setToken, + tradeInfo.sendToken, + tradeInfo.receiveToken, + tradeInfo.exchangeAdapter, + msg.sender, + netSendAmount, + netReceiveAmount, + protocolFee, + managerRebate + ); + } + + /** + * ACCESS LIMITED: Only callable when 1) there are no more components to be sold and, 2) entire remaining WETH amount (above WETH target) can be + * traded such that resulting inflows won't exceed component's maxTradeSize nor overshoot the target unit. To be used near the end of rebalances + * when a component's calculated trade size is greater in value than remaining WETH. + * + * Only approved addresses can call, if anyoneTrade is false then contracts are allowed to call otherwise calling address must be EOA. Trade + * can be called at anytime but will revert if the passed component's target unit is met or cool off period hasn't passed. Like with trade() + * a minimum component receive amount can be set. + * + * @param _setToken Address of the SetToken + * @param _component Address of the SetToken component to trade + * @param _minComponentReceived Min amount of component received during trade + */ + function tradeRemainingWETH( + ISetToken _setToken, + IERC20 _component, + uint256 _minComponentReceived + ) + external + nonReentrant + onlyAllowedTrader(_setToken) + onlyEOAIfUnrestricted(_setToken) + virtual + { + require(_noTokensToSell(_setToken), "Sell other set components first"); + require( + executionInfo[_setToken][weth].targetUnit < _getDefaultPositionRealUnit(_setToken, weth), + "WETH is below target unit" + ); + + _validateTradeParameters(_setToken, _component); + + TradeInfo memory tradeInfo = _createTradeRemainingInfo(_setToken, _component, _minComponentReceived); + + _executeTrade(tradeInfo); + + (uint256 protocolFee, uint256 managerRebate) = _accrueFees(tradeInfo); + (uint256 netSendAmount, uint256 netReceiveAmount) = _updatePositionStateAndTimestamp(tradeInfo, _component); + + require( + netReceiveAmount.add(protocolFee).add(managerRebate) < executionInfo[_setToken][_component].maxSize, + "Trade amount > max trade size" + ); + + _validateComponentPositionUnit(_setToken, _component); + + emit TradeExecuted( + tradeInfo.setToken, + tradeInfo.sendToken, + tradeInfo.receiveToken, + tradeInfo.exchangeAdapter, + msg.sender, + netSendAmount, + netReceiveAmount, + protocolFee, + managerRebate + ); + } + + /** + * ACCESS LIMITED: For situation where all target units met and remaining WETH, uniformly raise targets by same percentage by applying + * to logged positionMultiplier in RebalanceInfo struct, in order to allow further trading. Can be called multiple times if necessary, + * targets are increased by amount specified by raiseAssetTargetsPercentage as set by manager. In order to reduce tracking error + * raising the target by a smaller amount allows greater granularity in finding an equilibrium between the excess ETH and components + * that need to be bought. Raising the targets too much could result in vastly under allocating to WETH as more WETH than necessary is + * spent buying the components to meet their new target. + * + * @param _setToken Address of the SetToken + */ + function raiseAssetTargets(ISetToken _setToken) external onlyAllowedTrader(_setToken) virtual { + require( + _allTargetsMet(_setToken) + && _getDefaultPositionRealUnit(_setToken, weth) > _getNormalizedTargetUnit(_setToken, weth), + "Targets not met or ETH =~ 0" + ); + + // positionMultiplier / (10^18 + raiseTargetPercentage) + // ex: (10 ** 18) / ((10 ** 18) + ether(.0025)) => 997506234413965087 + rebalanceInfo[_setToken].positionMultiplier = rebalanceInfo[_setToken].positionMultiplier.preciseDiv( + PreciseUnitMath.preciseUnit().add(rebalanceInfo[_setToken].raiseTargetPercentage) + ); + emit AssetTargetsRaised(_setToken, rebalanceInfo[_setToken].positionMultiplier); + } + + /** + * MANAGER ONLY: Set trade maximums for passed components of the SetToken. Can be called at anytime. + * Note: Trade maximums must be set before rebalance can begin properly - they are zero by + * default and trades will not execute if a component's trade size is greater than the maximum. + * + * @param _setToken Address of the SetToken + * @param _components Array of components + * @param _tradeMaximums Array of trade maximums mapping to correct component + */ + function setTradeMaximums( + ISetToken _setToken, + address[] memory _components, + uint256[] memory _tradeMaximums + ) + external + onlyManagerAndValidSet(_setToken) + { + _components.validatePairsWithArray(_tradeMaximums); + + for (uint256 i = 0; i < _components.length; i++) { + executionInfo[_setToken][IERC20(_components[i])].maxSize = _tradeMaximums[i]; + emit TradeMaximumUpdated(_setToken, _components[i], _tradeMaximums[i]); + } + } + + /** + * MANAGER ONLY: Set exchange for passed components of the SetToken. Can be called at anytime. + * + * @param _setToken Address of the SetToken + * @param _components Array of components + * @param _exchangeNames Array of exchange names mapping to correct component + */ + function setExchanges( + ISetToken _setToken, + address[] memory _components, + string[] memory _exchangeNames + ) + external + onlyManagerAndValidSet(_setToken) + { + _components.validatePairsWithArray(_exchangeNames); + + for (uint256 i = 0; i < _components.length; i++) { + if (_components[i] != address(weth)) { + + require( + controller.getIntegrationRegistry().isValidIntegration(address(this), _exchangeNames[i]), + "Unrecognized exchange name" + ); + + executionInfo[_setToken][IERC20(_components[i])].exchangeName = _exchangeNames[i]; + emit AssetExchangeUpdated(_setToken, _components[i], _exchangeNames[i]); + } + } + } + + /** + * MANAGER ONLY: Set cool off periods for passed components of the SetToken. Can be called at any time. + * + * @param _setToken Address of the SetToken + * @param _components Array of components + * @param _coolOffPeriods Array of cool off periods to correct component + */ + function setCoolOffPeriods( + ISetToken _setToken, + address[] memory _components, + uint256[] memory _coolOffPeriods + ) + external + onlyManagerAndValidSet(_setToken) + { + _components.validatePairsWithArray(_coolOffPeriods); + + for (uint256 i = 0; i < _components.length; i++) { + executionInfo[_setToken][IERC20(_components[i])].coolOffPeriod = _coolOffPeriods[i]; + emit CoolOffPeriodUpdated(_setToken, _components[i], _coolOffPeriods[i]); + } + } + + /** + * MANAGER ONLY: Set arbitrary byte data on a per asset basis that can be used to pass exchange specific settings (i.e. specifying + * fee tiers) or exchange specific features (enabling multi-hop trades). Can be called at any time. + * + * @param _setToken Address of the SetToken + * @param _components Array of components + * @param _exchangeData Array of exchange specific arbitrary bytes data + */ + function setExchangeData( + ISetToken _setToken, + address[] memory _components, + bytes[] memory _exchangeData + ) + external + onlyManagerAndValidSet(_setToken) + { + _components.validatePairsWithArray(_exchangeData); + + for (uint256 i = 0; i < _components.length; i++) { + executionInfo[_setToken][IERC20(_components[i])].exchangeData = _exchangeData[i]; + emit ExchangeDataUpdated(_setToken, _components[i], _exchangeData[i]); + } + } + + /** + * MANAGER ONLY: Set amount by which all component's targets units would be raised. Can be called at any time. + * + * @param _setToken Address of the SetToken + * @param _raiseTargetPercentage Amount to raise all component's unit targets by (in precise units) + */ + function setRaiseTargetPercentage( + ISetToken _setToken, + uint256 _raiseTargetPercentage + ) + external + onlyManagerAndValidSet(_setToken) + { + require(_raiseTargetPercentage > 0, "Target percentage must be > 0"); + rebalanceInfo[_setToken].raiseTargetPercentage = _raiseTargetPercentage; + emit RaiseTargetPercentageUpdated(_setToken, _raiseTargetPercentage); + } + + /** + * MANAGER ONLY: Toggles ability for passed addresses to call trade() or tradeRemainingWETH(). Can be called at any time. + * + * @param _setToken Address of the SetToken + * @param _traders Array trader addresses to toggle status + * @param _statuses Booleans indicating if matching trader can trade + */ + function setTraderStatus( + ISetToken _setToken, + address[] memory _traders, + bool[] memory _statuses + ) + external + onlyManagerAndValidSet(_setToken) + { + _traders.validatePairsWithArray(_statuses); + + for (uint256 i = 0; i < _traders.length; i++) { + _updateTradersHistory(_setToken, _traders[i], _statuses[i]); + permissionInfo[_setToken].tradeAllowList[_traders[i]] = _statuses[i]; + emit TraderStatusUpdated(_setToken, _traders[i], _statuses[i]); + } + } + + /** + * MANAGER ONLY: Toggle whether anyone can trade, if true bypasses the traderAllowList. Can be called at anytime. + * + * @param _setToken Address of the SetToken + * @param _status Boolean indicating if anyone can trade + */ + function setAnyoneTrade(ISetToken _setToken, bool _status) external onlyManagerAndValidSet(_setToken) { + permissionInfo[_setToken].anyoneTrade = _status; + emit AnyoneTradeUpdated(_setToken, _status); + } + + /** + * MANAGER ONLY: Updates address receiving manager rebate fees for a given SetToken. + * + * @param _setToken Instance of the SetToken to update fee recipient + * @param _newRebateRecipient New rebate fee recipient address + */ + function updateFeeRecipient( + ISetToken _setToken, + address _newRebateRecipient + ) + external + onlyManagerAndValidSet(_setToken) + { + require(_newRebateRecipient != address(0), "Recipient must be non-zero address."); + require(_newRebateRecipient != managerRebateRecipient[_setToken], "Same fee recipient passed"); + + managerRebateRecipient[_setToken] = _newRebateRecipient; + + emit FeeRecipientUpdated(_setToken, _newRebateRecipient); + } + + /** + * MANAGER ONLY: Called to initialize module to SetToken in order to allow GeneralIndexModuleV2 access for rebalances. + * Grabs the current units for each asset in the Set and set's the targetUnit to that unit in order to prevent any + * trading until startRebalance() is explicitly called. Position multiplier is also logged in order to make sure any + * position multiplier changes don't unintentionally open the Set for rebalancing. + * + * @param _setToken Address of the Set Token + */ + function initialize( + ISetToken _setToken, + address _managerRebateRecipient + ) + external + onlySetManager(_setToken, msg.sender) + onlyValidAndPendingSet(_setToken) + { + require(_managerRebateRecipient != address(0), "Recipient must be non-zero address."); + + managerRebateRecipient[_setToken] = _managerRebateRecipient; + + ISetToken.Position[] memory positions = _setToken.getPositions(); + + for (uint256 i = 0; i < positions.length; i++) { + ISetToken.Position memory position = positions[i]; + require(position.positionState == 0, "External positions not allowed"); + executionInfo[_setToken][IERC20(position.component)].targetUnit = position.unit.toUint256(); + executionInfo[_setToken][IERC20(position.component)].lastTradeTimestamp = 0; + } + + rebalanceInfo[_setToken].positionMultiplier = _setToken.positionMultiplier().toUint256(); + _setToken.initializeModule(); + } + + /** + * Called by a SetToken to notify that this module was removed from the SetToken. Remove the manager rebate recipient address. + * Clears the rebalanceInfo and permissionsInfo of the calling SetToken. + * IMPORTANT: SetToken's execution settings, including trade maximums and exchange names, + * are NOT DELETED. Restoring a previously removed module requires that care is taken to + * initialize execution settings appropriately. + */ + function removeModule() external override { + TradePermissionInfo storage tokenPermissionInfo = permissionInfo[ISetToken(msg.sender)]; + + for (uint i = 0; i < tokenPermissionInfo.tradersHistory.length; i++) { + tokenPermissionInfo.tradeAllowList[tokenPermissionInfo.tradersHistory[i]] = false; + } + + delete rebalanceInfo[ISetToken(msg.sender)]; + delete permissionInfo[ISetToken(msg.sender)]; + delete managerRebateRecipient[ISetToken(msg.sender)]; + } + + /* ============ External View Functions ============ */ + + /** + * Get the array of SetToken components involved in rebalance. + * + * @param _setToken Address of the SetToken + * + * @return address[] Array of _setToken components involved in rebalance + */ + function getRebalanceComponents(ISetToken _setToken) + external + view + onlyValidAndInitializedSet(_setToken) + returns (address[] memory) + { + return rebalanceInfo[_setToken].rebalanceComponents; + } + + /** + * Calculates the amount of a component that is going to be traded and whether the component is being bought + * or sold. If currentUnit and targetUnit are the same, function will revert. + * + * @param _setToken Instance of the SetToken to rebalance + * @param _component IERC20 component to trade + * + * @return isSendTokenFixed Boolean indicating fixed asset is send token + * @return componentQuantity Amount of component being traded + */ + function getComponentTradeQuantityAndDirection( + ISetToken _setToken, + IERC20 _component + ) + external + view + onlyValidAndInitializedSet(_setToken) + returns (bool, uint256) + { + require( + rebalanceInfo[_setToken].rebalanceComponents.contains(address(_component)), + "Component not recognized" + ); + uint256 totalSupply = _setToken.totalSupply(); + return _calculateTradeSizeAndDirection(_setToken, _component, totalSupply); + } + + + /** + * Get if a given address is an allowed trader. + * + * @param _setToken Address of the SetToken + * @param _trader Address of the trader + * + * @return bool True if _trader is allowed to trade, else false + */ + function getIsAllowedTrader(ISetToken _setToken, address _trader) + external + view + onlyValidAndInitializedSet(_setToken) + returns (bool) + { + return _isAllowedTrader(_setToken, _trader); + } + + /** + * Get the list of traders who are allowed to call trade(), tradeRemainingWeth(), and raiseAssetTarget() + * + * @param _setToken Address of the SetToken + * + * @return address[] + */ + function getAllowedTraders(ISetToken _setToken) + external + view + onlyValidAndInitializedSet(_setToken) + returns (address[] memory) + { + return permissionInfo[_setToken].tradersHistory; + } + + /* ============ Internal Functions ============ */ + + /** + * A rebalance is a multi-step process in which current Set components are sold for a + * bridge asset (WETH) before buying target components in the correct amount to achieve + * the desired balance between elements in the set. + * + * Step 1 | Step 2 + * ------------------------------------------- + * Component --> WETH | WETH --> Component + * ------------------------------------------- + * + * The syntax we use frames this as trading from a "fixed" amount of one component to a + * "fixed" amount of another via a "floating limit" which is *either* the maximum size of + * the trade we want to make (trades may be tranched to avoid moving markets) OR the minimum + * amount of tokens we expect to receive. The different meanings of the floating limit map to + * the trade sequence as below: + * + * Step 1: Component --> WETH + * ---------------------------------------------------------- + * | Fixed | Floating limit | + * ---------------------------------------------------------- + * send (Component) | YES | | + * recieve (WETH) | | Min WETH to receive | + * ---------------------------------------------------------- + * + * Step 2: WETH --> Component + * ---------------------------------------------------------- + * | Fixed | Floating limit | + * ---------------------------------------------------------- + * send (WETH) | NO | Max WETH to send | + * recieve (Component) | YES | | + * ---------------------------------------------------------- + * + * Additionally, there is an edge case where price volatility during a rebalance + * results in remaining WETH which needs to be allocated proportionately. In this case + * the values are as below: + * + * Edge case: Remaining WETH --> Component + * ---------------------------------------------------------- + * | Fixed | Floating limit | + * ---------------------------------------------------------- + * send (WETH) | YES | | + * recieve (Component) | | Min component to receive | + * ---------------------------------------------------------- + */ + + /** + * Create and return TradeInfo struct. This function reverts if the target has already been met. + * If this is a trade from component into WETH, sell the total fixed component quantity + * and expect to receive an ETH amount the user has specified (or more). If it's a trade from + * WETH into a component, sell the lesser of: the user's WETH limit OR the SetToken's + * remaining WETH balance and expect to receive a fixed component quantity. + * + * @param _setToken Instance of the SetToken to rebalance + * @param _component IERC20 component to trade + * @param _ethQuantityLimit Max/min amount of weth spent/received during trade + * + * @return tradeInfo Struct containing data for trade + */ + function _createTradeInfo( + ISetToken _setToken, + IERC20 _component, + uint256 _ethQuantityLimit + ) + internal + view + virtual + returns (TradeInfo memory tradeInfo) + { + tradeInfo = _getDefaultTradeInfo(_setToken, _component, true); + + if (tradeInfo.isSendTokenFixed){ + tradeInfo.sendQuantity = tradeInfo.totalFixedQuantity; + tradeInfo.floatingQuantityLimit = _ethQuantityLimit; + } else { + tradeInfo.sendQuantity = _ethQuantityLimit.min(tradeInfo.preTradeSendTokenBalance); + tradeInfo.floatingQuantityLimit = tradeInfo.totalFixedQuantity; + } + } + + /** + * Create and return TradeInfo struct. This function does NOT check if the WETH target has been met. + * + * @param _setToken Instance of the SetToken to rebalance + * @param _component IERC20 component to trade + * @param _minComponentReceived Min amount of component received during trade + * + * @return tradeInfo Struct containing data for tradeRemaining info + */ + function _createTradeRemainingInfo( + ISetToken _setToken, + IERC20 _component, + uint256 _minComponentReceived + ) + internal + view + returns (TradeInfo memory tradeInfo) + { + tradeInfo = _getDefaultTradeInfo(_setToken, _component, false); + + (,, + uint256 currentNotional, + uint256 targetNotional + ) = _getUnitsAndNotionalAmounts(_setToken, weth, tradeInfo.setTotalSupply); + + tradeInfo.sendQuantity = currentNotional.sub(targetNotional); + tradeInfo.floatingQuantityLimit = _minComponentReceived; + tradeInfo.isSendTokenFixed = true; + } + + /** + * Create and returns a partial TradeInfo struct with all fields that overlap between `trade` + * and `tradeRemaining` info constructors filled in. Values for `sendQuantity` and `floatingQuantityLimit` + * are derived separately, outside this method. `trade` requires that trade size and direction are + * calculated, whereas `tradeRemaining` automatically sets WETH as the sendToken and _component + * as receiveToken. + * + * @param _setToken Instance of the SetToken to rebalance + * @param _component IERC20 component to trade + * @param calculateTradeDirection Indicates whether method should calculate trade size and direction + * + * @return tradeInfo Struct containing partial data for trade + */ + function _getDefaultTradeInfo(ISetToken _setToken, IERC20 _component, bool calculateTradeDirection) + internal + view + returns (TradeInfo memory tradeInfo) + { + tradeInfo.setToken = _setToken; + tradeInfo.setTotalSupply = _setToken.totalSupply(); + tradeInfo.exchangeAdapter = _getExchangeAdapter(_setToken, _component); + tradeInfo.exchangeData = executionInfo[_setToken][_component].exchangeData; + + if(calculateTradeDirection){ + ( + tradeInfo.isSendTokenFixed, + tradeInfo.totalFixedQuantity + ) = _calculateTradeSizeAndDirection(_setToken, _component, tradeInfo.setTotalSupply); + } + + if (tradeInfo.isSendTokenFixed){ + tradeInfo.sendToken = address(_component); + tradeInfo.receiveToken = address(weth); + } else { + tradeInfo.sendToken = address(weth); + tradeInfo.receiveToken = address(_component); + } + + tradeInfo.preTradeSendTokenBalance = IERC20(tradeInfo.sendToken).balanceOf(address(_setToken)); + tradeInfo.preTradeReceiveTokenBalance = IERC20(tradeInfo.receiveToken).balanceOf(address(_setToken)); + } + + /** + * Function handles all interactions with exchange. All GeneralIndexModuleV2 adapters must allow for selling or buying a fixed + * quantity of a token in return for a non-fixed (floating) quantity of a token. If `isSendTokenFixed` is true then the adapter + * will choose the exchange interface associated with inputting a fixed amount, otherwise it will select the interface used for + * receiving a fixed amount. Any other exchange specific data can also be created by calling generateDataParam function. + * + * @param _tradeInfo Struct containing trade information used in internal functions + */ + function _executeTrade(TradeInfo memory _tradeInfo) internal virtual { + _tradeInfo.setToken.invokeApprove( + _tradeInfo.sendToken, + _tradeInfo.exchangeAdapter.getSpender(), + _tradeInfo.sendQuantity + ); + + ( + address targetExchange, + uint256 callValue, + bytes memory methodData + ) = _tradeInfo.exchangeAdapter.getTradeCalldata( + _tradeInfo.sendToken, + _tradeInfo.receiveToken, + address(_tradeInfo.setToken), + _tradeInfo.isSendTokenFixed, + _tradeInfo.sendQuantity, + _tradeInfo.floatingQuantityLimit, + _tradeInfo.exchangeData + ); + + _tradeInfo.setToken.invoke(targetExchange, callValue, methodData); + } + + /** + * Retrieve fee from controller and calculate total protocol fee and send from SetToken to protocol recipient. + * The protocol fee is collected from the amount of received token in the trade. + * + * @param _tradeInfo Struct containing trade information used in internal functions + * + * @return protocolFee Amount of receive token taken as protocol fee + * @return managerRebate Amount of receive token taken as manager rebate fee + */ + function _accrueFees(TradeInfo memory _tradeInfo) internal returns (uint256 protocolFee, uint256 managerRebate) { + uint256 exchangedQuantity = IERC20(_tradeInfo.receiveToken) + .balanceOf(address(_tradeInfo.setToken)) + .sub(_tradeInfo.preTradeReceiveTokenBalance); + + uint256 totalFeePercentage = controller.getModuleFee(address(this), GENERAL_INDEX_MODULE_V2_TOTAL_FEE_INDEX); + uint256 managerRebateSplitPercentage = controller.getModuleFee(address(this), GENERAL_INDEX_MODULE_V2_MANAGER_REBATE_SPLIT_INDEX); + + managerRebate = totalFeePercentage.preciseMul(exchangedQuantity).preciseMul(managerRebateSplitPercentage); + protocolFee = totalFeePercentage.preciseMul(exchangedQuantity).sub(managerRebate); + + payProtocolFeeFromSetToken(_tradeInfo.setToken, _tradeInfo.receiveToken, protocolFee); + + if (managerRebate > 0) { + _tradeInfo.setToken.strictInvokeTransfer( + _tradeInfo.receiveToken, + managerRebateRecipient[_tradeInfo.setToken], + managerRebate + ); + } + } + + /** + * Update SetToken positions and executionInfo's last trade timestamp. This function is intended + * to be called after the fees have been accrued, hence it returns the amount of tokens bought net of fees. + * + * @param _tradeInfo Struct containing trade information used in internal functions + * @param _component IERC20 component which was traded + * + * @return netSendAmount Amount of sendTokens used in the trade + * @return netReceiveAmount Amount of receiveTokens received in the trade (net of fees) + */ + function _updatePositionStateAndTimestamp(TradeInfo memory _tradeInfo, IERC20 _component) + internal + returns (uint256 netSendAmount, uint256 netReceiveAmount) + { + (uint256 postTradeSendTokenBalance,,) = _tradeInfo.setToken.calculateAndEditDefaultPosition( + _tradeInfo.sendToken, + _tradeInfo.setTotalSupply, + _tradeInfo.preTradeSendTokenBalance + ); + (uint256 postTradeReceiveTokenBalance,,) = _tradeInfo.setToken.calculateAndEditDefaultPosition( + _tradeInfo.receiveToken, + _tradeInfo.setTotalSupply, + _tradeInfo.preTradeReceiveTokenBalance + ); + + netSendAmount = _tradeInfo.preTradeSendTokenBalance.sub(postTradeSendTokenBalance); + netReceiveAmount = postTradeReceiveTokenBalance.sub(_tradeInfo.preTradeReceiveTokenBalance); + + executionInfo[_tradeInfo.setToken][_component].lastTradeTimestamp = block.timestamp; + } + + /** + * Adds or removes newly permissioned trader to/from permissionsInfo traderHistory. It's + * necessary to verify that traderHistory contains the address because AddressArrayUtils will + * throw when attempting to remove a non-element and it's possible someone can set a new + * trader's status to false. + * + * @param _setToken Instance of the SetToken + * @param _trader Trader whose permission is being set + * @param _status Boolean permission being set + + */ + function _updateTradersHistory(ISetToken _setToken, address _trader, bool _status) internal { + if (_status && !permissionInfo[_setToken].tradersHistory.contains(_trader)) { + permissionInfo[_setToken].tradersHistory.push(_trader); + } else if(!_status && permissionInfo[_setToken].tradersHistory.contains(_trader)) { + permissionInfo[_setToken].tradersHistory.removeStorage(_trader); + } + } + + /** + * Calculates the amount of a component is going to be traded and whether the component is being bought or sold. + * If currentUnit and targetUnit are the same, function will revert. + * + * @param _setToken Instance of the SetToken to rebalance + * @param _component IERC20 component to trade + * @param _totalSupply Total supply of _setToken + * + * @return isSendTokenFixed Boolean indicating fixed asset is send token + * @return totalFixedQuantity Amount of fixed token to send or receive + */ + function _calculateTradeSizeAndDirection( + ISetToken _setToken, + IERC20 _component, + uint256 _totalSupply + ) + internal + view + returns (bool isSendTokenFixed, uint256 totalFixedQuantity) + { + uint256 totalFeePercentage = controller.getModuleFee(address(this), GENERAL_INDEX_MODULE_V2_TOTAL_FEE_INDEX); + uint256 componentMaxSize = executionInfo[_setToken][_component].maxSize; + + ( + uint256 currentUnit, + uint256 targetUnit, + uint256 currentNotional, + uint256 targetNotional + ) = _getUnitsAndNotionalAmounts(_setToken, _component, _totalSupply); + + require(currentUnit != targetUnit, "Target already met"); + + isSendTokenFixed = targetNotional < currentNotional; + + // In order to account for fees taken by protocol when buying the notional difference between currentUnit + // and targetUnit is divided by (1 - totalFeePercentage) to make sure that targetUnit can be met. Failure to + // do so would lead to never being able to meet target of components that need to be bought. + // + // ? - lesserOf: (componentMaxSize, (currentNotional - targetNotional)) + // : - lesserOf: (componentMaxSize, (targetNotional - currentNotional) / 10 ** 18 - totalFeePercentage) + totalFixedQuantity = isSendTokenFixed + ? componentMaxSize.min(currentNotional.sub(targetNotional)) + : componentMaxSize.min(targetNotional.sub(currentNotional).preciseDiv(PreciseUnitMath.preciseUnit().sub(totalFeePercentage))); + } + + /** + * Check if all targets are met. + * + * @param _setToken Instance of the SetToken to be rebalanced + * + * @return bool True if all component's target units have been met, otherwise false + */ + function _allTargetsMet(ISetToken _setToken) internal view returns (bool) { + address[] memory rebalanceComponents = rebalanceInfo[_setToken].rebalanceComponents; + + for (uint256 i = 0; i < rebalanceComponents.length; i++) { + if (_targetUnmet(_setToken, rebalanceComponents[i])) { return false; } + } + return true; + } + + /** + * Determine if passed address is allowed to call trade for the SetToken. If anyoneTrade set to true anyone can call otherwise + * needs to be approved. + * + * @param _setToken Instance of SetToken to be rebalanced + * @param _trader Address of the trader who called contract function + * + * @return bool True if trader is an approved trader for the SetToken + */ + function _isAllowedTrader(ISetToken _setToken, address _trader) internal view returns (bool) { + TradePermissionInfo storage permissions = permissionInfo[_setToken]; + return permissions.anyoneTrade || permissions.tradeAllowList[_trader]; + } + + /** + * Checks if sell conditions are met. The component cannot be WETH and its normalized target + * unit must be less than its default position real unit + * + * @param _setToken Instance of the SetToken to be rebalanced + * @param _component Component evaluated for sale + * + * @return bool True if sell allowed, false otherwise + */ + function _canSell(ISetToken _setToken, address _component) internal view returns(bool) { + return ( + _component != address(weth) && + ( + _getNormalizedTargetUnit(_setToken, IERC20(_component)) < + _getDefaultPositionRealUnit(_setToken,IERC20(_component)) + ) + ); + } + + /** + * Check if there are any more tokens to sell. Since we allow WETH to float around it's target during rebalances it is not checked. + * + * @param _setToken Instance of the SetToken to be rebalanced + * + * @return bool True if there is not any component that can be sold, otherwise false + */ + function _noTokensToSell(ISetToken _setToken) internal view returns (bool) { + address[] memory rebalanceComponents = rebalanceInfo[_setToken].rebalanceComponents; + + for (uint256 i = 0; i < rebalanceComponents.length; i++) { + if (_canSell(_setToken, rebalanceComponents[i]) ) { return false; } + } + return true; + } + + /** + * Determines if a target is met. Due to small rounding errors converting between virtual and + * real unit on SetToken we allow for a 1 wei buffer when checking if target is met. In order to + * avoid subtraction overflow errors targetUnits of zero check for an exact amount. WETH is not + * checked as it is allowed to float around its target. + * + * @param _setToken Instance of the SetToken to be rebalanced + * @param _component Component whose target is evaluated + * + * @return bool True if component's target units are met, false otherwise + */ + function _targetUnmet(ISetToken _setToken, address _component) internal view returns(bool) { + if (_component == address(weth)) return false; + + uint256 normalizedTargetUnit = _getNormalizedTargetUnit(_setToken, IERC20(_component)); + uint256 currentUnit = _getDefaultPositionRealUnit(_setToken, IERC20(_component)); + + return (normalizedTargetUnit > 0) + ? !(normalizedTargetUnit.approximatelyEquals(currentUnit, 1)) + : normalizedTargetUnit != currentUnit; + } + + /** + * Validate component position unit has not exceeded it's target unit. This is used during tradeRemainingWETH() to make sure + * the amount of component bought does not exceed the targetUnit. + * + * @param _setToken Instance of the SetToken + * @param _component IERC20 component whose position units are to be validated + */ + function _validateComponentPositionUnit(ISetToken _setToken, IERC20 _component) internal view { + uint256 currentUnit = _getDefaultPositionRealUnit(_setToken, _component); + uint256 targetUnit = _getNormalizedTargetUnit(_setToken, _component); + require(currentUnit <= targetUnit, "Can not exceed target unit"); + } + + /** + * Validate that component is a valid component and enough time has elapsed since component's last trade. Traders + * cannot explicitly trade WETH, it may only implicitly be traded by being the quote asset for other component trades. + * + * @param _setToken Instance of the SetToken + * @param _component IERC20 component to be validated + */ + function _validateTradeParameters(ISetToken _setToken, IERC20 _component) internal view virtual { + require(address(_component) != address(weth), "Can not explicitly trade WETH"); + require( + rebalanceInfo[_setToken].rebalanceComponents.contains(address(_component)), + "Component not part of rebalance" + ); + + TradeExecutionParams memory componentInfo = executionInfo[_setToken][_component]; + require( + componentInfo.lastTradeTimestamp.add(componentInfo.coolOffPeriod) <= block.timestamp, + "Component cool off in progress" + ); + + require(!_setToken.hasExternalPosition(address(_component)), "External positions not allowed"); + } + + /** + * Extends and/or updates the current component set and its target units with new components and targets, + * Validates inputs, requiring that that new components and new target units arrays are the same size, and + * that the number of old components target units matches the number of current components. Throws if + * a duplicate component has been added. + * + * @param _currentComponents Complete set of current SetToken components + * @param _newComponents Array of new components to add to allocation + * @param _newComponentsTargetUnits Array of target units at end of rebalance for new components, maps to same index of _newComponents array + * @param _oldComponentsTargetUnits Array of target units at end of rebalance for old component, maps to same index of + * _setToken.getComponents() array, if component being removed set to 0. + * + * @return aggregateComponents Array of current components extended by new components, without duplicates + * @return aggregateTargetUnits Array of old component target units extended by new target units, without duplicates + */ + function _getAggregateComponentsAndUnits( + address[] memory _currentComponents, + address[] calldata _newComponents, + uint256[] calldata _newComponentsTargetUnits, + uint256[] calldata _oldComponentsTargetUnits + ) + internal + pure + returns (address[] memory aggregateComponents, uint256[] memory aggregateTargetUnits) + { + // Don't use validate arrays because empty arrays are valid + require(_newComponents.length == _newComponentsTargetUnits.length, "Array length mismatch"); + require(_currentComponents.length == _oldComponentsTargetUnits.length, "Old Components targets missing"); + + aggregateComponents = _currentComponents.extend(_newComponents); + aggregateTargetUnits = _oldComponentsTargetUnits.extend(_newComponentsTargetUnits); + + require(!aggregateComponents.hasDuplicate(), "Cannot duplicate components"); + } + + /** + * Get the SetToken's default position as uint256 + * + * @param _setToken Instance of the SetToken + * @param _component IERC20 component to fetch the default position for + * + * return uint256 Real unit position + */ + function _getDefaultPositionRealUnit(ISetToken _setToken, IERC20 _component) internal view returns (uint256) { + return _setToken.getDefaultPositionRealUnit(address(_component)).toUint256(); + } + + /** + * Gets exchange adapter address for a component after checking that it exists in the + * IntegrationRegistry. This method is called during a trade and must validate the adapter + * because its state may have changed since it was set in a separate transaction. + * + * @param _setToken Instance of the SetToken to be rebalanced + * @param _component IERC20 component whose exchange adapter is fetched + * + * @return IExchangeAdapter Adapter address + */ + function _getExchangeAdapter(ISetToken _setToken, IERC20 _component) internal view returns(IIndexExchangeAdapter) { + return IIndexExchangeAdapter(getAndValidateAdapter(executionInfo[_setToken][_component].exchangeName)); + } + + /** + * Calculates and returns the normalized target unit value. + * + * @param _setToken Instance of the SetToken to be rebalanced + * @param _component IERC20 component whose normalized target unit is required + * + * @return uint256 Normalized target unit of the component + */ + function _getNormalizedTargetUnit(ISetToken _setToken, IERC20 _component) internal view returns(uint256) { + // (targetUnit * current position multiplier) / position multiplier when rebalance started + return executionInfo[_setToken][_component] + .targetUnit + .mul(_setToken.positionMultiplier().toUint256()) + .div(rebalanceInfo[_setToken].positionMultiplier); + } + + /** + * Gets unit and notional amount values for current position and target. These are necessary + * to calculate the trade size and direction for regular trades and the `sendQuantity` for + * remainingWEth trades. + * + * @param _setToken Instance of the SetToken to rebalance + * @param _component IERC20 component to calculate notional amounts for + * @param _totalSupply SetToken total supply + * + * @return uint256 Current default position real unit of component + * @return uint256 Normalized unit of the trade target + * @return uint256 Current notional amount: total notional amount of SetToken default position + * @return uint256 Target notional amount: Total SetToken supply * targetUnit + */ + function _getUnitsAndNotionalAmounts(ISetToken _setToken, IERC20 _component, uint256 _totalSupply) + internal + view + returns (uint256, uint256, uint256, uint256) + { + uint256 currentUnit = _getDefaultPositionRealUnit(_setToken, _component); + uint256 targetUnit = _getNormalizedTargetUnit(_setToken, _component); + + return ( + currentUnit, + targetUnit, + _totalSupply.getDefaultTotalNotional(currentUnit), + _totalSupply.preciseMulCeil(targetUnit) + ); + } + + /* ============== Modifier Helpers =============== + * Internal functions used to reduce bytecode size + */ + + /* + * Trader must be permissioned for SetToken + */ + function _validateOnlyAllowedTrader(ISetToken _setToken) internal view { + require(_isAllowedTrader(_setToken, msg.sender), "Address not permitted to trade"); + } + + /* + * Trade must be an EOA if `anyoneTrade` has been enabled for SetToken on the module. + */ + function _validateOnlyEOAIfUnrestricted(ISetToken _setToken) internal view { + if(permissionInfo[_setToken].anyoneTrade) { + require(msg.sender == tx.origin, "Caller must be EOA Address"); + } + } +} diff --git a/contracts/protocol/modules/TradeModuleV2.sol b/contracts/protocol/modules/TradeModuleV2.sol new file mode 100644 index 000000000..0b2da1e5f --- /dev/null +++ b/contracts/protocol/modules/TradeModuleV2.sol @@ -0,0 +1,383 @@ +/* + Copyright 2021 Set Labs Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + SPDX-License-Identifier: Apache License, Version 2.0 +*/ + +pragma solidity ^0.6.10; +pragma experimental "ABIEncoderV2"; + +import { ReentrancyGuard } from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import { SafeMath } from "@openzeppelin/contracts/math/SafeMath.sol"; +import { SafeCast } from "@openzeppelin/contracts/utils/SafeCast.sol"; + +import { IController } from "../../interfaces/IController.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IExchangeAdapter } from "../../interfaces/IExchangeAdapter.sol"; +import { IIntegrationRegistry } from "../../interfaces/IIntegrationRegistry.sol"; +import { Invoke } from "../lib/Invoke.sol"; +import { ISetToken } from "../../interfaces/ISetToken.sol"; +import { ModuleBase } from "../lib/ModuleBase.sol"; +import { Position } from "../lib/Position.sol"; +import { PreciseUnitMath } from "../../lib/PreciseUnitMath.sol"; + +/** + * @title TradeModuleV2 + * @author Set Protocol + * + * Module that enables SetTokens to perform atomic trades using Decentralized Exchanges + * such as Uniswap or 0x. Integrations mappings are stored on the IntegrationRegistry contract. + * + * Note: TradeModuleV2 will allow governance to specify a protocol fee and rebate split percentage + * which is sent to the manager's address. The fee rebates will be automatic per trade. + */ +contract TradeModuleV2 is ModuleBase, ReentrancyGuard { + using SafeCast for int256; + using SafeMath for uint256; + + using Invoke for ISetToken; + using Position for ISetToken; + using PreciseUnitMath for uint256; + + /* ============ Struct ============ */ + + struct TradeInfo { + ISetToken setToken; // Instance of SetToken + IExchangeAdapter exchangeAdapter; // Instance of exchange adapter contract + address sendToken; // Address of token being sold + address receiveToken; // Address of token being bought + uint256 setTotalSupply; // Total supply of SetToken in Precise Units (10^18) + uint256 totalSendQuantity; // Total quantity of sold token (position unit x total supply) + uint256 totalMinReceiveQuantity; // Total minimum quantity of token to receive back + uint256 preTradeSendTokenBalance; // Total initial balance of token being sold + uint256 preTradeReceiveTokenBalance; // Total initial balance of token being bought + } + + /* ============ Events ============ */ + + event ComponentExchanged( + ISetToken indexed _setToken, + address indexed _sendToken, + address indexed _receiveToken, + IExchangeAdapter _exchangeAdapter, + uint256 _totalSendAmount, + uint256 _totalReceiveAmount, + uint256 _protocolFee, + uint256 _managerRebate + ); + + event FeeRecipientUpdated(ISetToken indexed _setToken, address _newFeeRecipient); + + /* ============ Constants ============ */ + + // 0 index stores the total fee % charged in the trade function + uint256 constant internal TRADE_MODULE_V2_TOTAL_FEE_INDEX = 0; + + // 1 index stores the % of total fees that the manager receives back as rebates + uint256 constant internal TRADE_MODULE_V2_MANAGER_REBATE_SPLIT_INDEX = 1; + + /* ============ State Variables ============ */ + + // Mapping to efficiently identify a manager rebate recipient address for a given SetToken + mapping(ISetToken => address) public managerRebateRecipient; + + /* ============ Constructor ============ */ + + constructor(IController _controller) public ModuleBase(_controller) {} + + /* ============ External Functions ============ */ + + /** + * Initializes this module to the SetToken. Only callable by the SetToken's manager. + * + * @param _setToken Instance of the SetToken to initialize + */ + function initialize( + ISetToken _setToken, + address _managerRebateRecipient + ) + external + onlyValidAndPendingSet(_setToken) + onlySetManager(_setToken, msg.sender) + { + require(_managerRebateRecipient != address(0), "Recipient must be non-zero address."); + + managerRebateRecipient[_setToken] = _managerRebateRecipient; + + _setToken.initializeModule(); + } + + /** + * Executes a trade on a supported DEX. Only callable by the SetToken's manager. + * @dev Although the SetToken units are passed in for the send and receive quantities, the total quantity + * sent and received is the quantity of SetToken units multiplied by the SetToken totalSupply. + * + * @param _setToken Instance of the SetToken to trade + * @param _exchangeName Human readable name of the exchange in the integrations registry + * @param _sendToken Address of the token to be sent to the exchange + * @param _sendQuantity Units of token in SetToken sent to the exchange + * @param _receiveToken Address of the token that will be received from the exchange + * @param _minReceiveQuantity Min units of token in SetToken to be received from the exchange + * @param _data Arbitrary bytes to be used to construct trade call data + */ + function trade( + ISetToken _setToken, + string memory _exchangeName, + address _sendToken, + uint256 _sendQuantity, + address _receiveToken, + uint256 _minReceiveQuantity, + bytes memory _data + ) + external + nonReentrant + onlyManagerAndValidSet(_setToken) + { + TradeInfo memory tradeInfo = _createTradeInfo( + _setToken, + _exchangeName, + _sendToken, + _receiveToken, + _sendQuantity, + _minReceiveQuantity + ); + + _validatePreTradeData(tradeInfo, _sendQuantity); + + _executeTrade(tradeInfo, _data); + + uint256 exchangedQuantity = _validatePostTrade(tradeInfo); + + (uint256 protocolFee, uint256 managerRebate) = _accrueFees(tradeInfo, exchangedQuantity); + + ( + uint256 netSendAmount, + uint256 netReceiveAmount + ) = _updateSetTokenPositions(tradeInfo); + + emit ComponentExchanged( + _setToken, + _sendToken, + _receiveToken, + tradeInfo.exchangeAdapter, + netSendAmount, + netReceiveAmount, + protocolFee, + managerRebate + ); + } + + /** + * MANAGER ONLY: Updates address receiving manager rebate fees for a given SetToken. + * + * @param _setToken Instance of the SetToken to update fee recipient + * @param _newRebateRecipient New rebate fee recipient address + */ + function updateFeeRecipient( + ISetToken _setToken, + address _newRebateRecipient + ) + external + onlyManagerAndValidSet(_setToken) + { + require(_newRebateRecipient != address(0), "Recipient must be non-zero address."); + require(_newRebateRecipient != managerRebateRecipient[_setToken], "Same fee recipient passed"); + + managerRebateRecipient[_setToken] = _newRebateRecipient; + + emit FeeRecipientUpdated(_setToken, _newRebateRecipient); + } + + /** + * Removes this module from the SetToken, via call by the SetToken. Remove the manager rebate recipient address. + */ + function removeModule() external override { + delete managerRebateRecipient[ISetToken(msg.sender)]; + } + + /* ============ Internal Functions ============ */ + + /** + * Create and return TradeInfo struct + * + * @param _setToken Instance of the SetToken to trade + * @param _exchangeName Human readable name of the exchange in the integrations registry + * @param _sendToken Address of the token to be sent to the exchange + * @param _receiveToken Address of the token that will be received from the exchange + * @param _sendQuantity Units of token in SetToken sent to the exchange + * @param _minReceiveQuantity Min units of token in SetToken to be received from the exchange + * + * return TradeInfo Struct containing data for trade + */ + function _createTradeInfo( + ISetToken _setToken, + string memory _exchangeName, + address _sendToken, + address _receiveToken, + uint256 _sendQuantity, + uint256 _minReceiveQuantity + ) + internal + view + returns (TradeInfo memory) + { + TradeInfo memory tradeInfo; + + tradeInfo.setToken = _setToken; + + tradeInfo.exchangeAdapter = IExchangeAdapter(getAndValidateAdapter(_exchangeName)); + + tradeInfo.sendToken = _sendToken; + tradeInfo.receiveToken = _receiveToken; + + tradeInfo.setTotalSupply = _setToken.totalSupply(); + + tradeInfo.totalSendQuantity = Position.getDefaultTotalNotional(tradeInfo.setTotalSupply, _sendQuantity); + + tradeInfo.totalMinReceiveQuantity = Position.getDefaultTotalNotional(tradeInfo.setTotalSupply, _minReceiveQuantity); + + tradeInfo.preTradeSendTokenBalance = IERC20(_sendToken).balanceOf(address(_setToken)); + tradeInfo.preTradeReceiveTokenBalance = IERC20(_receiveToken).balanceOf(address(_setToken)); + + return tradeInfo; + } + + /** + * Validate pre trade data. Check exchange is valid, token quantity is valid. + * + * @param _tradeInfo Struct containing trade information used in internal functions + * @param _sendQuantity Units of token in SetToken sent to the exchange + */ + function _validatePreTradeData(TradeInfo memory _tradeInfo, uint256 _sendQuantity) internal view { + require(_tradeInfo.totalSendQuantity > 0, "Token to sell must be nonzero"); + + require( + _tradeInfo.setToken.hasSufficientDefaultUnits(_tradeInfo.sendToken, _sendQuantity), + "Unit cant be greater than existing" + ); + } + + /** + * Invoke approve for send token, get method data and invoke trade in the context of the SetToken. + * + * @param _tradeInfo Struct containing trade information used in internal functions + * @param _data Arbitrary bytes to be used to construct trade call data + */ + function _executeTrade( + TradeInfo memory _tradeInfo, + bytes memory _data + ) + internal + { + // Get spender address from exchange adapter and invoke approve for exact amount on SetToken + _tradeInfo.setToken.invokeApprove( + _tradeInfo.sendToken, + _tradeInfo.exchangeAdapter.getSpender(), + _tradeInfo.totalSendQuantity + ); + + ( + address targetExchange, + uint256 callValue, + bytes memory methodData + ) = _tradeInfo.exchangeAdapter.getTradeCalldata( + _tradeInfo.sendToken, + _tradeInfo.receiveToken, + address(_tradeInfo.setToken), + _tradeInfo.totalSendQuantity, + _tradeInfo.totalMinReceiveQuantity, + _data + ); + + _tradeInfo.setToken.invoke(targetExchange, callValue, methodData); + } + + /** + * Validate post trade data. + * + * @param _tradeInfo Struct containing trade information used in internal functions + * @return uint256 Total quantity of receive token that was exchanged + */ + function _validatePostTrade(TradeInfo memory _tradeInfo) internal view returns (uint256) { + uint256 exchangedQuantity = IERC20(_tradeInfo.receiveToken) + .balanceOf(address(_tradeInfo.setToken)) + .sub(_tradeInfo.preTradeReceiveTokenBalance); + + require( + exchangedQuantity >= _tradeInfo.totalMinReceiveQuantity, + "Slippage greater than allowed" + ); + + return exchangedQuantity; + } + + /** + * Retrieve fees from controller and calculate protocol fee and manager rebate. Send from SetToken to protocol recipient and + * to manager recipient. + * + * @param _tradeInfo Struct containing trade information used in internal functions + * @return protocolFee Amount of receive token taken as protocol fee + * @return managerRebate Amount of receive token taken as manager rebate fee + */ + function _accrueFees( + TradeInfo memory _tradeInfo, + uint256 _exchangedQuantity + ) + internal + returns (uint256 protocolFee, uint256 managerRebate) + { + uint256 totalFeePercentage = controller.getModuleFee(address(this), TRADE_MODULE_V2_TOTAL_FEE_INDEX); + uint256 managerRebateSplitPercentage = controller.getModuleFee(address(this), TRADE_MODULE_V2_MANAGER_REBATE_SPLIT_INDEX); + + managerRebate = totalFeePercentage.preciseMul(_exchangedQuantity).preciseMul(managerRebateSplitPercentage); + protocolFee = totalFeePercentage.preciseMul(_exchangedQuantity).sub(managerRebate); + + payProtocolFeeFromSetToken(_tradeInfo.setToken, _tradeInfo.receiveToken, protocolFee); + + if (managerRebate > 0) { + _tradeInfo.setToken.strictInvokeTransfer( + _tradeInfo.receiveToken, + managerRebateRecipient[_tradeInfo.setToken], + managerRebate + ); + } + } + + /** + * Update SetToken positions + * + * @param _tradeInfo Struct containing trade information used in internal functions + * @return uint256 Amount of sendTokens used in the trade + * @return uint256 Amount of receiveTokens received in the trade (net of fees) + */ + function _updateSetTokenPositions(TradeInfo memory _tradeInfo) internal returns (uint256, uint256) { + (uint256 currentSendTokenBalance,,) = _tradeInfo.setToken.calculateAndEditDefaultPosition( + _tradeInfo.sendToken, + _tradeInfo.setTotalSupply, + _tradeInfo.preTradeSendTokenBalance + ); + + (uint256 currentReceiveTokenBalance,,) = _tradeInfo.setToken.calculateAndEditDefaultPosition( + _tradeInfo.receiveToken, + _tradeInfo.setTotalSupply, + _tradeInfo.preTradeReceiveTokenBalance + ); + + return ( + _tradeInfo.preTradeSendTokenBalance.sub(currentSendTokenBalance), + currentReceiveTokenBalance.sub(_tradeInfo.preTradeReceiveTokenBalance) + ); + } +} \ No newline at end of file diff --git a/test/protocol/modules/generalIndexModuleV2.spec.ts b/test/protocol/modules/generalIndexModuleV2.spec.ts new file mode 100644 index 000000000..beab1bd15 --- /dev/null +++ b/test/protocol/modules/generalIndexModuleV2.spec.ts @@ -0,0 +1,3077 @@ +import "module-alias/register"; +import { BigNumber } from "ethers"; + +import { hexlify, hexZeroPad } from "ethers/lib/utils"; +import { Address, StreamingFeeState } from "@utils/types"; +import { Account } from "@utils/test/types"; +import { ADDRESS_ZERO, MAX_UINT_256, PRECISE_UNIT, ONE, THREE, ZERO, ONE_DAY_IN_SECONDS } from "@utils/constants"; +import { + BalancerV1IndexExchangeAdapter, + ContractCallerMock, + GeneralIndexModuleV2, + SetToken, + UniswapV2IndexExchangeAdapter, + UniswapV3IndexExchangeAdapter, + KyberV3IndexExchangeAdapter +} from "@utils/contracts"; +import DeployHelper from "@utils/deploys"; +import { + bitcoin, + ether, + preciseDiv, + preciseMul, + preciseMulCeil, + usdc +} from "@utils/index"; +import { + cacheBeforeEach, + increaseTimeAsync, + getAccounts, + getBalancerFixture, + getLastBlockTimestamp, + getKyberV3DMMFixture, + getRandomAccount, + getRandomAddress, + getSystemFixture, + getUniswapFixture, + getUniswapV3Fixture, + getWaffleExpect +} from "@utils/test/index"; +import { BalancerFixture, KyberV3DMMFixture, SystemFixture, UniswapFixture, UniswapV3Fixture } from "@utils/fixtures"; +import { ContractTransaction } from "ethers"; + +const expect = getWaffleExpect(); + +describe("GeneralIndexModuleV2", () => { + let owner: Account; + let trader: Account; + let positionModule: Account; + let deployer: DeployHelper; + let setup: SystemFixture; + + let uniswapSetup: UniswapFixture; + let sushiswapSetup: UniswapFixture; + let balancerSetup: BalancerFixture; + let uniswapV3Setup: UniswapV3Fixture; + let kyberV3Setup: KyberV3DMMFixture; + + let index: SetToken; + let indexWithWeth: SetToken; + let indexModule: GeneralIndexModuleV2; + + let balancerAdapterName: string; + let sushiswapAdapterName: string; + let uniswapAdapterName: string; + let uniswapV3AdapterName: string; + let kyberV3AdapterName: string; + + let balancerExchangeAdapter: BalancerV1IndexExchangeAdapter; + let sushiswapExchangeAdapter: UniswapV2IndexExchangeAdapter; + let uniswapExchangeAdapter: UniswapV2IndexExchangeAdapter; + let uniswapV3ExchangeAdapter: UniswapV3IndexExchangeAdapter; + let kyberV3ExchangeAdapter: KyberV3IndexExchangeAdapter; + + let indexComponents: Address[]; + let indexUnits: BigNumber[]; + let indexWithWethComponents: Address[]; + let indexWithWethUnits: BigNumber[]; + + const ONE_MINUTE_IN_SECONDS: BigNumber = BigNumber.from(60); + + before(async () => { + [ + owner, + trader, + positionModule, + ] = await getAccounts(); + + deployer = new DeployHelper(owner.wallet); + setup = getSystemFixture(owner.address); + uniswapSetup = getUniswapFixture(owner.address); + sushiswapSetup = getUniswapFixture(owner.address); + balancerSetup = getBalancerFixture(owner.address); + uniswapV3Setup = getUniswapV3Fixture(owner.address); + kyberV3Setup = getKyberV3DMMFixture(owner.address); + + await setup.initialize(); + await uniswapSetup.initialize(owner, setup.weth.address, setup.wbtc.address, setup.dai.address); + await sushiswapSetup.initialize(owner, setup.weth.address, setup.wbtc.address, setup.dai.address); + await balancerSetup.initialize(owner, setup.weth, setup.wbtc, setup.dai); + await uniswapV3Setup.initialize( + owner, + setup.weth, + 230, + setup.wbtc, + 9000, + setup.dai + ); + await kyberV3Setup.initialize(owner, setup.weth.address, setup.wbtc.address, setup.dai.address); + + indexModule = await deployer.modules.deployGeneralIndexModuleV2( + setup.controller.address, + setup.weth.address + ); + await setup.controller.addModule(indexModule.address); + await setup.controller.addModule(positionModule.address); + + balancerExchangeAdapter = await deployer.adapters.deployBalancerV1IndexExchangeAdapter(balancerSetup.exchange.address); + sushiswapExchangeAdapter = await deployer.adapters.deployUniswapV2IndexExchangeAdapter(sushiswapSetup.router.address); + uniswapExchangeAdapter = await deployer.adapters.deployUniswapV2IndexExchangeAdapter(uniswapSetup.router.address); + uniswapV3ExchangeAdapter = await deployer.adapters.deployUniswapV3IndexExchangeAdapter(uniswapV3Setup.swapRouter.address); + kyberV3ExchangeAdapter = await deployer.adapters.deployKyberV3IndexExchangeAdapter( + kyberV3Setup.dmmRouter.address, + kyberV3Setup.dmmFactory.address + ); + + balancerAdapterName = "BALANCER"; + sushiswapAdapterName = "SUSHISWAP"; + uniswapAdapterName = "UNISWAP"; + uniswapV3AdapterName = "UNISWAPV3"; + kyberV3AdapterName = "KYBERV3"; + + + await setup.integrationRegistry.batchAddIntegration( + [indexModule.address, indexModule.address, indexModule.address, indexModule.address, indexModule.address], + [balancerAdapterName, sushiswapAdapterName, uniswapAdapterName, uniswapV3AdapterName, kyberV3AdapterName], + [ + balancerExchangeAdapter.address, + sushiswapExchangeAdapter.address, + uniswapExchangeAdapter.address, + uniswapV3ExchangeAdapter.address, + kyberV3ExchangeAdapter.address, + ] + ); + }); + + cacheBeforeEach(async () => { + indexComponents = [uniswapSetup.uni.address, setup.wbtc.address, setup.dai.address]; + indexUnits = [ether(86.9565217), bitcoin(.01111111), ether(100)]; + index = await setup.createSetToken( + indexComponents, + indexUnits, // $100 of each + [setup.issuanceModule.address, setup.streamingFeeModule.address, indexModule.address, positionModule.address], + ); + + const feeSettings = { + feeRecipient: owner.address, + maxStreamingFeePercentage: ether(.1), + streamingFeePercentage: ether(.01), + lastStreamingFeeTimestamp: ZERO, + } as StreamingFeeState; + + await setup.streamingFeeModule.initialize(index.address, feeSettings); + await setup.issuanceModule.initialize(index.address, ADDRESS_ZERO); + await index.connect(positionModule.wallet).initializeModule(); + + indexWithWethComponents = [uniswapSetup.uni.address, setup.wbtc.address, setup.dai.address, setup.weth.address]; + indexWithWethUnits = [ether(86.9565217), bitcoin(.01111111), ether(100), ether(0.434782609)]; + indexWithWeth = await setup.createSetToken( + indexWithWethComponents, + indexWithWethUnits, // $100 of each + [setup.issuanceModule.address, setup.streamingFeeModule.address, indexModule.address], + ); + + const feeSettingsForIndexWithWeth = { + feeRecipient: owner.address, + maxStreamingFeePercentage: ether(.1), + streamingFeePercentage: ether(.01), + lastStreamingFeeTimestamp: ZERO, + } as StreamingFeeState; + + await setup.streamingFeeModule.initialize(indexWithWeth.address, feeSettingsForIndexWithWeth); + await setup.issuanceModule.initialize(indexWithWeth.address, ADDRESS_ZERO); + + await setup.weth.connect(owner.wallet).approve(uniswapSetup.router.address, ether(1000)); + await uniswapSetup.uni.connect(owner.wallet).approve(uniswapSetup.router.address, ether(200000)); + await uniswapSetup.router.connect(owner.wallet).addLiquidity( + setup.weth.address, + uniswapSetup.uni.address, + ether(1000), + ether(200000), + ether(999), + ether(199000), + owner.address, + MAX_UINT_256 + ); + + await setup.weth.connect(owner.wallet).approve(sushiswapSetup.router.address, ether(1000)); + await setup.wbtc.connect(owner.wallet).approve(sushiswapSetup.router.address, bitcoin(26)); + await sushiswapSetup.router.addLiquidity( + setup.weth.address, + setup.wbtc.address, + ether(1000), + bitcoin(25.5555), + ether(999), + ether(25.3), + owner.address, + MAX_UINT_256 + ); + + await setup.weth.connect(owner.wallet).approve(uniswapV3Setup.nftPositionManager.address, ether(1000)); + await setup.wbtc.connect(owner.wallet).approve(uniswapV3Setup.nftPositionManager.address, bitcoin(26)); + await uniswapV3Setup.addLiquidityWide( + setup.weth, + setup.wbtc, + 3000, + ether(1000), + bitcoin(26), + owner.address + ); + + await setup.weth.connect(owner.wallet).approve(uniswapV3Setup.nftPositionManager.address, ether(100)); + await setup.dai.connect(owner.wallet).approve(uniswapV3Setup.nftPositionManager.address, ether(23000)); + await uniswapV3Setup.addLiquidityWide( + setup.weth, + setup.dai, + 3000, + ether(100), + ether(23000), + owner.address + ); + + await setup.weth.connect(owner.wallet).approve(kyberV3Setup.dmmRouter.address, ether(1000)); + await setup.wbtc.connect(owner.wallet).approve(kyberV3Setup.dmmRouter.address, bitcoin(26)); + await kyberV3Setup.dmmRouter.connect(owner.wallet).addLiquidity( + setup.weth.address, + setup.wbtc.address, + kyberV3Setup.wethWbtcPool.address, + ether(1000), + bitcoin(26), + ether(999), + bitcoin(25.3), + [0, MAX_UINT_256], + owner.address, + MAX_UINT_256 + ); + + await setup.weth.connect(owner.wallet).approve(kyberV3Setup.dmmRouter.address, ether(100)); + await setup.dai.connect(owner.wallet).approve(kyberV3Setup.dmmRouter.address, ether(23000)); + await kyberV3Setup.dmmRouter.connect(owner.wallet).addLiquidity( + setup.weth.address, + setup.dai.address, + kyberV3Setup.wethDaiPool.address, + ether(100), + ether(23000), + ether(99), + ether(22950), + [0, MAX_UINT_256], + owner.address, + MAX_UINT_256 + ); + }); + + describe("#constructor", async () => { + it("should set all the parameters correctly", async () => { + const weth = await indexModule.weth(); + const controller = await indexModule.controller(); + + expect(weth).to.eq(setup.weth.address); + expect(controller).to.eq(setup.controller.address); + }); + }); + + describe("#initialize", async () => { + let subjectSetToken: SetToken; + let subjectRebateRecipient: Address; + let subjectCaller: Account; + + beforeEach(async () => { + subjectSetToken = index; + subjectRebateRecipient = owner.address; + subjectCaller = owner; + }); + + async function subject(): Promise { + indexModule = indexModule.connect(subjectCaller.wallet); + return indexModule.initialize(subjectSetToken.address, subjectRebateRecipient); + } + + it("should enable the Module on the SetToken", async () => { + await subject(); + const isModuleEnabled = await subjectSetToken.isInitializedModule(indexModule.address); + expect(isModuleEnabled).to.eq(true); + }); + + it("should set the manager rebate recipient", async () => { + await subject(); + const managerRebateRecipient = await indexModule.managerRebateRecipient(subjectSetToken.address); + expect(managerRebateRecipient).to.eq(subjectRebateRecipient); + }); + + describe("when the caller is not the SetToken manager", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be the SetToken manager"); + }); + }); + + describe("when the module is not pending", async () => { + beforeEach(async () => { + await subject(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be pending initialization"); + }); + }); + + describe("when the SetToken is not enabled on the controller", async () => { + beforeEach(async () => { + const nonEnabledSetToken = await setup.createNonControllerEnabledSetToken( + [setup.dai.address], + [ether(1)], + [indexModule.address], + owner.address + ); + + subjectSetToken = nonEnabledSetToken; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be controller-enabled SetToken"); + }); + }); + + describe("when the manager rebate recipient is the zero address", async () => { + beforeEach(async () => { + subjectRebateRecipient = ADDRESS_ZERO; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Recipient must be non-zero address."); + }); + }); + + describe("when set has weth as component", async () => { + beforeEach(async () => { + subjectSetToken = indexWithWeth; + }); + + it("should enable the Module on the SetToken", async () => { + await subject(); + const isModuleEnabled = await subjectSetToken.isInitializedModule(indexModule.address); + expect(isModuleEnabled).to.eq(true); + }); + }); + + describe("when there are external positions for a component", async () => { + beforeEach(async () => { + await subjectSetToken.connect(positionModule.wallet).addExternalPositionModule( + indexComponents[0], + positionModule.address + ); + }); + + afterEach(async () => { + await subjectSetToken.connect(positionModule.wallet).removeExternalPositionModule( + indexComponents[0], + positionModule.address + ); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("External positions not allowed"); + }); + }); + }); + + describe("when module is initialized", async () => { + let subjectSetToken: SetToken; + let subjectCaller: Account; + + let newComponents: Address[]; + let newTargetUnits: BigNumber[]; + let oldTargetUnits: BigNumber[]; + let issueAmount: BigNumber; + + async function initSetToken( + setToken: SetToken, components: Address[], tradeMaximums: BigNumber[], exchanges: string[], coolOffPeriods: BigNumber[] + ) { + await indexModule.initialize(setToken.address, owner.address); + await indexModule.setTradeMaximums(setToken.address, components, tradeMaximums); + await indexModule.setExchanges(setToken.address, components, exchanges); + await indexModule.setCoolOffPeriods(setToken.address, components, coolOffPeriods); + await indexModule.setTraderStatus(setToken.address, [trader.address], [true]); + } + + cacheBeforeEach(async () => { + // initialize indexModule on both SetTokens + await initSetToken( + index, + [uniswapSetup.uni.address, setup.wbtc.address, setup.dai.address, sushiswapSetup.uni.address], + [ether(800), bitcoin(.1), ether(1000), ether(500)], + [uniswapAdapterName, sushiswapAdapterName, balancerAdapterName, sushiswapAdapterName], + [ONE_MINUTE_IN_SECONDS.mul(3), ONE_MINUTE_IN_SECONDS, ONE_MINUTE_IN_SECONDS.mul(2), ONE_MINUTE_IN_SECONDS] + ); + + await initSetToken( + indexWithWeth, + [uniswapSetup.uni.address, setup.wbtc.address, setup.dai.address, setup.weth.address, sushiswapSetup.uni.address], + [ether(800), bitcoin(.1), ether(1000), ether(10000), ether(500)], + [uniswapAdapterName, sushiswapAdapterName, balancerAdapterName, "", sushiswapAdapterName], + [ONE_MINUTE_IN_SECONDS.mul(3), ONE_MINUTE_IN_SECONDS, ONE_MINUTE_IN_SECONDS.mul(2), ZERO, ONE_MINUTE_IN_SECONDS], + ); + }); + + describe("#startRebalance", async () => { + let subjectNewComponents: Address[]; + let subjectNewTargetUnits: BigNumber[]; + let subjectOldTargetUnits: BigNumber[]; + + beforeEach(async () => { + subjectSetToken = index; + subjectCaller = owner; + + subjectNewComponents = [sushiswapSetup.uni.address]; + subjectNewTargetUnits = [ether(50)]; + subjectOldTargetUnits = [ether("60.869565780223716593"), bitcoin(.02), ether(50)]; + }); + + async function subject(): Promise { + return await indexModule.connect(subjectCaller.wallet).startRebalance( + subjectSetToken.address, + subjectNewComponents, + subjectNewTargetUnits, + subjectOldTargetUnits, + await subjectSetToken.positionMultiplier() + ); + } + + it("should set target units and rebalance info correctly", async () => { + await subject(); + + const currentComponents = await subjectSetToken.getComponents(); + const aggregateComponents = [...currentComponents, ...subjectNewComponents]; + const aggregateTargetUnits = [...subjectOldTargetUnits, ...subjectNewTargetUnits]; + + for (let i = 0; i < aggregateComponents.length; i++) { + const targetUnit = (await indexModule.executionInfo(subjectSetToken.address, aggregateComponents[i])).targetUnit; + const exepectedTargetUnit = aggregateTargetUnits[i]; + expect(targetUnit).to.be.eq(exepectedTargetUnit); + } + + const rebalanceComponents = await indexModule.getRebalanceComponents(subjectSetToken.address); + const expectedRebalanceComponents = aggregateComponents; + for (let i = 0; i < rebalanceComponents.length; i++) { + expect(rebalanceComponents[i]).to.be.eq(expectedRebalanceComponents[i]); + } + + const positionMultiplier = (await indexModule.rebalanceInfo(subjectSetToken.address)).positionMultiplier; + const expectedPositionMultiplier = await subjectSetToken.positionMultiplier(); + + expect(positionMultiplier).to.be.eq(expectedPositionMultiplier); + }); + + it("emits the correct RebalanceStarted event", async () => { + const currentComponents = await subjectSetToken.getComponents(); + const expectedAggregateComponents = [...currentComponents, ...subjectNewComponents]; + const expectedAggregateTargetUnits = [...subjectOldTargetUnits, ...subjectNewTargetUnits]; + const expectedPositionMultiplier = await subjectSetToken.positionMultiplier(); + + await expect(subject()).to.emit(indexModule, "RebalanceStarted").withArgs( + subjectSetToken.address, + expectedAggregateComponents, + expectedAggregateTargetUnits, + expectedPositionMultiplier + ); + }); + + describe("newComponents and newComponentsTargetUnits are not of same length", async () => { + beforeEach(async () => { + subjectNewTargetUnits = []; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Array length mismatch"); + }); + }); + + describe("when missing target units for old comoponents", async () => { + beforeEach(async () => { + subjectOldTargetUnits = [ether("60.869565780223716593"), bitcoin(.02)]; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Old Components targets missing"); + }); + }); + + describe("when newComponents contains an old component", async () => { + beforeEach(async () => { + subjectNewComponents = [sushiswapSetup.uni.address, uniswapSetup.uni.address]; + subjectNewTargetUnits = [ether(50), ether(50)]; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Cannot duplicate components"); + }); + }); + + describe("when there are external positions for a component", async () => { + beforeEach(async () => { + await subjectSetToken.connect(positionModule.wallet).addExternalPositionModule( + subjectNewComponents[0], + positionModule.address + ); + }); + + afterEach(async () => { + await subjectSetToken.connect(positionModule.wallet).removeExternalPositionModule( + subjectNewComponents[0], + positionModule.address + ); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("External positions not allowed"); + }); + }); + }); + + describe("#setCoolOffPeriods", async () => { + let subjectComponents: Address[]; + let subjectCoolOffPeriods: BigNumber[]; + + beforeEach(async () => { + subjectSetToken = index; + subjectCaller = owner; + subjectComponents = [uniswapSetup.uni.address, setup.wbtc.address]; + subjectCoolOffPeriods = [ONE_MINUTE_IN_SECONDS.mul(3), ONE_MINUTE_IN_SECONDS]; + }); + + async function subject(): Promise { + return await indexModule.connect(subjectCaller.wallet).setCoolOffPeriods( + subjectSetToken.address, + subjectComponents, + subjectCoolOffPeriods + ); + } + + it("should set values correctly", async () => { + await subject(); + + for (let i = 0; i < subjectComponents.length; i++) { + const coolOffPeriod = (await indexModule.executionInfo(subjectSetToken.address, subjectComponents[i])).coolOffPeriod; + const exepctedCoolOffPeriod = subjectCoolOffPeriods[i]; + expect(coolOffPeriod).to.be.eq(exepctedCoolOffPeriod); + } + }); + + describe("when array lengths are not same", async () => { + beforeEach(async () => { + subjectCoolOffPeriods = [ONE_MINUTE_IN_SECONDS.mul(3), ONE_MINUTE_IN_SECONDS, ONE_MINUTE_IN_SECONDS.mul(2)]; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Array length mismatch"); + }); + }); + + describe("when component array has duplilcate values", async () => { + beforeEach(async () => { + subjectComponents = [uniswapSetup.uni.address, setup.wbtc.address, uniswapSetup.uni.address]; + subjectCoolOffPeriods = [ONE_MINUTE_IN_SECONDS.mul(3), ONE_MINUTE_IN_SECONDS, ONE_MINUTE_IN_SECONDS.mul(3)]; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Cannot duplicate addresses"); + }); + }); + + describe("when array length is 0", async () => { + beforeEach(async () => { + subjectComponents = []; + subjectCoolOffPeriods = []; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Array length must be > 0"); + }); + }); + }); + + describe("#setTradeMaximums", async () => { + let subjectComponents: Address[]; + let subjectTradeMaximums: BigNumber[]; + + beforeEach(async () => { + subjectSetToken = index; + subjectCaller = owner; + subjectComponents = [uniswapSetup.uni.address, setup.wbtc.address]; + subjectTradeMaximums = [ether(800), bitcoin(.1)]; + }); + + async function subject(): Promise { + return await indexModule.connect(subjectCaller.wallet).setTradeMaximums( + subjectSetToken.address, + subjectComponents, + subjectTradeMaximums + ); + } + + it("should set values correctly", async () => { + await subject(); + + for (let i = 0; i < subjectComponents.length; i++) { + const maxSize = (await indexModule.executionInfo(subjectSetToken.address, subjectComponents[i])).maxSize; + const exepctedMaxSize = subjectTradeMaximums[i]; + expect(maxSize).to.be.eq(exepctedMaxSize); + } + }); + }); + + describe("#setExchanges", async () => { + let subjectComponents: Address[]; + let subjectExchanges: string[]; + + beforeEach(async () => { + subjectSetToken = index; + subjectCaller = owner; + subjectComponents = [uniswapSetup.uni.address, setup.wbtc.address]; + subjectExchanges = [uniswapAdapterName, sushiswapAdapterName]; + }); + + async function subject(): Promise { + return await indexModule.connect(subjectCaller.wallet).setExchanges(subjectSetToken.address, subjectComponents, subjectExchanges); + } + + it("should set values correctly", async () => { + await subject(); + + for (let i = 0; i < subjectComponents.length; i++) { + const exchangeName = (await indexModule.executionInfo(subjectSetToken.address, subjectComponents[i])).exchangeName; + const expectedExchangeName = subjectExchanges[i]; + expect(exchangeName).to.be.eq(expectedExchangeName); + } + }); + + describe("when array lengths are not same", async () => { + beforeEach(async () => { + subjectExchanges = [uniswapAdapterName, sushiswapAdapterName, balancerAdapterName]; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Array length mismatch"); + }); + }); + + describe("when component array has duplilcate values", async () => { + beforeEach(async () => { + subjectComponents = [uniswapSetup.uni.address, setup.wbtc.address, uniswapSetup.uni.address]; + subjectExchanges = [uniswapAdapterName, sushiswapAdapterName, uniswapAdapterName]; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Cannot duplicate addresses"); + }); + }); + + describe("when component array has duplilcate values", async () => { + beforeEach(async () => { + subjectComponents = []; + subjectExchanges = []; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Array length must be > 0"); + }); + }); + + describe("when exchange is not a valid integration", async () => { + beforeEach(async () => { + await setup.integrationRegistry.removeIntegration(indexModule.address, sushiswapAdapterName); + }); + + afterEach(async () => { + await setup.integrationRegistry.addIntegration( + indexModule.address, + sushiswapAdapterName, + sushiswapExchangeAdapter.address + ); + }); + + describe("for component other than weth", async () => { + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Unrecognized exchange name"); + }); + }); + + describe("for weth", async () => { + beforeEach(async () => { + subjectComponents = [sushiswapSetup.uni.address, setup.weth.address]; + }); + + it("should not revert", async () => { + await expect(subject()).to.not.be.reverted; + }); + }); + }); + }); + + describe("#setExchangeData", async () => { + let uniBytes: string; + let wbtcBytes: string; + + let subjectComponents: Address[]; + let subjectExchangeData: string[]; + + beforeEach(async () => { + uniBytes = "0x"; + wbtcBytes = "0x7890"; + + subjectSetToken = index; + subjectCaller = owner; + subjectComponents = [uniswapSetup.uni.address, setup.wbtc.address]; + subjectExchangeData = [uniBytes, wbtcBytes]; + }); + + async function subject(): Promise { + return await indexModule.connect(subjectCaller.wallet).setExchangeData(subjectSetToken.address, subjectComponents, subjectExchangeData); + } + + it("should set values correctly", async () => { + await subject(); + + for (let i = 0; i < subjectComponents.length; i++) { + const exchangeData = (await indexModule.executionInfo(subjectSetToken.address, subjectComponents[i])).exchangeData; + const expectedExchangeData = subjectExchangeData[i]; + expect(exchangeData).to.be.eq(expectedExchangeData); + } + }); + + describe("when array lengths are not same", async () => { + beforeEach(async () => { + subjectExchangeData = ["0x", "0x523454", "0x7890"]; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Array length mismatch"); + }); + }); + + describe("when component array has duplicate values", async () => { + beforeEach(async () => { + subjectComponents = [uniswapSetup.uni.address, setup.wbtc.address, uniswapSetup.uni.address]; + subjectExchangeData = ["0x", "0x523454", "0x7890"]; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Cannot duplicate addresses"); + }); + }); + + describe("when component array has no values", async () => { + beforeEach(async () => { + subjectComponents = []; + subjectExchangeData = []; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Array length must be > 0"); + }); + }); + }); + + describe("#updateFeeRecipient", async () => { + let subjectNewFeeRecipient: Address; + let subjectCaller: Account; + + beforeEach(async () => { + subjectNewFeeRecipient = await getRandomAddress(); + subjectSetToken = index; + subjectCaller = owner; + }); + + async function subject(): Promise { + return indexModule.connect(subjectCaller.wallet).updateFeeRecipient( + subjectSetToken.address, + subjectNewFeeRecipient + ); + } + + it("should have set the new fee recipient address", async () => { + await subject(); + + const managerRebateRecipient = await indexModule.managerRebateRecipient(subjectSetToken.address); + + expect(managerRebateRecipient).to.eq(subjectNewFeeRecipient); + }); + + it("should emit the correct FeeRecipientUpdated event", async () => { + await expect(subject()).to.emit(indexModule, "FeeRecipientUpdated").withArgs( + subjectSetToken.address, + subjectNewFeeRecipient + ); + }); + + describe("when fee recipient address is null address", async () => { + beforeEach(async () => { + subjectNewFeeRecipient = ADDRESS_ZERO; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Recipient must be non-zero address."); + }); + }); + + describe("when fee recipient address is same address", async () => { + beforeEach(async () => { + subjectNewFeeRecipient = await indexModule.managerRebateRecipient(subjectSetToken.address); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Same fee recipient passed"); + }); + }); + + describe("when SetToken is not valid", async () => { + beforeEach(async () => { + const nonEnabledSetToken = await setup.createNonControllerEnabledSetToken( + [setup.weth.address], + [ether(1)], + [indexModule.address], + owner.address + ); + + subjectSetToken = nonEnabledSetToken; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be a valid and initialized SetToken"); + }); + }); + + describe("when the caller is not the SetToken manager", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be the SetToken manager"); + }); + }); + }); + + describe("#trade", async () => { + let subjectComponent: Address; + let subjectIncreaseTime: BigNumber; + let subjectEthQuantityLimit: BigNumber; + + let expectedOut: BigNumber; + + before(async () => { + // current units [ether(86.9565217), bitcoin(.01111111), ether(100), ZERO] + newComponents = []; + oldTargetUnits = [ether("60.869565780223716593"), bitcoin(.02), ether(50)]; + newTargetUnits = []; + issueAmount = ether("20.000000000000000001"); + }); + + const startRebalance = async () => { + await setup.approveAndIssueSetToken(subjectSetToken, issueAmount); + await indexModule.startRebalance( + subjectSetToken.address, + newComponents, + newTargetUnits, + oldTargetUnits, + await index.positionMultiplier() + ); + }; + + const initializeSubjectVariables = () => { + subjectSetToken = index; + subjectCaller = trader; + subjectComponent = setup.dai.address; + subjectIncreaseTime = ONE_MINUTE_IN_SECONDS.mul(5); + subjectEthQuantityLimit = ZERO; + }; + + async function subject(): Promise { + await increaseTimeAsync(subjectIncreaseTime); + return await indexModule.connect(subjectCaller.wallet).trade( + subjectSetToken.address, + subjectComponent, + subjectEthQuantityLimit + ); + } + + describe("with default target units", async () => { + beforeEach(async () => { + initializeSubjectVariables(); + + expectedOut = (await balancerSetup.exchange.viewSplitExactIn( + setup.dai.address, + setup.weth.address, + ether(1000), + THREE + )).totalOutput; + subjectEthQuantityLimit = expectedOut; + }); + cacheBeforeEach(startRebalance); + + it("the position units and lastTradeTimestamp should be set as expected, sell using Balancer", async () => { + const currentDaiAmount = await setup.dai.balanceOf(subjectSetToken.address); + const currentWethAmount = await setup.weth.balanceOf(subjectSetToken.address); + const totalSupply = await subjectSetToken.totalSupply(); + + await subject(); + + const lastBlockTimestamp = await getLastBlockTimestamp(); + + const expectedWethPositionUnits = preciseDiv(currentWethAmount.add(expectedOut), totalSupply); + const expectedDaiPositionUnits = preciseDiv(currentDaiAmount.sub(ether(1000)), totalSupply); + + const wethPositionUnits = await subjectSetToken.getDefaultPositionRealUnit(setup.weth.address); + const daiPositionUnits = await subjectSetToken.getDefaultPositionRealUnit(setup.dai.address); + + const lastTrade = (await indexModule.executionInfo(subjectSetToken.address, setup.dai.address)).lastTradeTimestamp; + + expect(wethPositionUnits).to.eq(expectedWethPositionUnits); + expect(daiPositionUnits).to.eq(expectedDaiPositionUnits); + expect(lastTrade).to.eq(lastBlockTimestamp); + }); + + it("emits the correct TradeExecuted event", async () => { + await expect(subject()).to.emit(indexModule, "TradeExecuted").withArgs( + subjectSetToken.address, + setup.dai.address, + setup.weth.address, + balancerExchangeAdapter.address, + trader.address, + ether(1000), + expectedOut, + ZERO, + ZERO + ); + }); + + describe("when there is a protocol fee charged", async () => { + let feePercentage: BigNumber; + let managerRebatePercentage: BigNumber; + + beforeEach(async () => { + feePercentage = ether(0.001); + managerRebatePercentage = ether(0.4); + setup.controller = setup.controller.connect(owner.wallet); + await setup.controller.addFee( + indexModule.address, + ZERO, // Fee type on trade function denoted as 0 + feePercentage // Set fee to 10 bps + ); + + await setup.controller.addFee( + indexModule.address, + ONE, // Fee type on trade function denoted as 0 + managerRebatePercentage // Set rebate to 40% + ); + }); + + it("the position units and lastTradeTimestamp should be set as expected, sell using Balancer", async () => { + const currentDaiAmount = await setup.dai.balanceOf(subjectSetToken.address); + const currentWethAmount = await setup.weth.balanceOf(subjectSetToken.address); + const totalSupply = await subjectSetToken.totalSupply(); + + await subject(); + + const lastBlockTimestamp = await getLastBlockTimestamp(); + + const protocolFee = expectedOut.mul(feePercentage).div(ether(1)); + const expectedWethPositionUnits = preciseDiv(currentWethAmount.add(expectedOut).sub(protocolFee), totalSupply); + const expectedDaiPositionUnits = preciseDiv(currentDaiAmount.sub(ether(1000)), totalSupply); + + const wethPositionUnits = await subjectSetToken.getDefaultPositionRealUnit(setup.weth.address); + const daiPositionUnits = await subjectSetToken.getDefaultPositionRealUnit(setup.dai.address); + + const lastTrade = (await indexModule.executionInfo(subjectSetToken.address, setup.dai.address)).lastTradeTimestamp; + + expect(wethPositionUnits).to.eq(expectedWethPositionUnits); + expect(daiPositionUnits).to.eq(expectedDaiPositionUnits); + expect(lastTrade).to.eq(lastBlockTimestamp); + }); + + it("the fees should be sent to the correct recipients", async () => { + const feeRecipient = await setup.controller.feeRecipient(); + const beforeProtocolWethBalance = await setup.weth.balanceOf(feeRecipient); + const beforeManagerWethBalance = await setup.weth.balanceOf(owner.address); + + await subject(); + + const postProtocolWethBalance = await setup.weth.balanceOf(feeRecipient); + const postManagerWethBalance = await setup.weth.balanceOf(owner.address); + + const totalFee = feePercentage.mul(expectedOut).div(ether(1)); + const managerRebate = preciseMul(totalFee, managerRebatePercentage); + const protocolFee = totalFee.sub(managerRebate); + + const expectedProtocolWethBalance = beforeProtocolWethBalance.add(protocolFee); + const expectedManagerWethBalance = beforeManagerWethBalance.add(managerRebate); + + expect(postProtocolWethBalance).to.eq(expectedProtocolWethBalance); + expect(postManagerWethBalance).to.eq(expectedManagerWethBalance); + }); + + it("emits the correct TradeExecuted event", async () => { + const totalFee = expectedOut.mul(feePercentage).div(ether(1)); + const managerRebate = preciseMul(totalFee, managerRebatePercentage); + const protocolFee = totalFee.sub(managerRebate); + + await expect(subject()).to.emit(indexModule, "TradeExecuted").withArgs( + subjectSetToken.address, + setup.dai.address, + setup.weth.address, + balancerExchangeAdapter.address, + trader.address, + ether(1000), + expectedOut.sub(totalFee), + protocolFee, + managerRebate + ); + }); + + describe("and the buy component does not meet the max trade size", async () => { + beforeEach(async () => { + await indexModule.startRebalance( + subjectSetToken.address, + [], + [], + [ether("60.869565780223716593"), bitcoin(.016), ether(50)], + await index.positionMultiplier() + ); + + await subject(); + + subjectComponent = setup.wbtc.address; + subjectEthQuantityLimit = MAX_UINT_256; + }); + + it("position units should match the target", async () => { + const totalSupply = await subjectSetToken.totalSupply(); + const currentWbtcUnit = await subjectSetToken.getDefaultPositionRealUnit(setup.wbtc.address); + const expectedWbtcSize = preciseDiv( + preciseMulCeil(bitcoin(.016), totalSupply).sub(preciseMul(currentWbtcUnit, totalSupply)), + PRECISE_UNIT.sub(feePercentage) + ); + + const [expectedIn, expectedOut] = await sushiswapSetup.router.getAmountsIn( + expectedWbtcSize, + [setup.weth.address, setup.wbtc.address] + ); + const currentWbtcAmount = await setup.wbtc.balanceOf(subjectSetToken.address); + const currentWethAmount = await setup.weth.balanceOf(subjectSetToken.address); + + const wethUnit = await subjectSetToken.getDefaultPositionRealUnit(setup.weth.address); + const wbtcUnit = await subjectSetToken.getDefaultPositionRealUnit(setup.wbtc.address); + + await subject(); + + const wbtcExcess = currentWbtcAmount.sub(preciseMul(totalSupply, wbtcUnit)); + const wethExcess = currentWethAmount.sub(preciseMul(totalSupply, wethUnit)); + + const expectedWethPositionUnits = preciseDiv(currentWethAmount.sub(expectedIn).sub(wethExcess), totalSupply); + const expectedWbtcPositionUnits = preciseDiv( + currentWbtcAmount.add(preciseMulCeil(expectedOut, PRECISE_UNIT.sub(feePercentage))).sub(wbtcExcess), + totalSupply + ); + + const wethPositionUnits = await subjectSetToken.getDefaultPositionRealUnit(setup.weth.address); + const wbtcPositionUnits = await subjectSetToken.getDefaultPositionRealUnit(setup.wbtc.address); + + expect(wbtcPositionUnits).to.eq(expectedWbtcPositionUnits); + expect(wethPositionUnits).to.eq(expectedWethPositionUnits); + }); + }); + }); + + describe("when the component being sold doesn't meet the max trade size", async () => { + beforeEach(async () => { + subjectComponent = uniswapSetup.uni.address; + subjectEthQuantityLimit = ZERO; + }); + + it("the trade gets rounded down to meet the target", async () => { + const totalSupply = await subjectSetToken.totalSupply(); + const currentUniUnit = await subjectSetToken.getDefaultPositionRealUnit(uniswapSetup.uni.address); + const expectedUniSize = preciseMul(currentUniUnit.sub(ether("60.869565780223716593")), totalSupply); + + const [expectedIn, expectedOut] = await uniswapSetup.router.getAmountsOut( + expectedUniSize, + [uniswapSetup.uni.address, setup.weth.address] + ); + + const currentUniAmount = await uniswapSetup.uni.balanceOf(subjectSetToken.address); + const currentWethAmount = await setup.weth.balanceOf(subjectSetToken.address); + + await subject(); + + const lastBlockTimestamp = await getLastBlockTimestamp(); + + const expectedWethPositionUnits = preciseDiv(currentWethAmount.add(expectedOut), totalSupply); + const expectedUniPositionUnits = preciseDiv(currentUniAmount.sub(expectedIn), totalSupply); + + const wethPositionUnits = await subjectSetToken.getDefaultPositionRealUnit(setup.weth.address); + const uniPositionUnits = await subjectSetToken.getDefaultPositionRealUnit(uniswapSetup.uni.address); + const lastTrade = (await indexModule.executionInfo(subjectSetToken.address, uniswapSetup.uni.address)).lastTradeTimestamp; + + expect(uniPositionUnits).to.eq(expectedUniPositionUnits); + expect(wethPositionUnits).to.eq(expectedWethPositionUnits); + expect(lastTrade).to.eq(lastBlockTimestamp); + }); + }); + + describe("when the component is being bought using Sushiswap", async () => { + beforeEach(async () => { + await subject(); // sell DAI for ETH on Balancer, as we would need ETH to buy WBTC on Sushiswap + + subjectComponent = setup.wbtc.address; + subjectEthQuantityLimit = MAX_UINT_256; + }); + + it("the position units and lastTradeTimestamp should be set as expected", async () => { + const [expectedIn, expectedOut] = await sushiswapSetup.router.getAmountsIn( + bitcoin(.1), + [setup.weth.address, setup.wbtc.address] + ); + const currentWbtcAmount = await setup.wbtc.balanceOf(subjectSetToken.address); + const currentWethAmount = await setup.weth.balanceOf(subjectSetToken.address); + + const wethUnit = await subjectSetToken.getDefaultPositionRealUnit(setup.weth.address); + const wbtcUnit = await subjectSetToken.getDefaultPositionRealUnit(setup.wbtc.address); + const totalSupply = await subjectSetToken.totalSupply(); + + await subject(); + + const lastBlockTimestamp = await getLastBlockTimestamp(); + + const wbtcExcess = currentWbtcAmount.sub(preciseMul(totalSupply, wbtcUnit)); + const wethExcess = currentWethAmount.sub(preciseMul(totalSupply, wethUnit)); + + const expectedWethPositionUnits = preciseDiv(currentWethAmount.sub(expectedIn).sub(wethExcess), totalSupply); + const expectedWbtcPositionUnits = preciseDiv(currentWbtcAmount.add(expectedOut).sub(wbtcExcess), totalSupply); + + const wethPositionUnits = await subjectSetToken.getDefaultPositionRealUnit(setup.weth.address); + const wbtcPositionUnits = await subjectSetToken.getDefaultPositionRealUnit(setup.wbtc.address); + const lastTrade = (await indexModule.executionInfo(subjectSetToken.address, setup.wbtc.address)).lastTradeTimestamp; + + expect(wbtcPositionUnits).to.eq(expectedWbtcPositionUnits); + expect(wethPositionUnits).to.eq(expectedWethPositionUnits); + expect(lastTrade).to.eq(lastBlockTimestamp); + }); + + it("emits the correct TradeExecuted event", async () => { + const [expectedIn, ] = await sushiswapSetup.router.getAmountsIn( + bitcoin(.1), + [setup.weth.address, setup.wbtc.address] + ); + await expect(subject()).to.emit(indexModule, "TradeExecuted").withArgs( + subjectSetToken.address, + setup.weth.address, + setup.wbtc.address, + sushiswapExchangeAdapter.address, + trader.address, + expectedIn, + bitcoin(.1), + ZERO, + ZERO + ); + }); + }); + + describe("when exchange is Uniswap V3", async () => { + describe("when component is beling sold using UniswapV3", async () => { + beforeEach(async () => { + await indexModule.setExchanges(subjectSetToken.address, [setup.dai.address], [uniswapV3AdapterName]); + await indexModule.setExchangeData(subjectSetToken.address, [setup.dai.address], [hexZeroPad(hexlify(3000), 3)]); + + expectedOut = await uniswapV3Setup.quoter.callStatic.quoteExactInputSingle( + setup.dai.address, + setup.weth.address, + 3000, + ether(1000), + 0 + ); + + subjectEthQuantityLimit = expectedOut; + }); + + afterEach(async () => { + await indexModule.setExchanges(subjectSetToken.address, [setup.dai.address], [balancerAdapterName]); + }); + + it("the position units and lastTradeTimestamp should be set as expected", async () => { + const currentDaiAmount = await setup.dai.balanceOf(subjectSetToken.address); + const currentWethAmount = await setup.weth.balanceOf(subjectSetToken.address); + const totalSupply = await subjectSetToken.totalSupply(); + + await subject(); + + const lastBlockTimestamp = await getLastBlockTimestamp(); + + const expectedWethPositionUnits = preciseDiv(currentWethAmount.add(expectedOut), totalSupply); + const expectedDaiPositionUnits = preciseDiv(currentDaiAmount.sub(ether(1000)), totalSupply); + + const wethPositionUnits = await subjectSetToken.getDefaultPositionRealUnit(setup.weth.address); + const daiPositionUnits = await subjectSetToken.getDefaultPositionRealUnit(setup.dai.address); + + const lastTrade = (await indexModule.executionInfo(subjectSetToken.address, setup.dai.address)).lastTradeTimestamp; + + expect(wethPositionUnits).to.eq(expectedWethPositionUnits); + expect(daiPositionUnits).to.eq(expectedDaiPositionUnits); + expect(lastTrade).to.eq(lastBlockTimestamp); + }); + + it("emits the correct TradeExecuted event", async () => { + await expect(subject()).to.emit(indexModule, "TradeExecuted").withArgs( + subjectSetToken.address, + setup.dai.address, + setup.weth.address, + uniswapV3ExchangeAdapter.address, + trader.address, + ether(1000), + expectedOut, + ZERO, + ZERO + ); + }); + }); + + describe("when component is being bought using UniswapV3", async () => { + beforeEach(async () => { + await subject(); // sell DAI for ETH on Balancer, as we would need ETH to buy WBTC on UniswapV3 + + await indexModule.setExchanges(subjectSetToken.address, [setup.wbtc.address], [uniswapV3AdapterName]); + await indexModule.setExchangeData(subjectSetToken.address, [setup.wbtc.address], [hexZeroPad(hexlify(3000), 3)]); + + subjectComponent = setup.wbtc.address; + subjectEthQuantityLimit = MAX_UINT_256; + }); + + afterEach(async () => { + await indexModule.setExchanges(subjectSetToken.address, [setup.wbtc.address], [sushiswapAdapterName]); + }); + + it("the position units and lastTradeTimestamp should be set as expected", async () => { + const amountOut = bitcoin(0.1); + const expectedIn = await uniswapV3Setup.quoter.callStatic.quoteExactOutputSingle( + setup.weth.address, + setup.wbtc.address, + 3000, + amountOut, + 0 + ); + + const currentWbtcAmount = await setup.wbtc.balanceOf(subjectSetToken.address); + const currentWethAmount = await setup.weth.balanceOf(subjectSetToken.address); + + const wethUnit = await subjectSetToken.getDefaultPositionRealUnit(setup.weth.address); + const wbtcUnit = await subjectSetToken.getDefaultPositionRealUnit(setup.wbtc.address); + const totalSupply = await subjectSetToken.totalSupply(); + + await subject(); + + const lastBlockTimestamp = await getLastBlockTimestamp(); + + const wbtcExcess = currentWbtcAmount.sub(preciseMul(totalSupply, wbtcUnit)); + const wethExcess = currentWethAmount.sub(preciseMul(totalSupply, wethUnit)); + + const expectedWethPositionUnits = preciseDiv(currentWethAmount.sub(expectedIn).sub(wethExcess), totalSupply); + const expectedWbtcPositionUnits = preciseDiv(currentWbtcAmount.add(amountOut).sub(wbtcExcess), totalSupply); + + const wethPositionUnits = await subjectSetToken.getDefaultPositionRealUnit(setup.weth.address); + const wbtcPositionUnits = await subjectSetToken.getDefaultPositionRealUnit(setup.wbtc.address); + const lastTrade = (await indexModule.executionInfo(subjectSetToken.address, setup.wbtc.address)).lastTradeTimestamp; + + expect(wbtcPositionUnits).to.eq(expectedWbtcPositionUnits); + expect(wethPositionUnits).to.eq(expectedWethPositionUnits); + expect(lastTrade).to.eq(lastBlockTimestamp); + }); + }); + }); + + describe("when exchange is Kyber V3 DMM", async () => { + describe("when component is beling sold using Kyber V3 DMM exchange", async () => { + beforeEach(async () => { + await indexModule.setExchanges(subjectSetToken.address, [setup.dai.address], [kyberV3AdapterName]); + await indexModule.setExchangeData(subjectSetToken.address, [setup.dai.address], [kyberV3Setup.wethDaiPool.address.toLowerCase()]); + + [, expectedOut] = await kyberV3Setup.dmmRouter.getAmountsOut( + ether(1000), + [kyberV3Setup.wethDaiPool.address], + [setup.dai.address, setup.weth.address] + ); + + subjectEthQuantityLimit = expectedOut; + }); + + afterEach(async () => { + await indexModule.setExchanges(subjectSetToken.address, [setup.dai.address], [balancerAdapterName]); + }); + + it("the position units and lastTradeTimestamp should be set as expected", async () => { + const currentDaiAmount = await setup.dai.balanceOf(subjectSetToken.address); + const currentWethAmount = await setup.weth.balanceOf(subjectSetToken.address); + const totalSupply = await subjectSetToken.totalSupply(); + + await subject(); + + const lastBlockTimestamp = await getLastBlockTimestamp(); + + const expectedWethPositionUnits = preciseDiv(currentWethAmount.add(expectedOut), totalSupply); + const expectedDaiPositionUnits = preciseDiv(currentDaiAmount.sub(ether(1000)), totalSupply); + + const wethPositionUnits = await subjectSetToken.getDefaultPositionRealUnit(setup.weth.address); + const daiPositionUnits = await subjectSetToken.getDefaultPositionRealUnit(setup.dai.address); + + const lastTrade = (await indexModule.executionInfo(subjectSetToken.address, setup.dai.address)).lastTradeTimestamp; + + expect(wethPositionUnits).to.eq(expectedWethPositionUnits); + expect(daiPositionUnits).to.eq(expectedDaiPositionUnits); + expect(lastTrade).to.eq(lastBlockTimestamp); + }); + + it("emits the correct TradeExecuted event", async () => { + await expect(subject()).to.emit(indexModule, "TradeExecuted").withArgs( + subjectSetToken.address, + setup.dai.address, + setup.weth.address, + kyberV3ExchangeAdapter.address, + trader.address, + ether(1000), + expectedOut, + ZERO, + ZERO + ); + }); + }); + + describe("when component is being bought using KyberV3", async () => { + beforeEach(async () => { + await subject(); // sell DAI for ETH on Balancer, as we would need ETH to buy WBTC on KyberV3 + + await indexModule.setExchanges(subjectSetToken.address, [setup.wbtc.address], [kyberV3AdapterName]); + await indexModule.setExchangeData(subjectSetToken.address, [setup.wbtc.address], [kyberV3Setup.wethWbtcPool.address]); + + subjectComponent = setup.wbtc.address; + subjectEthQuantityLimit = MAX_UINT_256; + }); + + afterEach(async () => { + await indexModule.setExchanges(subjectSetToken.address, [setup.wbtc.address], [sushiswapAdapterName]); + }); + + it("the position units and lastTradeTimestamp should be set as expected", async () => { + const amountOut = bitcoin(0.1); + const [expectedIn, ] = await kyberV3Setup.dmmRouter.getAmountsIn( + amountOut, + [kyberV3Setup.wethWbtcPool.address], + [setup.weth.address, setup.wbtc.address] + ); + + const currentWbtcAmount = await setup.wbtc.balanceOf(subjectSetToken.address); + const currentWethAmount = await setup.weth.balanceOf(subjectSetToken.address); + + const wethUnit = await subjectSetToken.getDefaultPositionRealUnit(setup.weth.address); + const wbtcUnit = await subjectSetToken.getDefaultPositionRealUnit(setup.wbtc.address); + const totalSupply = await subjectSetToken.totalSupply(); + + await subject(); + + const lastBlockTimestamp = await getLastBlockTimestamp(); + + const wbtcExcess = currentWbtcAmount.sub(preciseMul(totalSupply, wbtcUnit)); + const wethExcess = currentWethAmount.sub(preciseMul(totalSupply, wethUnit)); + + const expectedWethPositionUnits = preciseDiv(currentWethAmount.sub(expectedIn).sub(wethExcess), totalSupply); + const expectedWbtcPositionUnits = preciseDiv(currentWbtcAmount.add(amountOut).sub(wbtcExcess), totalSupply); + + const wethPositionUnits = await subjectSetToken.getDefaultPositionRealUnit(setup.weth.address); + const wbtcPositionUnits = await subjectSetToken.getDefaultPositionRealUnit(setup.wbtc.address); + const lastTrade = (await indexModule.executionInfo(subjectSetToken.address, setup.wbtc.address)).lastTradeTimestamp; + + expect(wbtcPositionUnits).to.eq(expectedWbtcPositionUnits); + expect(wethPositionUnits).to.eq(expectedWethPositionUnits); + expect(lastTrade).to.eq(lastBlockTimestamp); + }); + }); + }); + + describe("when exchange doesn't return minimum receive eth amount, while selling component", async () => { + beforeEach(async () => { + expectedOut = (await balancerSetup.exchange.viewSplitExactIn( + setup.dai.address, + setup.weth.address, + ether(1000), + THREE + )).totalOutput; + subjectEthQuantityLimit = expectedOut.mul(2); + }); + + it("should revert", async () => { + await expect(subject()).to.be.reverted; + }); + }); + + describe("when exchange takes more than maximum input eth amount, while buying component", async () => { + beforeEach(async () => { + subjectComponent = setup.wbtc.address; + const [expectedIn, ] = await sushiswapSetup.router.getAmountsOut( + bitcoin(.1), + [setup.wbtc.address, setup.weth.address] + ); + subjectEthQuantityLimit = expectedIn.div(2); + }); + + it("should revert", async () => { + await expect(subject()).to.be.reverted; + }); + }); + + describe("when anyoneTrade is true and a random address calls", async () => { + beforeEach(async () => { + await indexModule.setAnyoneTrade(subjectSetToken.address, true); + subjectCaller = await getRandomAccount(); + }); + + it("the trade should not revert", async () => { + await expect(subject()).to.not.be.reverted; + }); + }); + + describe("when not enough time has elapsed between trades", async () => { + beforeEach(async () => { + await subject(); + subjectIncreaseTime = ZERO; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Component cool off in progress"); + }); + }); + + describe("when exchange adapter has been removed from integration registry", async () => { + beforeEach(async () => { + await indexModule.setExchanges(subjectSetToken.address, [subjectComponent], [balancerAdapterName]); + await setup.integrationRegistry.removeIntegration(indexModule.address, balancerAdapterName); + }); + + afterEach(async () => { + await setup.integrationRegistry.addIntegration( + indexModule.address, + balancerAdapterName, + balancerExchangeAdapter.address + ); + }); + + it("the trade reverts", async () => { + await expect(subject()).to.be.revertedWith("Must be valid adapter"); + }); + }); + + describe("when the passed component is not included in the rebalance", async () => { + beforeEach(async () => { + subjectComponent = sushiswapSetup.uni.address; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Component not part of rebalance"); + }); + }); + + describe("when the calling address is not a permissioned address", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Address not permitted to trade"); + }); + }); + + describe("when the component is weth", async () => { + beforeEach(async () => { + subjectComponent = setup.weth.address; + }); + + it("should revert", async () => { + expect(subject()).to.be.revertedWith("Can not explicitly trade WETH"); + }); + }); + + describe("when there are external positions for a component", async () => { + beforeEach(async () => { + await subjectSetToken.connect(positionModule.wallet).addExternalPositionModule( + subjectComponent, + positionModule.address + ); + }); + + afterEach(async () => { + await subjectSetToken.connect(positionModule.wallet).removeExternalPositionModule( + subjectComponent, + positionModule.address + ); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("External positions not allowed"); + }); + }); + + describe("when caller is a contract", async () => { + let subjectTarget: Address; + let subjectCallData: string; + let subjectValue: BigNumber; + + let contractCaller: ContractCallerMock; + + beforeEach(async () => { + contractCaller = await deployer.mocks.deployContractCallerMock(); + await indexModule.connect(owner.wallet).setTraderStatus(subjectSetToken.address, [contractCaller.address], [true]); + + subjectTarget = indexModule.address; + subjectCallData = indexModule.interface.encodeFunctionData("trade", [subjectSetToken.address, subjectComponent, ZERO]); + subjectValue = ZERO; + }); + + async function subjectContractCaller(): Promise { + return await contractCaller.invoke( + subjectTarget, + subjectValue, + subjectCallData + ); + } + + it("should not revert", async () => { + await expect(subjectContractCaller()).to.not.be.reverted; + }); + + describe("when anyone trade is true", async () => { + beforeEach(async () => { + await indexModule.connect(owner.wallet).setAnyoneTrade(subjectSetToken.address, true); + }); + + it("the trader reverts", async () => { + await expect(subjectContractCaller()).to.be.revertedWith("Caller must be EOA Address"); + }); + }); + }); + }); + + describe("with alternative target units", async () => { + before(async () => { + oldTargetUnits = [ether(100), ZERO, ether(185)]; + }); + + after(async () => { + oldTargetUnits = [ether(60.869565), bitcoin(.02), ether(50)]; + }); + + beforeEach(async () => { + initializeSubjectVariables(); + + expectedOut = (await balancerSetup.exchange.viewSplitExactIn( + setup.dai.address, + setup.weth.address, + ether(1000), + THREE + )).totalOutput; + subjectEthQuantityLimit = expectedOut; + }); + cacheBeforeEach(startRebalance); + + describe("when the sell happens on Sushiswap", async () => { + beforeEach(async () => { + subjectComponent = setup.wbtc.address; + subjectEthQuantityLimit = ZERO; + }); + + it("the position units and lastTradeTimestamp should be set as expected", async () => { + const [expectedIn, expectedOut] = await sushiswapSetup.router.getAmountsOut( + bitcoin(.1), + [setup.wbtc.address, setup.weth.address] + ); + + const currentWbtcAmount = await setup.wbtc.balanceOf(subjectSetToken.address); + const currentWethAmount = await setup.weth.balanceOf(subjectSetToken.address); + + const wethUnit = await subjectSetToken.getDefaultPositionRealUnit(setup.weth.address); + const wbtcUnit = await subjectSetToken.getDefaultPositionRealUnit(setup.wbtc.address); + const totalSupply = await subjectSetToken.totalSupply(); + + await subject(); + + const lastBlockTimestamp = await getLastBlockTimestamp(); + + const wbtcExcess = currentWbtcAmount.sub(preciseMul(totalSupply, wbtcUnit)); + const wethExcess = currentWethAmount.sub(preciseMul(totalSupply, wethUnit)); + + const expectedWethPositionUnits = preciseDiv(currentWethAmount.add(expectedOut).sub(wethExcess), totalSupply); + const expectedWbtcPositionUnits = preciseDiv(currentWbtcAmount.sub(expectedIn).sub(wbtcExcess), totalSupply); + + const wethPositionUnits = await subjectSetToken.getDefaultPositionRealUnit(setup.weth.address); + const wbtcPositionUnits = await subjectSetToken.getDefaultPositionRealUnit(setup.wbtc.address); + const lastTrade = (await indexModule.executionInfo(subjectSetToken.address, setup.wbtc.address)).lastTradeTimestamp; + + expect(wbtcPositionUnits).to.eq(expectedWbtcPositionUnits); + expect(wethPositionUnits).to.eq(expectedWethPositionUnits); + expect(lastTrade).to.eq(lastBlockTimestamp); + }); + + describe("sell trade zeroes out the asset", async () => { + before(async () => { + oldTargetUnits = [ether(100), ZERO, ether(185)]; + }); + + beforeEach(async () => { + await increaseTimeAsync(ONE_MINUTE_IN_SECONDS.mul(5)); + await indexModule.connect(trader.wallet).trade(subjectSetToken.address, setup.wbtc.address, ZERO); + await increaseTimeAsync(ONE_MINUTE_IN_SECONDS.mul(5)); + await indexModule.connect(trader.wallet).trade(subjectSetToken.address, setup.wbtc.address, ZERO); + }); + + after(async () => { + oldTargetUnits = [ether(60.869565), bitcoin(.02), ether(50)]; + }); + + it("should remove the asset from the index", async () => { + await subject(); + + const components = await subjectSetToken.getComponents(); + const positionUnit = await subjectSetToken.getDefaultPositionRealUnit(setup.wbtc.address); + + expect(components).to.not.contain(setup.wbtc.address); + expect(positionUnit).to.eq(ZERO); + }); + }); + }); + + describe("when the buy happens on Balancer", async () => { + beforeEach(async () => { + await increaseTimeAsync(ONE_MINUTE_IN_SECONDS.mul(5)); + await indexModule.connect(trader.wallet).trade(subjectSetToken.address, setup.wbtc.address, ZERO); + await increaseTimeAsync(ONE_MINUTE_IN_SECONDS.mul(5)); + await indexModule.connect(trader.wallet).trade(subjectSetToken.address, setup.wbtc.address, ZERO); + + subjectComponent = setup.dai.address; + subjectEthQuantityLimit = MAX_UINT_256; + }); + + it("the position units and lastTradeTimestamp should be set as expected", async () => { + const expectedIn = (await balancerSetup.exchange.viewSplitExactOut( + setup.weth.address, + setup.dai.address, + ether(1000), + THREE + )).totalOutput; + const currentDaiAmount = await setup.dai.balanceOf(subjectSetToken.address); + const currentWethAmount = await setup.weth.balanceOf(subjectSetToken.address); + + const wethUnit = await subjectSetToken.getDefaultPositionRealUnit(setup.weth.address); + const daiUnit = await subjectSetToken.getDefaultPositionRealUnit(setup.dai.address); + const totalSupply = await subjectSetToken.totalSupply(); + + await subject(); + + const lastBlockTimestamp = await getLastBlockTimestamp(); + + const wethPositionUnits = await subjectSetToken.getDefaultPositionRealUnit(setup.weth.address); + const daiPositionUnits = await subjectSetToken.getDefaultPositionRealUnit(setup.dai.address); + const lastTrade = (await indexModule.executionInfo(subjectSetToken.address, setup.dai.address)).lastTradeTimestamp; + + const daiExcess = currentDaiAmount.sub(preciseMul(totalSupply, daiUnit)); + const wethExcess = currentWethAmount.sub(preciseMul(totalSupply, wethUnit)); + const expectedWethPositionUnits = preciseDiv( + currentWethAmount.sub(expectedIn).sub(wethExcess), + totalSupply + ); + const expectedDaiPositionUnits = preciseDiv( + currentDaiAmount.add(ether(1000)).sub(daiExcess), + totalSupply + ); + + expect(wethPositionUnits).to.eq(expectedWethPositionUnits); + expect(daiPositionUnits).to.eq(expectedDaiPositionUnits); + expect(lastTrade).to.eq(lastBlockTimestamp); + }); + }); + }); + + describe("when alternative issue amount", async () => { + before(async () => { + issueAmount = ether(20); + }); + + after(async () => { + issueAmount = ether("20.000000000000000001"); + }); + + beforeEach(async () => { + initializeSubjectVariables(); + + expectedOut = (await balancerSetup.exchange.viewSplitExactIn( + setup.dai.address, + setup.weth.address, + ether(1000), + THREE + )).totalOutput; + subjectEthQuantityLimit = expectedOut; + }); + cacheBeforeEach(startRebalance); + + describe("when fees are accrued and target is met", async () => { + beforeEach(async () => { + await increaseTimeAsync(ONE_MINUTE_IN_SECONDS.mul(5)); + await indexModule.connect(trader.wallet).trade(subjectSetToken.address, setup.dai.address, ZERO); + + await setup.streamingFeeModule.accrueFee(subjectSetToken.address); + }); + + it("the trade reverts", async () => { + const targetUnit = (await indexModule.executionInfo(subjectSetToken.address, setup.dai.address)).targetUnit; + const currentUnit = await subjectSetToken.getDefaultPositionRealUnit(setup.dai.address); + + expect(targetUnit).to.not.eq(currentUnit); + await expect(subject()).to.be.revertedWith("Target already met"); + }); + }); + + describe("when the target has been met", async () => { + + beforeEach(async () => { + await increaseTimeAsync(ONE_MINUTE_IN_SECONDS.mul(5)); + await indexModule.connect(trader.wallet).trade(subjectSetToken.address, setup.dai.address, ZERO); + }); + + it("the trade reverts", async () => { + await expect(subject()).to.be.revertedWith("Target already met"); + }); + }); + }); + + describe("when set has weth as component", async () => { + beforeEach(async () => { + // current units [ether(86.9565217), bitcoin(.01111111), ether(100), ether(0.434782609), ZERO] + oldTargetUnits = [ether("60.869565780223716593"), bitcoin(.02), ether(50), ether(0.434782609)]; + issueAmount = ether(20); + + expectedOut = (await balancerSetup.exchange.viewSplitExactIn( + setup.dai.address, + setup.weth.address, + ether(1000), + THREE + )).totalOutput; + subjectEthQuantityLimit = expectedOut; + + initializeSubjectVariables(); + subjectSetToken = indexWithWeth; + + await startRebalance(); + }); + + after(async () => { + // current units [ether(86.9565217), bitcoin(.01111111), ether(100), ZERO] + oldTargetUnits = [ether("60.869565780223716593"), bitcoin(.02), ether(50)]; + issueAmount = ether("20.000000000000000001"); + }); + + it("the position units and lastTradeTimestamp should be set as expected, sell using Balancer", async () => { + const currentDaiAmount = await setup.dai.balanceOf(subjectSetToken.address); + const currentWethAmount = await setup.weth.balanceOf(subjectSetToken.address); + const totalSupply = await subjectSetToken.totalSupply(); + + await subject(); + + const lastBlockTimestamp = await getLastBlockTimestamp(); + + const expectedWethPositionUnits = preciseDiv(currentWethAmount.add(expectedOut), totalSupply); + const expectedDaiPositionUnits = preciseDiv(currentDaiAmount.sub(ether(1000)), totalSupply); + + const wethPositionUnits = await subjectSetToken.getDefaultPositionRealUnit(setup.weth.address); + const daiPositionUnits = await subjectSetToken.getDefaultPositionRealUnit(setup.dai.address); + + const lastTrade = (await indexModule.executionInfo(subjectSetToken.address, setup.dai.address)).lastTradeTimestamp; + + expect(wethPositionUnits).to.eq(expectedWethPositionUnits); + expect(daiPositionUnits).to.eq(expectedDaiPositionUnits); + expect(lastTrade).to.eq(lastBlockTimestamp); + }); + }); + + describe("when adding a new asset", async () => { + before(async () => { + oldTargetUnits = [ether(100), ZERO, ether(185)]; + newComponents = [sushiswapSetup.uni.address]; + newTargetUnits = [ether(50)]; + }); + + beforeEach(async () => { + await setup.weth.connect(owner.wallet).approve(sushiswapSetup.router.address, ether(100)); + await sushiswapSetup.uni.connect(owner.wallet).approve(sushiswapSetup.router.address, ether(20000)); + await sushiswapSetup.router.connect(owner.wallet).addLiquidity( + setup.weth.address, + sushiswapSetup.uni.address, + ether(100), + ether(20000), + ether(90), + ether(19000), + owner.address, + MAX_UINT_256 + ); + + initializeSubjectVariables(); + subjectComponent = sushiswapSetup.uni.address; + subjectEthQuantityLimit = MAX_UINT_256; + + await startRebalance(); + + await increaseTimeAsync(ONE_MINUTE_IN_SECONDS.mul(5)); + await indexModule.connect(trader.wallet).trade(subjectSetToken.address, setup.wbtc.address, ZERO); + }); + + after(async () => { + oldTargetUnits = [ether(60.869565), bitcoin(.02), ether(50)]; + newComponents = []; + newTargetUnits = []; + }); + + it("the position units and lastTradeTimestamp should be set as expected", async () => { + await subject(); + + const lastBlockTimestamp = await getLastBlockTimestamp(); + const totalSupply = await subjectSetToken.totalSupply(); + const components = await subjectSetToken.getComponents(); + const expectedSushiPositionUnits = preciseDiv(ether(500), totalSupply); + + const sushiPositionUnits = await subjectSetToken.getDefaultPositionRealUnit(sushiswapSetup.uni.address); + const lastTrade = (await indexModule.executionInfo(subjectSetToken.address, sushiswapSetup.uni.address)).lastTradeTimestamp; + + expect(components).to.contain(sushiswapSetup.uni.address); + expect(sushiPositionUnits).to.eq(expectedSushiPositionUnits); + expect(lastTrade).to.eq(lastBlockTimestamp); + }); + }); + }); + + describe("#tradeRemainingWETH", async () => { + let subjectComponent: Address; + let subjectIncreaseTime: BigNumber; + let subjectMinComponentReceived: BigNumber; + + before(async () => { + // current units [ether(86.9565217), bitcoin(.01111111), ether(100)] + oldTargetUnits = [ether(60.869565), bitcoin(.02), ether(50)]; + }); + + const startRebalanceAndTrade = async () => { + // oldTargetUnits = [ether(60.869565), bitcoin(.02), ether(50)]; + await setup.approveAndIssueSetToken(subjectSetToken, ether(20)); + await indexModule.startRebalance(subjectSetToken.address, [], [], oldTargetUnits, await subjectSetToken.positionMultiplier()); + + await increaseTimeAsync(ONE_MINUTE_IN_SECONDS.mul(5)); + await indexModule.connect(trader.wallet).trade(subjectSetToken.address, setup.dai.address, ZERO); + await indexModule.connect(trader.wallet).trade(subjectSetToken.address, uniswapSetup.uni.address, ZERO); + await indexModule.connect(trader.wallet).trade(subjectSetToken.address, setup.wbtc.address, MAX_UINT_256); + }; + + const getFixedAmountIn = async (setToken: SetToken, component: Address, considerMaxSize: boolean = false) => { + const totalSupply = await setToken.totalSupply(); + const componentMaxSize = considerMaxSize ? (await indexModule.executionInfo(setToken.address, component)).maxSize : MAX_UINT_256; + const currentPositionMultiplier = await setToken.positionMultiplier(); + const positionMultiplier = (await indexModule.rebalanceInfo(setToken.address)).positionMultiplier; + + const currentUnit = await setToken.getDefaultPositionRealUnit(component); + const targetUnit = (await indexModule.executionInfo(setToken.address, component)).targetUnit; + const normalizedTargetUnit = targetUnit.mul(currentPositionMultiplier).div(positionMultiplier); + + const currentNotional = preciseMul(totalSupply, currentUnit); + const targetNotional = preciseMulCeil(totalSupply, normalizedTargetUnit); + + if (targetNotional.lt(currentNotional)) { + return componentMaxSize.lt(currentNotional.sub(targetNotional)) ? componentMaxSize : currentNotional.sub(targetNotional); + } else { + return componentMaxSize.lt(targetNotional.sub(currentNotional)) ? componentMaxSize : targetNotional.sub(currentNotional); + } + }; + + const initializeSubjectVariables = () => { + subjectCaller = trader; + subjectSetToken = index; + subjectComponent = setup.wbtc.address; + subjectIncreaseTime = ONE_MINUTE_IN_SECONDS.mul(5); + subjectMinComponentReceived = ZERO; + }; + + async function subject(): Promise { + await increaseTimeAsync(subjectIncreaseTime); + return await indexModule.connect(subjectCaller.wallet).tradeRemainingWETH( + subjectSetToken.address, + subjectComponent, + subjectMinComponentReceived + ); + } + + describe("with default target units", () => { + let wethAmountIn: BigNumber; + let expectedWbtcOut: BigNumber; + + beforeEach(initializeSubjectVariables); + cacheBeforeEach(startRebalanceAndTrade); + + describe("when ETH remaining in contract, trade remaining WETH", async () => { + beforeEach(async () => { + wethAmountIn = await getFixedAmountIn(subjectSetToken, setup.weth.address); + [, expectedWbtcOut] = await sushiswapSetup.router.getAmountsOut( + wethAmountIn, + [setup.weth.address, setup.wbtc.address] + ); + + subjectMinComponentReceived = expectedWbtcOut; + }); + + it("the position units and lastTradeTimestamp should be set as expected", async () => { + const totalSupply = await subjectSetToken.totalSupply(); + const currentWbtcAmount = await setup.wbtc.balanceOf(subjectSetToken.address); + + await subject(); + + const lastBlockTimestamp = await getLastBlockTimestamp(); + + const expectedWbtcPositionUnits = preciseDiv(currentWbtcAmount.add(expectedWbtcOut), totalSupply); + + const wethPositionUnits = await subjectSetToken.getDefaultPositionRealUnit(setup.weth.address); + const wbtcPositionUnits = await subjectSetToken.getDefaultPositionRealUnit(setup.wbtc.address); + const lastTrade = (await indexModule.executionInfo(subjectSetToken.address, subjectComponent)).lastTradeTimestamp; + + expect(wethPositionUnits).to.eq(ZERO); + expect(wbtcPositionUnits).to.eq(expectedWbtcPositionUnits); + expect(lastTrade).to.eq(lastBlockTimestamp); + }); + + it("emits the correct TradeExecuted event", async () => { + await expect(subject()).to.be.emit(indexModule, "TradeExecuted").withArgs( + subjectSetToken.address, + setup.weth.address, + subjectComponent, + sushiswapExchangeAdapter.address, + subjectCaller.wallet.address, + wethAmountIn, + expectedWbtcOut, + ZERO, + ZERO + ); + }); + + describe("when protocol fee is charged", async () => { + let subjectFeePercentage: BigNumber; + let subjectManagerRebateSplit: BigNumber; + + beforeEach(async () => { + subjectFeePercentage = ether(0.05); + subjectManagerRebateSplit = ether(0.3); + setup.controller = setup.controller.connect(owner.wallet); + await setup.controller.addFee( + indexModule.address, + ZERO, // Fee type on trade function denoted as 0 + subjectFeePercentage // Set fee to 50 bps + ); + + await setup.controller.addFee( + indexModule.address, + ONE, // Fee type on trade function denoted as 1 + subjectManagerRebateSplit // Set split to 30% + ); + }); + + it("the position units and lastTradeTimestamp should be set as expected", async () => { + const totalSupply = await subjectSetToken.totalSupply(); + const currentWbtcAmount = await setup.wbtc.balanceOf(subjectSetToken.address); + + await subject(); + + const lastBlockTimestamp = await getLastBlockTimestamp(); + + const protocolFee = expectedWbtcOut.mul(subjectFeePercentage).div(ether(1)); + const expectedWbtcPositionUnits = preciseDiv(currentWbtcAmount.add(expectedWbtcOut).sub(protocolFee), totalSupply); + + const wethPositionUnits = await subjectSetToken.getDefaultPositionRealUnit(setup.weth.address); + const wbtcPositionUnits = await subjectSetToken.getDefaultPositionRealUnit(setup.wbtc.address); + const lastTrade = (await indexModule.executionInfo(subjectSetToken.address, subjectComponent)).lastTradeTimestamp; + + expect(wethPositionUnits).to.eq(ZERO); + expect(wbtcPositionUnits).to.eq(expectedWbtcPositionUnits); + expect(lastTrade).to.eq(lastBlockTimestamp); + }); + + it("the fees should be sent to the correct recipients", async () => { + const feeRecipient = await setup.controller.feeRecipient(); + const beforeProtocolWbtcBalance = await setup.wbtc.balanceOf(feeRecipient); + const beforeManagerWbtcBalance = await setup.wbtc.balanceOf(owner.address); + + await subject(); + + const postProtocolWbtcBalance = await setup.wbtc.balanceOf(feeRecipient); + const postManagerWbtcBalance = await setup.wbtc.balanceOf(owner.address); + + const totalFee = subjectFeePercentage.mul(expectedWbtcOut).div(ether(1)); + const managerRebate = preciseMul(totalFee, subjectManagerRebateSplit); + const protocolFee = totalFee.sub(managerRebate); + + const expectedProtocolWbtcBalance = beforeProtocolWbtcBalance.add(protocolFee); + const expectedManagerWbtcBalance = beforeManagerWbtcBalance.add(managerRebate); + + expect(postProtocolWbtcBalance).to.eq(expectedProtocolWbtcBalance); + expect(postManagerWbtcBalance).to.eq(expectedManagerWbtcBalance); + }); + + it("emits the correct TradeExecuted event", async () => { + const totalFee = expectedWbtcOut.mul(subjectFeePercentage).div(ether(1)); + const managerRebate = preciseMul(totalFee, subjectManagerRebateSplit); + const protocolFee = totalFee.sub(managerRebate); + await expect(subject()).to.be.emit(indexModule, "TradeExecuted").withArgs( + subjectSetToken.address, + setup.weth.address, + subjectComponent, + sushiswapExchangeAdapter.address, + subjectCaller.wallet.address, + wethAmountIn, + expectedWbtcOut.sub(totalFee), + protocolFee, + managerRebate + ); + }); + + describe("when the prototol fee percentage is 100", async () => { + beforeEach(async () => { + subjectFeePercentage = ether(100); + await setup.controller.editFee( + indexModule.address, + ZERO, // Fee type on trade function denoted as 0 + subjectFeePercentage + ); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("transfer amount exceeds balance"); + }); + }); + + describe("when the prototol fee percentage is MAX_UINT_256", async () => { + beforeEach(async () => { + subjectFeePercentage = ether(100); + await setup.controller.editFee( + indexModule.address, + ZERO, // Fee type on trade function denoted as 0 + subjectFeePercentage + ); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("transfer amount exceeds balance"); + }); + }); + }); + + describe("when exchange returns amount less than subjectMinComponentReceived", async () => { + beforeEach(async () => { + [, expectedWbtcOut] = await sushiswapSetup.router.getAmountsOut( + wethAmountIn, + [setup.weth.address, setup.wbtc.address] + ); + subjectMinComponentReceived = expectedWbtcOut.mul(2); + }); + + it("should revert", async () => { + await expect(subject()).to.be.reverted; + }); + }); + + describe("when the target has been met and trading overshoots target unit", async () => { + beforeEach(async () => { + subjectComponent = setup.dai.address; + subjectMinComponentReceived = ZERO; + }); + + it("the trade reverts", async () => { + await expect(subject()).to.be.revertedWith("Can not exceed target unit"); + }); + }); + + describe("when not enough time has elapsed between trades", async () => { + beforeEach(async () => { + subjectIncreaseTime = ZERO; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Component cool off in progress"); + }); + }); + + describe("when the passed component is not included in rebalance components", async () => { + beforeEach(async () => { + subjectComponent = sushiswapSetup.uni.address; + subjectMinComponentReceived = ZERO; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Component not part of rebalance"); + }); + }); + + describe("when there are external positions for a component", async () => { + beforeEach(async () => { + await subjectSetToken.connect(positionModule.wallet).addExternalPositionModule( + subjectComponent, + positionModule.address + ); + }); + + afterEach(async () => { + await subjectSetToken.connect(positionModule.wallet).removeExternalPositionModule( + subjectComponent, + positionModule.address + ); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("External positions not allowed"); + }); + }); + + describe("when the calling address is not a permissioned address", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Address not permitted to trade"); + }); + }); + + describe("when caller is a contract", async () => { + let subjectTarget: Address; + let subjectCallData: string; + let subjectValue: BigNumber; + + let contractCaller: ContractCallerMock; + + beforeEach(async () => { + contractCaller = await deployer.mocks.deployContractCallerMock(); + await indexModule.connect(owner.wallet).setTraderStatus(subjectSetToken.address, [contractCaller.address], [true]); + + subjectTarget = indexModule.address; + subjectIncreaseTime = ONE_MINUTE_IN_SECONDS.mul(5); + subjectCallData = indexModule.interface.encodeFunctionData( + "tradeRemainingWETH", + [subjectSetToken.address, subjectComponent, subjectMinComponentReceived] + ); + subjectValue = ZERO; + }); + + async function subjectContractCaller(): Promise { + await increaseTimeAsync(subjectIncreaseTime); + return await contractCaller.invoke( + subjectTarget, + subjectValue, + subjectCallData + ); + } + + it("the trade reverts", async () => { + await expect(subjectContractCaller()).to.not.be.reverted; + }); + }); + }); + }); + + describe("with alternative target units", () => { + describe("when the value of WETH in index exceeds component trade size", async () => { + beforeEach(async () => { + oldTargetUnits = [ether(60.869565), bitcoin(.019), ether(50)]; + + initializeSubjectVariables(); + + await startRebalanceAndTrade(); + await indexModule.connect(owner.wallet).setTradeMaximums(subjectSetToken.address, [subjectComponent], [bitcoin(.01)]); + }); + + after(async () => { + await indexModule.connect(owner.wallet).setTradeMaximums( + subjectSetToken.address, + [subjectComponent], + [bitcoin(.1)] + ); + }); + + it("the trade reverts", async () => { + await expect(subject()).to.be.revertedWith("Trade amount > max trade size"); + }); + }); + + describe("when sellable components still remain", async () => { + beforeEach(async () => { + oldTargetUnits = [ether(60.869565), bitcoin(.019), ether(48)]; + initializeSubjectVariables(); + + await startRebalanceAndTrade(); + }); + + it("the trade reverts", async () => { + await expect(subject()).to.be.revertedWith("Sell other set components first"); + }); + }); + }); + + describe("when set has weth as component", async () => { + before(async () => { + oldTargetUnits = [ether(60.869565), bitcoin(.02), ether(50), ether(.434782609)]; + }); + + beforeEach(async () => { + initializeSubjectVariables(); + subjectSetToken = indexWithWeth; + + await startRebalanceAndTrade(); + }); + + it("the position units and lastTradeTimestamp should be set as expected", async () => { + const wethAmountIn = await getFixedAmountIn(subjectSetToken, setup.weth.address); + const [, expectedWbtcOut] = await sushiswapSetup.router.getAmountsOut( + wethAmountIn, + [setup.weth.address, setup.wbtc.address] + ); + const totalSupply = await subjectSetToken.totalSupply(); + const currentWbtcAmount = await setup.wbtc.balanceOf(subjectSetToken.address); + + await subject(); + + const lastBlockTimestamp = await getLastBlockTimestamp(); + + const expectedWbtcPositionUnits = preciseDiv(currentWbtcAmount.add(expectedWbtcOut), totalSupply); + + const wethPositionUnits = await subjectSetToken.getDefaultPositionRealUnit(setup.weth.address); + const wbtcPositionUnits = await subjectSetToken.getDefaultPositionRealUnit(setup.wbtc.address); + const lastTrade = (await indexModule.executionInfo(subjectSetToken.address, subjectComponent)).lastTradeTimestamp; + + expect(wethPositionUnits).to.eq(ether(.434782609)); + expect(wbtcPositionUnits).to.eq(expectedWbtcPositionUnits); + expect(lastTrade).to.eq(lastBlockTimestamp); + }); + + describe("when weth is below target unit", async () => { + before(async () => { + oldTargetUnits = [ether(60.869565), bitcoin(.02), ether(50), ether(.8)]; + }); + + it("the trade reverts", async () => { + await expect(subject()).to.be.revertedWith("WETH is below target unit"); + }); + }); + }); + }); + + describe("#getRebalanceComponents", async () => { + before(async () => { + // current units [ether(86.9565217), bitcoin(.01111111), ether(100), ZERO] + newComponents = []; + oldTargetUnits = [ether("60.869565780223716593"), bitcoin(.02), ether(55)]; + newTargetUnits = []; + issueAmount = ether("20.000000000000000001"); + }); + + const startRebalance = async () => { + await setup.approveAndIssueSetToken(subjectSetToken, issueAmount); + await indexModule.startRebalance( + subjectSetToken.address, + newComponents, + newTargetUnits, + oldTargetUnits, + await index.positionMultiplier() + ); + }; + + const initializeSubjectVariables = () => { + subjectSetToken = index; + }; + + beforeEach(async () => { + initializeSubjectVariables(); + await startRebalance(); + }); + + async function subject(tokenAddress: Address): Promise { + return await indexModule.getRebalanceComponents(tokenAddress); + } + + it("the components being rebalanced should be returned", async () => { + const expectedComponents = [uniswapSetup.uni.address, setup.wbtc.address, setup.dai.address]; + + const rebalanceComponents = await subject(subjectSetToken.address); + + expect(rebalanceComponents).to.deep.eq(expectedComponents); + }); + + describe("when set token is not valid", async () => { + it("should revert", async () => { + await expect(subject(ADDRESS_ZERO)).to.be.revertedWith("Must be a valid and initialized SetToken"); + }); + }); + }); + + describe("#getComponentTradeQuantityAndDirection", async () => { + let subjectComponent: Address; + + let feePercentage: BigNumber; + + before(async () => { + // current units [ether(86.9565217), bitcoin(.01111111), ether(100), ZERO] + newComponents = [setup.usdc.address]; + oldTargetUnits = [ether("60.869565780223716593"), bitcoin(.02), ether(55)]; + newTargetUnits = [usdc(100)]; + issueAmount = ether("20.000000000000000001"); + }); + + const startRebalance = async () => { + await setup.approveAndIssueSetToken(subjectSetToken, issueAmount); + await indexModule.startRebalance( + subjectSetToken.address, + newComponents, + newTargetUnits, + oldTargetUnits, + await index.positionMultiplier() + ); + }; + + const initializeSubjectVariables = () => { + subjectSetToken = index; + subjectComponent = setup.dai.address; + }; + + beforeEach(async () => { + await indexModule.setTradeMaximums(index.address, [setup.usdc.address], [usdc(3000)]); + + initializeSubjectVariables(); + + await startRebalance(); + + feePercentage = ether(0.005); + setup.controller = setup.controller.connect(owner.wallet); + await setup.controller.addFee( + indexModule.address, + ZERO, // Fee type on trade function denoted as 0 + feePercentage // Set fee to 5 bps + ); + }); + + async function subject(): Promise { + return await indexModule.getComponentTradeQuantityAndDirection( + subjectSetToken.address, + subjectComponent + ); + } + + it("the position units and lastTradeTimestamp should be set as expected, sell using Balancer", async () => { + const totalSupply = await subjectSetToken.totalSupply(); + const currentDaiUnit = await subjectSetToken.getDefaultPositionRealUnit(setup.dai.address); + const expectedDaiSize = preciseMul(currentDaiUnit, totalSupply).sub(preciseMul(ether(55), totalSupply)); + + const [ + isSendTokenFixed, + componentQuantity, + ] = await subject(); + + expect(componentQuantity).to.eq(expectedDaiSize); + expect(isSendTokenFixed).to.be.true; + }); + + describe("when the component is being added to the Set", async () => { + beforeEach(async () => { + subjectComponent = setup.usdc.address; + }); + + it("the correct trade direction and size should be returned", async () => { + const totalSupply = await subjectSetToken.totalSupply(); + const currentUsdcUnit = await subjectSetToken.getDefaultPositionRealUnit(setup.usdc.address); + const expectedUsdcSize = preciseDiv( + preciseMulCeil(usdc(100), totalSupply).sub(preciseMul(currentUsdcUnit, totalSupply)), + PRECISE_UNIT.sub(feePercentage) + ); + + const [ + isSendTokenFixed, + componentQuantity, + ] = await subject(); + + expect(componentQuantity).to.eq(expectedUsdcSize); + expect(isSendTokenFixed).to.be.false; + }); + }); + + describe("and the buy component does not meet the max trade size", async () => { + beforeEach(async () => { + await indexModule.startRebalance( + subjectSetToken.address, + [], + [], + [ether("60.869565780223716593"), bitcoin(.016), ether(50)], + await index.positionMultiplier() + ); + + subjectComponent = setup.wbtc.address; + }); + + it("the correct trade direction and size should be returned", async () => { + const totalSupply = await subjectSetToken.totalSupply(); + const currentWbtcUnit = await subjectSetToken.getDefaultPositionRealUnit(setup.wbtc.address); + const expectedWbtcSize = preciseDiv( + preciseMulCeil(bitcoin(.016), totalSupply).sub(preciseMul(currentWbtcUnit, totalSupply)), + PRECISE_UNIT.sub(feePercentage) + ); + + const [ + isSendTokenFixed, + componentQuantity, + ] = await subject(); + + expect(componentQuantity).to.eq(expectedWbtcSize); + expect(isSendTokenFixed).to.be.false; + }); + }); + + describe("when the setToken is not valid", async () => { + beforeEach(() => { + subjectSetToken = { address: ADDRESS_ZERO } as SetToken; + }); + + it("should revert", async () => { + expect(subject()).to.be.revertedWith("Must be a valid and initialized SetToken"); + }); + }); + + describe("when the component is not part of the rebalance", async () => { + beforeEach(() => { + subjectComponent = setup.weth.address; + }); + + it("should revert", async () => { + expect(subject()).to.be.revertedWith("Component not recognized"); + }); + }); + }); + + describe("#getIsAllowedTrader", async () => { + let subjectTraders: Address[]; + let subjectStatuses: boolean[]; + + beforeEach(async () => { + subjectCaller = owner; + subjectSetToken = index; + subjectTraders = [trader.address]; + subjectStatuses = [true]; + + return await indexModule.connect(subjectCaller.wallet).setTraderStatus( + subjectSetToken.address, + subjectTraders, + subjectStatuses + ); + }); + + async function subject(): Promise { + return await indexModule.connect(subjectCaller.wallet).getIsAllowedTrader( + subjectSetToken.address, + subjectTraders[0], + ); + } + + it("returns trader status", async () => { + await subject(); + + const isTrader = await subject(); + expect(isTrader).to.be.true; + }); + + describe("when the setToken is not valid", async () => { + beforeEach(() => { + subjectSetToken = { address: ADDRESS_ZERO } as SetToken; + }); + + it("should revert", async () => { + expect(subject()).to.be.revertedWith("Must be a valid and initialized SetToken"); + }); + }); + }); + + describe("#getAllowedTraders", async () => { + let subjectTraders: Address[]; + let subjectStatuses: boolean[]; + + beforeEach(async () => { + subjectCaller = owner; + subjectSetToken = index; + subjectTraders = [trader.address]; + subjectStatuses = [true]; + + return await indexModule.connect(subjectCaller.wallet).setTraderStatus( + subjectSetToken.address, + subjectTraders, + subjectStatuses + ); + }); + + async function subject(): Promise { + return await indexModule.connect(subjectCaller.wallet).getAllowedTraders(subjectSetToken.address); + } + + it("returns trader status", async () => { + await subject(); + + const expectedTraders = await subject(); + expect(expectedTraders).to.deep.equal(subjectTraders); + }); + + describe("when the setToken is not valid", async () => { + beforeEach(() => { + subjectSetToken = { address: ADDRESS_ZERO } as SetToken; + }); + + it("should revert", async () => { + expect(subject()).to.be.revertedWith("Must be a valid and initialized SetToken"); + }); + }); + }); + + describe("#setRaiseTargetPercentage", async () => { + let subjectRaiseTargetPercentage: BigNumber; + + beforeEach(async () => { + subjectSetToken = index; + subjectCaller = owner; + subjectRaiseTargetPercentage = ether("0.02"); + }); + + async function subject(): Promise { + return await indexModule.connect(subjectCaller.wallet).setRaiseTargetPercentage( + subjectSetToken.address, + subjectRaiseTargetPercentage + ); + } + + it("sets raiseTargetPercentage", async () => { + await subject(); + const newRaiseTargetPercentage = (await indexModule.rebalanceInfo(subjectSetToken.address)).raiseTargetPercentage; + + expect(newRaiseTargetPercentage).to.eq(subjectRaiseTargetPercentage); + }); + + it("emits correct RaiseTargetPercentageUpdated event", async () => { + await expect(subject()).to.emit(indexModule, "RaiseTargetPercentageUpdated").withArgs( + subjectSetToken.address, + subjectRaiseTargetPercentage + ); + }); + + describe("when target percentage is 0", async () => { + beforeEach(async () => { + subjectRaiseTargetPercentage = ZERO; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Target percentage must be > 0"); + }); + }); + }); + + describe("#raiseAssetTargets", async () => { + let subjectRaiseTargetPercentage: BigNumber; + + before(async () => { + // current units [ether(86.9565217), bitcoin(.01111111), ether(100)] + oldTargetUnits = [ether(60.869565), bitcoin(.015), ether(50)]; + }); + + const startRebalance = async (trade: boolean = true, accrueFee: boolean = false) => { + await setup.approveAndIssueSetToken(subjectSetToken, ether(20)); + + if (accrueFee) { + await increaseTimeAsync(ONE_DAY_IN_SECONDS); + await setup.streamingFeeModule.accrueFee(subjectSetToken.address); + } + + await indexModule.startRebalance(subjectSetToken.address, [], [], oldTargetUnits, await subjectSetToken.positionMultiplier()); + + if (trade) { + await increaseTimeAsync(ONE_MINUTE_IN_SECONDS.mul(5)); + await indexModule.connect(trader.wallet).trade(subjectSetToken.address, setup.dai.address, ZERO); + await indexModule.connect(trader.wallet).trade(subjectSetToken.address, uniswapSetup.uni.address, ZERO); + await indexModule.connect(trader.wallet).trade(subjectSetToken.address, setup.wbtc.address, MAX_UINT_256); + } + + await indexModule.setRaiseTargetPercentage(subjectSetToken.address, subjectRaiseTargetPercentage); + }; + + async function subject(): Promise { + return await indexModule.connect(subjectCaller.wallet).raiseAssetTargets(subjectSetToken.address); + } + + const initialializeSubjectVariables = () => { + subjectSetToken = index; + subjectCaller = trader; + }; + + describe("with default target units", () => { + beforeEach(async () => { + initialializeSubjectVariables(); + subjectRaiseTargetPercentage = ether(.0025); + await startRebalance(); + }); + + it("the position units and lastTradeTimestamp should be set as expected", async () => { + const prePositionMultiplier = (await indexModule.rebalanceInfo(subjectSetToken.address)).positionMultiplier; + + await subject(); + + const expectedPositionMultiplier = preciseDiv( + prePositionMultiplier, + PRECISE_UNIT.add(ether(.0025)) + ); + + const positionMultiplier = (await indexModule.rebalanceInfo(subjectSetToken.address)).positionMultiplier; + + expect(positionMultiplier).to.eq(expectedPositionMultiplier); + }); + + it("emits correct AssetTargetsRaised event", async () => { + const prePositionMultiplier = (await indexModule.rebalanceInfo(subjectSetToken.address)).positionMultiplier; + + const expectedPositionMultiplier = preciseDiv( + prePositionMultiplier, + PRECISE_UNIT.add(subjectRaiseTargetPercentage) + ); + + await expect(subject()).to.emit(indexModule, "AssetTargetsRaised").withArgs( + subjectSetToken.address, + expectedPositionMultiplier + ); + }); + + describe("when the calling address is not a permissioned address", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Address not permitted to trade"); + }); + }); + }); + + describe("when the raiseTargetPercentage is the lowest valid decimal (1e-6)", () => { + beforeEach(async () => { + initialializeSubjectVariables(); + subjectRaiseTargetPercentage = ether(.000001); + await startRebalance(); + }); + + afterEach(() => { + subjectRaiseTargetPercentage = ether(.0025); + }); + + it("the position multiplier should be set as expected", async () => { + const prePositionMultiplier = (await indexModule.rebalanceInfo(subjectSetToken.address)).positionMultiplier; + + await subject(); + + const expectedPositionMultiplier = preciseDiv( + prePositionMultiplier, + PRECISE_UNIT.add(subjectRaiseTargetPercentage) + ); + + const positionMultiplier = (await indexModule.rebalanceInfo(subjectSetToken.address)).positionMultiplier; + + expect(positionMultiplier).to.eq(expectedPositionMultiplier); + }); + }); + + describe("when the raiseTargetPercentage is MAX_UINT_256", () => { + beforeEach(async () => { + initialializeSubjectVariables(); + subjectRaiseTargetPercentage = MAX_UINT_256; + await startRebalance(); + }); + + afterEach(() => { + subjectRaiseTargetPercentage = ether(.0025); + }); + + it("it should revert", async () => { + await expect(subject()).to.be.revertedWith("addition overflow"); + }); + }); + + describe("when protocol fees are charged", () => { + beforeEach(async () => { + const feePercentage = ether(0.005); + setup.controller = setup.controller.connect(owner.wallet); + await setup.controller.addFee( + indexModule.address, + ZERO, // Fee type on trade function denoted as 0 + feePercentage // Set fee to 5 bps + ); + + initialializeSubjectVariables(); + await startRebalance(true, true); + }); + + it("the position units and lastTradeTimestamp should be set as expected", async () => { + const prePositionMultiplier = (await indexModule.rebalanceInfo(subjectSetToken.address)).positionMultiplier; + + await subject(); + + const expectedPositionMultiplier = preciseDiv( + prePositionMultiplier, + PRECISE_UNIT.add(ether(.0025)) + ); + + const positionMultiplier = (await indexModule.rebalanceInfo(subjectSetToken.address)).positionMultiplier; + + expect(positionMultiplier).to.eq(expectedPositionMultiplier); + }); + }); + + describe("when a component is being removed", async () => { + beforeEach(async () => { + // current Units [ether(86.9565217), bitcoin(.01111111), ether(100)] + oldTargetUnits = [ether(60.869565), bitcoin(.015), ZERO]; + + initialializeSubjectVariables(); + + await indexModule.setTradeMaximums(subjectSetToken.address, [setup.dai.address], [ether(2000)]); + await startRebalance(); + }); + + it("the position units and lastTradeTimestamp should be set as expected and the unit should be zeroed out", async () => { + const prePositionMultiplier = (await indexModule.rebalanceInfo(subjectSetToken.address)).positionMultiplier; + + await subject(); + + const expectedPositionMultiplier = preciseDiv( + prePositionMultiplier, + PRECISE_UNIT.add(ether(.0025)) + ); + + const positionMultiplier = (await indexModule.rebalanceInfo(subjectSetToken.address)).positionMultiplier; + const daiUnits = await subjectSetToken.getDefaultPositionRealUnit(setup.dai.address); + + expect(positionMultiplier).to.eq(expectedPositionMultiplier); + expect(daiUnits).to.eq(ZERO); + }); + }); + + describe("with alternative target units", async () => { + describe("when the target has been met and no ETH remains", async () => { + beforeEach(async () => { + // current Units [ether(86.9565217), bitcoin(.01111111), ether(100)] + oldTargetUnits = [ether(60.869565), bitcoin(.02), ether(50)]; + + initialializeSubjectVariables(); + await startRebalance(); + + await increaseTimeAsync(ONE_MINUTE_IN_SECONDS.mul(5)); + await indexModule.connect(trader.wallet).tradeRemainingWETH(subjectSetToken.address, setup.wbtc.address, ZERO); + }); + + it("the trade reverts", async () => { + await expect(subject()).to.be.revertedWith("Targets not met or ETH =~ 0"); + }); + }); + + describe("when set has weth as a component", async () => { + describe("when the target has been met and ETH is below target unit", async () => { + beforeEach(async () => { + // current Units [ether(86.9565217), bitcoin(.01111111), ether(100), ether(0.434782609)] + oldTargetUnits = [ether(86.9565217), bitcoin(.01111111), ether(100), ether(0.5)]; + + subjectSetToken = indexWithWeth; + subjectCaller = trader; + + await startRebalance(false); + }); + + it("the trade reverts", async () => { + await expect(subject()).to.be.revertedWith("Targets not met or ETH =~ 0"); + }); + }); + }); + }); + }); + + describe("#setTraderStatus", async () => { + let subjectTraders: Address[]; + let subjectStatuses: boolean[]; + + beforeEach(async () => { + subjectCaller = owner; + subjectSetToken = index; + subjectTraders = [trader.address, await getRandomAddress(), await getRandomAddress()]; + subjectStatuses = [true, true, true]; + }); + + async function subject(): Promise { + return await indexModule.connect(subjectCaller.wallet).setTraderStatus( + subjectSetToken.address, + subjectTraders, + subjectStatuses + ); + } + + it("the trader status should be flipped to true", async () => { + await subject(); + + const isTraderOne = await indexModule.getIsAllowedTrader(subjectSetToken.address, subjectTraders[0]); + const isTraderTwo = await indexModule.getIsAllowedTrader(subjectSetToken.address, subjectTraders[1]); + const isTraderThree = await indexModule.getIsAllowedTrader(subjectSetToken.address, subjectTraders[2]); + + expect(isTraderOne).to.be.true; + expect(isTraderTwo).to.be.true; + expect(isTraderThree).to.be.true; + }); + + it("should emit TraderStatusUpdated event", async () => { + await expect(subject()).to.emit(indexModule, "TraderStatusUpdated").withArgs( + subjectSetToken.address, + subjectTraders[0], + true + ); + }); + + describe("when de-authorizing a trader", async () => { + beforeEach(async () => { + await subject(); + subjectStatuses = [false, true, true]; + }); + + it("the trader status should be flipped to false", async () => { + const preConditionTrader = await indexModule.getIsAllowedTrader(subjectSetToken.address, subjectTraders[0]); + expect(preConditionTrader).to.be.true; + + await subject(); + + const postConditionTrader = await indexModule.getIsAllowedTrader(subjectSetToken.address, subjectTraders[0]); + expect(postConditionTrader).to.be.false; + }); + + it("the tradersHistory should be updated correctly", async () => { + const preConditionTraders = await indexModule.getAllowedTraders(subjectSetToken.address); + expect(preConditionTraders).to.deep.equal(subjectTraders); + + await subject(); + + const postConditionTraders = await indexModule.getAllowedTraders(subjectSetToken.address); + const expectedTraders = subjectTraders.slice(1); + + expect(expectedTraders[0]).to.not.equal(expectedTraders[1]); + expect(postConditionTraders[0]).to.not.equal(postConditionTraders[1]); + + expect(postConditionTraders.includes(expectedTraders[0])).to.be.true; + expect(postConditionTraders.includes(expectedTraders[1])).to.be.true; + }); + }); + + describe("when array lengths don't match", async () => { + beforeEach(async () => { + subjectTraders = [trader.address, await getRandomAddress()]; + subjectStatuses = [false]; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Array length mismatch"); + }); + }); + + describe("when traders are duplicated", async () => { + beforeEach(async () => { + subjectTraders = [trader.address, trader.address, await getRandomAddress()]; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Cannot duplicate addresses"); + }); + }); + + describe("when arrays are empty", async () => { + beforeEach(async () => { + subjectTraders = []; + subjectStatuses = []; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Array length must be > 0"); + }); + }); + + describe("when the caller is not the manager", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be the SetToken manager"); + }); + }); + + describe("when the SetToken has not initialized the module", async () => { + beforeEach(async () => { + await setup.controller.removeSet(index.address); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be a valid and initialized SetToken"); + }); + }); + }); + + describe("#removeModule", async () => { + let subjectStatuses: boolean[]; + let subjectTraders: Address[]; + + beforeEach(async () => { + subjectSetToken = index; + subjectCaller = owner; + subjectTraders = [trader.address, await getRandomAddress()]; + subjectStatuses = [true, false]; + }); + + afterEach(restoreModule); + + async function restoreModule() { + const isModuleEnabled = await subjectSetToken.isInitializedModule(indexModule.address); + + if (!isModuleEnabled) { + await subjectSetToken.connect(subjectCaller.wallet).addModule(indexModule.address); + await indexModule.connect(subjectCaller.wallet).initialize(subjectSetToken.address, owner.address); + } + } + + describe("removal", async () => { + async function subject(andRestore?: boolean): Promise { + return subjectSetToken.connect(subjectCaller.wallet).removeModule(indexModule.address); + } + + it("should remove the module", async () => { + await subject(); + const isModuleEnabled = await subjectSetToken.isInitializedModule(indexModule.address); + expect(isModuleEnabled).to.eq(false); + }); + + it("should delete the manager recipient state", async () => { + await subject(); + const managerRebateRecipient = await indexModule.managerRebateRecipient(subjectSetToken.address); + expect(managerRebateRecipient).to.eq(ADDRESS_ZERO); + }); + }); + + describe("when restoring module after removal and using permissionInfo", async () => { + beforeEach(async () => { + await indexModule.connect(subjectCaller.wallet).setTraderStatus( + subjectSetToken.address, + subjectTraders, + subjectStatuses + ); + + await indexModule.connect(subjectCaller.wallet).setAnyoneTrade( + subjectSetToken.address, + true + ); + }); + + async function subject(andRestore?: boolean): Promise { + await subjectSetToken.connect(subjectCaller.wallet).removeModule(indexModule.address); + await restoreModule(); + } + + it("should have removed traders from the permissions whitelist", async () => { + let isTraderOne = await indexModule.getIsAllowedTrader(subjectSetToken.address, subjectTraders[0]); + expect(isTraderOne).to.be.true; + + await subject(); + + isTraderOne = await indexModule.getIsAllowedTrader(subjectSetToken.address, subjectTraders[0]); + expect(isTraderOne).to.be.false; + }); + + it("should have set anyoneTrade to false", async () => { + // The public getter return sig generated for permissionInfo's abi + // is anyoneTrade (and nothing else). + let anyoneTrade = await indexModule.permissionInfo(subjectSetToken.address); + expect(anyoneTrade).to.be.true; + + await subject(); + + anyoneTrade = await indexModule.permissionInfo(subjectSetToken.address); + expect(anyoneTrade).to.be.false; + }); + }); + + describe("when restoring module after removal and using rebalanceInfo", async () => { + let subjectNewComponents; + let subjectNewTargetUnits; + let subjectOldTargetUnits; + let subjectPositionMultiplier; + + beforeEach(async () => { + subjectNewComponents = [sushiswapSetup.uni.address]; + subjectNewTargetUnits = [ether(50)]; + subjectOldTargetUnits = [ether("60.869565780223716593"), bitcoin(.02), ether(50)]; + subjectPositionMultiplier = MAX_UINT_256; + + await indexModule.startRebalance( + subjectSetToken.address, + subjectNewComponents, + subjectNewTargetUnits, + subjectOldTargetUnits, + subjectPositionMultiplier + ); + + await indexModule.setRaiseTargetPercentage(subjectSetToken.address, MAX_UINT_256); + }); + + async function subject(andRestore?: boolean): Promise { + await subjectSetToken.connect(subjectCaller.wallet).removeModule(indexModule.address); + await restoreModule(); + } + + it("should have cleared the rebalance components array", async () => { + const preRemoveComponents = await indexModule.getRebalanceComponents(subjectSetToken.address); + + await subject(); + + const postRemoveComponents = await indexModule.getRebalanceComponents(subjectSetToken.address); + + expect(preRemoveComponents.length).to.equal(4); + expect(postRemoveComponents.length).to.equal(ZERO); + }); + + it("should have reset the positionMultiplier to PRECISE_UNIT", async () => { + const preRemoveMultiplier = (await indexModule.rebalanceInfo(subjectSetToken.address)).positionMultiplier; + + await subject(); + + const postRemoveMultiplier = (await indexModule.rebalanceInfo(subjectSetToken.address)).positionMultiplier; + expect(preRemoveMultiplier).to.equal(MAX_UINT_256); + expect(postRemoveMultiplier).to.equal(PRECISE_UNIT); + }); + + it("should have zeroed out the raiseTargetPercentage", async () => { + const preRemoveMultiplier = (await indexModule.rebalanceInfo(subjectSetToken.address)).raiseTargetPercentage; + + await subject(); + + const postRemoveMultiplier = (await indexModule.rebalanceInfo(subjectSetToken.address)).raiseTargetPercentage; + expect(preRemoveMultiplier).to.equal(MAX_UINT_256); + expect(postRemoveMultiplier).to.equal(ZERO); + }); + }); + }); + }); +}); diff --git a/test/protocol/modules/tradeModuleV2.spec.ts b/test/protocol/modules/tradeModuleV2.spec.ts new file mode 100644 index 000000000..1da774b1f --- /dev/null +++ b/test/protocol/modules/tradeModuleV2.spec.ts @@ -0,0 +1,2007 @@ +import "module-alias/register"; +import Web3 from "web3"; +import { BigNumber } from "ethers"; +import { utils } from "ethers"; +import { ethers } from "hardhat"; + +import { Address, Bytes } from "@utils/types"; +import { Account } from "@utils/test/types"; +import { + KyberExchangeAdapter, + KyberNetworkProxyMock, + ManagerIssuanceHookMock, + OneInchExchangeAdapter, + OneInchExchangeMock, + SetToken, + StandardTokenMock, + StandardTokenWithFeeMock, + TradeModuleV2, + UniswapV2ExchangeAdapter, + UniswapV2TransferFeeExchangeAdapter, + UniswapV2ExchangeAdapterV2, + WETH9, + ZeroExApiAdapter, + ZeroExMock, + UniswapV3ExchangeAdapter, +} from "@utils/contracts"; +import { ADDRESS_ZERO, EMPTY_BYTES, MAX_UINT_256, ZERO, ONE } from "@utils/constants"; +import DeployHelper from "@utils/deploys"; +import { + ether, + bitcoin, + preciseMul +} from "@utils/index"; +import { + cacheBeforeEach, + getAccounts, + getRandomAccount, + getRandomAddress, + getSystemFixture, + getUniswapFixture, + getUniswapV3Fixture, + getWaffleExpect, +} from "@utils/test/index"; + +import { SystemFixture, UniswapFixture, UniswapV3Fixture } from "@utils/fixtures"; + +const web3 = new Web3(); +const expect = getWaffleExpect(); + +describe("TradeModuleV2", () => { + let owner: Account; + let manager: Account; + let mockModule: Account; + + let deployer: DeployHelper; + + let kyberNetworkProxy: KyberNetworkProxyMock; + let kyberExchangeAdapter: KyberExchangeAdapter; + let kyberAdapterName: string; + + let oneInchExchangeMock: OneInchExchangeMock; + let oneInchExchangeAdapter: OneInchExchangeAdapter; + let oneInchAdapterName: string; + + let uniswapExchangeAdapter: UniswapV2ExchangeAdapter; + let uniswapAdapterName: string; + let uniswapTransferFeeExchangeAdapter: UniswapV2TransferFeeExchangeAdapter; + let uniswapTransferFeeAdapterName: string; + let uniswapExchangeAdapterV2: UniswapV2ExchangeAdapterV2; + let uniswapAdapterV2Name: string; + let uniswapV3ExchangeAdapter: UniswapV3ExchangeAdapter; + let uniswapV3AdapterName: string; + + let zeroExMock: ZeroExMock; + let zeroExApiAdapter: ZeroExApiAdapter; + let zeroExApiAdapterName: string; + + let wbtcRate: BigNumber; + let setup: SystemFixture; + let uniswapSetup: UniswapFixture; + let uniswapV3Setup: UniswapV3Fixture; + let tradeModuleV2: TradeModuleV2; + + cacheBeforeEach(async () => { + [ + owner, + manager, + mockModule, + ] = await getAccounts(); + + deployer = new DeployHelper(owner.wallet); + setup = getSystemFixture(owner.address); + await setup.initialize(); + + wbtcRate = ether(33); // 1 WBTC = 33 ETH + + // Mock Kyber reserve only allows trading from/to WETH + kyberNetworkProxy = await deployer.mocks.deployKyberNetworkProxyMock(setup.weth.address); + await kyberNetworkProxy.addToken( + setup.wbtc.address, + wbtcRate, + 8 + ); + kyberExchangeAdapter = await deployer.adapters.deployKyberExchangeAdapter(kyberNetworkProxy.address); + + // Mock OneInch exchange that allows for only fixed exchange amounts + oneInchExchangeMock = await deployer.mocks.deployOneInchExchangeMock( + setup.wbtc.address, + setup.weth.address, + BigNumber.from(100000000), // 1 WBTC + wbtcRate, // Trades for 33 WETH + ); + + // 1inch function signature + const oneInchFunctionSignature = web3.eth.abi.encodeFunctionSignature( + "swap(address,address,uint256,uint256,uint256,address,address[],bytes,uint256[],uint256[])" + ); + oneInchExchangeAdapter = await deployer.adapters.deployOneInchExchangeAdapter( + oneInchExchangeMock.address, + oneInchExchangeMock.address, + oneInchFunctionSignature + ); + + uniswapSetup = getUniswapFixture(owner.address); + await uniswapSetup.initialize( + owner, + setup.weth.address, + setup.wbtc.address, + setup.dai.address + ); + + uniswapV3Setup = getUniswapV3Fixture(owner.address); + await uniswapV3Setup.initialize( + owner, + setup.weth, + 2500, + setup.wbtc, + 35000, + setup.dai + ); + + uniswapExchangeAdapter = await deployer.adapters.deployUniswapV2ExchangeAdapter(uniswapSetup.router.address); + uniswapTransferFeeExchangeAdapter = await deployer.adapters.deployUniswapV2TransferFeeExchangeAdapter(uniswapSetup.router.address); + uniswapExchangeAdapterV2 = await deployer.adapters.deployUniswapV2ExchangeAdapterV2(uniswapSetup.router.address); + uniswapV3ExchangeAdapter = await deployer.adapters.deployUniswapV3ExchangeAdapter(uniswapV3Setup.swapRouter.address); + + zeroExMock = await deployer.mocks.deployZeroExMock( + setup.wbtc.address, + setup.weth.address, + BigNumber.from(100000000), // 1 WBTC + wbtcRate, // Trades for 33 WETH + ); + zeroExApiAdapter = await deployer.adapters.deployZeroExApiAdapter(zeroExMock.address, setup.weth.address); + + + kyberAdapterName = "KYBER"; + oneInchAdapterName = "ONEINCH"; + uniswapAdapterName = "UNISWAP"; + uniswapTransferFeeAdapterName = "UNISWAP_TRANSFER_FEE"; + uniswapAdapterV2Name = "UNISWAPV2"; + zeroExApiAdapterName = "ZERO_EX"; + uniswapV3AdapterName = "UNISWAPV3"; + + tradeModuleV2 = await deployer.modules.deployTradeModuleV2(setup.controller.address); + await setup.controller.addModule(tradeModuleV2.address); + + await setup.integrationRegistry.batchAddIntegration( + [ + tradeModuleV2.address, + tradeModuleV2.address, + tradeModuleV2.address, + tradeModuleV2.address, + tradeModuleV2.address, + tradeModuleV2.address, + tradeModuleV2.address, + ], + [ + kyberAdapterName, + oneInchAdapterName, + uniswapAdapterName, + uniswapTransferFeeAdapterName, + uniswapAdapterV2Name, + zeroExApiAdapterName, + uniswapV3AdapterName, + ], + [ + kyberExchangeAdapter.address, + oneInchExchangeAdapter.address, + uniswapExchangeAdapter.address, + uniswapTransferFeeExchangeAdapter.address, + uniswapExchangeAdapterV2.address, + zeroExApiAdapter.address, + uniswapV3ExchangeAdapter.address, + ] + ); + }); + + describe("#constructor", async () => { + let subjectTradeModuleV2: TradeModuleV2; + + async function subject(): Promise { + return deployer.modules.deployTradeModuleV2(setup.controller.address); + } + + it("should have the correct controller", async () => { + subjectTradeModuleV2 = await subject(); + const expectedController = await subjectTradeModuleV2.controller(); + expect(expectedController).to.eq(setup.controller.address); + }); + }); + + context("when there is a deployed SetToken with enabled TradeModuleV2", async () => { + let sourceToken: StandardTokenMock; + let wbtcUnits: BigNumber; + let destinationToken: WETH9; + let setToken: SetToken; + let issueQuantity: BigNumber; + let mockPreIssuanceHook: ManagerIssuanceHookMock; + + cacheBeforeEach(async () => { + // Selling WBTC + sourceToken = setup.wbtc; + destinationToken = setup.weth; + wbtcUnits = BigNumber.from(100000000); // 1 WBTC in base units 1 * 10 ** 8 + + // Create Set token + setToken = await setup.createSetToken( + [sourceToken.address], + [wbtcUnits], + [setup.issuanceModule.address, tradeModuleV2.address], + manager.address + ); + }); + + describe("#initialize", async () => { + let subjectSetToken: Address; + let subjectRebateRecipient: Address; + let subjectCaller: Account; + + beforeEach(async () => { + subjectSetToken = setToken.address; + subjectRebateRecipient = manager.address; + subjectCaller = manager; + }); + + async function subject(): Promise { + tradeModuleV2 = tradeModuleV2.connect(subjectCaller.wallet); + return tradeModuleV2.initialize(subjectSetToken, subjectRebateRecipient); + } + + it("should set the manager rebate recipient", async () => { + await subject(); + const managerRebateRecipient = await tradeModuleV2.managerRebateRecipient(setToken.address); + expect(managerRebateRecipient).to.eq(manager.address); + }); + + it("should enable the Module on the SetToken", async () => { + await subject(); + const isModuleEnabled = await setToken.isInitializedModule(tradeModuleV2.address); + expect(isModuleEnabled).to.eq(true); + }); + + describe("when the caller is not the SetToken manager", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be the SetToken manager"); + }); + }); + + describe("when the manager rebate recipient is the zero address", async () => { + beforeEach(async () => { + subjectRebateRecipient = ADDRESS_ZERO; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Recipient must be non-zero address."); + }); + }); + + describe("when the module is not pending", async () => { + beforeEach(async () => { + await subject(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be pending initialization"); + }); + }); + + describe("when the SetToken is not enabled on the controller", async () => { + beforeEach(async () => { + const nonEnabledSetToken = await setup.createNonControllerEnabledSetToken( + [setup.dai.address], + [ether(1)], + [tradeModuleV2.address], + manager.address + ); + + subjectSetToken = nonEnabledSetToken.address; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be controller-enabled SetToken"); + }); + }); + }); + + describe("#trade", async () => { + let tokenWithFee: StandardTokenWithFeeMock; + let sourceTokenQuantity: BigNumber; + let destinationTokenQuantity: BigNumber; + let isInitialized: boolean; + + let subjectDestinationToken: Address; + let subjectSourceToken: Address; + let subjectSourceQuantity: BigNumber; + let subjectAdapterName: string; + let subjectSetToken: Address; + let subjectMinDestinationQuantity: BigNumber; + let subjectData: Bytes; + let subjectCaller: Account; + + context("when trading a Default component on Kyber", async () => { + before(async () => { + isInitialized = true; + }); + + const initializeContracts = async () => { + // Fund Kyber reserve with destinationToken WETH + destinationToken = destinationToken.connect(owner.wallet); + await destinationToken.transfer(kyberNetworkProxy.address, ether(1000)); + + // Initialize module if set to true + if (isInitialized) { + tradeModuleV2 = tradeModuleV2.connect(manager.wallet); + await tradeModuleV2.initialize(setToken.address, manager.address); + } + + sourceTokenQuantity = wbtcUnits.div(2); // Trade 0.5 WBTC + const sourceTokenDecimals = await sourceToken.decimals(); + destinationTokenQuantity = wbtcRate.mul(sourceTokenQuantity).div(10 ** sourceTokenDecimals); + + // Transfer sourceToken from owner to manager for issuance + sourceToken = sourceToken.connect(owner.wallet); + await sourceToken.transfer(manager.address, wbtcUnits.mul(100)); + + // Approve tokens to Controller and call issue + sourceToken = sourceToken.connect(manager.wallet); + await sourceToken.approve(setup.issuanceModule.address, ethers.constants.MaxUint256); + // Deploy mock issuance hook and initialize issuance module + setup.issuanceModule = setup.issuanceModule.connect(manager.wallet); + mockPreIssuanceHook = await deployer.mocks.deployManagerIssuanceHookMock(); + await setup.issuanceModule.initialize(setToken.address, mockPreIssuanceHook.address); + + // Issue 10 SetTokens + issueQuantity = ether(10); + await setup.issuanceModule.issue(setToken.address, issueQuantity, owner.address); + }; + + const initializeSubjectVariables = () => { + subjectSourceToken = sourceToken.address; + subjectDestinationToken = destinationToken.address; + subjectSourceQuantity = sourceTokenQuantity; + subjectSetToken = setToken.address; + subjectAdapterName = kyberAdapterName; + subjectData = EMPTY_BYTES; + subjectMinDestinationQuantity = destinationTokenQuantity.sub(ether(0.5)); // Receive a min of 16 WETH for 0.5 WBTC + subjectCaller = manager; + }; + + async function subject(): Promise { + tradeModuleV2 = tradeModuleV2.connect(subjectCaller.wallet); + return tradeModuleV2.trade( + subjectSetToken, + subjectAdapterName, + subjectSourceToken, + subjectSourceQuantity, + subjectDestinationToken, + subjectMinDestinationQuantity, + subjectData + ); + } + + describe("when the module is initialized", () => { + cacheBeforeEach(initializeContracts); + beforeEach(initializeSubjectVariables); + + it("should transfer the correct components to the SetToken", async () => { + const oldDestinationTokenBalance = await destinationToken.balanceOf(setToken.address); + + await subject(); + const totalDestinationQuantity = issueQuantity.mul(destinationTokenQuantity).div(ether(1)); + const expectedDestinationTokenBalance = oldDestinationTokenBalance.add(totalDestinationQuantity); + const newDestinationTokenBalance = await destinationToken.balanceOf(setToken.address); + expect(newDestinationTokenBalance).to.eq(expectedDestinationTokenBalance); + }); + + it("should transfer the correct components from the SetToken", async () => { + const oldSourceTokenBalance = await sourceToken.balanceOf(setToken.address); + + await subject(); + const totalSourceQuantity = issueQuantity.mul(sourceTokenQuantity).div(ether(1)); + const expectedSourceTokenBalance = oldSourceTokenBalance.sub(totalSourceQuantity); + const newSourceTokenBalance = await sourceToken.balanceOf(setToken.address); + expect(newSourceTokenBalance).to.eq(expectedSourceTokenBalance); + }); + + it("should transfer the correct components to the exchange", async () => { + const oldSourceTokenBalance = await sourceToken.balanceOf(kyberNetworkProxy.address); + + await subject(); + const totalSourceQuantity = issueQuantity.mul(sourceTokenQuantity).div(ether(1)); + const expectedSourceTokenBalance = oldSourceTokenBalance.add(totalSourceQuantity); + const newSourceTokenBalance = await sourceToken.balanceOf(kyberNetworkProxy.address); + expect(newSourceTokenBalance).to.eq(expectedSourceTokenBalance); + }); + + it("should transfer the correct components from the exchange", async () => { + const oldDestinationTokenBalance = await destinationToken.balanceOf(kyberNetworkProxy.address); + + await subject(); + const totalDestinationQuantity = issueQuantity.mul(destinationTokenQuantity).div(ether(1)); + const expectedDestinationTokenBalance = oldDestinationTokenBalance.sub(totalDestinationQuantity); + const newDestinationTokenBalance = await destinationToken.balanceOf(kyberNetworkProxy.address); + expect(newDestinationTokenBalance).to.eq(expectedDestinationTokenBalance); + }); + + it("should update the positions on the SetToken correctly", async () => { + const initialPositions = await setToken.getPositions(); + const initialFirstPosition = (await setToken.getPositions())[0]; + + await subject(); + + const currentPositions = await setToken.getPositions(); + const newFirstPosition = (await setToken.getPositions())[0]; + const newSecondPosition = (await setToken.getPositions())[1]; + + expect(initialPositions.length).to.eq(1); + expect(currentPositions.length).to.eq(2); + expect(newFirstPosition.component).to.eq(sourceToken.address); + expect(newFirstPosition.unit).to.eq(initialFirstPosition.unit.sub(sourceTokenQuantity)); + expect(newFirstPosition.module).to.eq(ADDRESS_ZERO); + expect(newSecondPosition.component).to.eq(destinationToken.address); + expect(newSecondPosition.unit).to.eq(destinationTokenQuantity); + expect(newSecondPosition.module).to.eq(ADDRESS_ZERO); + }); + + describe("when there is a protocol fee charged", async () => { + let feePercentage: BigNumber; + let managerRebatePercentage: BigNumber; + + beforeEach(async () => { + feePercentage = ether(0.001); + managerRebatePercentage = ether(0.4); + setup.controller = setup.controller.connect(owner.wallet); + await setup.controller.addFee( + tradeModuleV2.address, + ZERO, // Fee type on trade function denoted as 0 + feePercentage // Set fee to 10 bps + ); + + await setup.controller.addFee( + tradeModuleV2.address, + ONE, // Fee type on trade function denoted as 0 + managerRebatePercentage // Set rebate to 40% + ); + }); + + it("should transfer the correct components minus fee to the SetToken", async () => { + const oldDestinationTokenBalance = await destinationToken.balanceOf(setToken.address); + + await subject(); + const totalDestinationQuantity = issueQuantity.mul(destinationTokenQuantity).div(ether(1)); + const totalFee = feePercentage.mul(totalDestinationQuantity).div(ether(1)); + const expectedDestinationTokenBalance = oldDestinationTokenBalance + .add(totalDestinationQuantity) + .sub(totalFee); + + const newDestinationTokenBalance = await destinationToken.balanceOf(setToken.address); + expect(newDestinationTokenBalance).to.eq(expectedDestinationTokenBalance); + }); + + it("should transfer the fees to the correct recipients", async () => { + const protocolFeeRecipient = await setup.controller.feeRecipient(); + const preProtocolFeeRecipientBalance = await destinationToken.balanceOf(protocolFeeRecipient); + const preManagerRebateRecipientBalance = await destinationToken.balanceOf(manager.address); + + await subject(); + + const totalDestinationQuantity = issueQuantity.mul(destinationTokenQuantity).div(ether(1)); + const totalFee = feePercentage.mul(totalDestinationQuantity).div(ether(1)); + const managerRebate = preciseMul(totalFee, managerRebatePercentage); + const protocolFee = totalFee.sub(managerRebate); + + const postProtocolFeeRecipientBalance = await destinationToken.balanceOf(protocolFeeRecipient); + const postManagerRebateRecipientBalance = await destinationToken.balanceOf(manager.address); + + expect(postProtocolFeeRecipientBalance.sub(preProtocolFeeRecipientBalance)).to.eq(protocolFee); + expect(postManagerRebateRecipientBalance.sub(preManagerRebateRecipientBalance)).to.eq(managerRebate); + }); + + it("should transfer the correct components from the SetToken to the exchange", async () => { + const oldSourceTokenBalance = await sourceToken.balanceOf(setToken.address); + await subject(); + const totalSourceQuantity = issueQuantity.mul(sourceTokenQuantity).div(ether(1)); + const expectedSourceTokenBalance = oldSourceTokenBalance.sub(totalSourceQuantity); + const newSourceTokenBalance = await sourceToken.balanceOf(setToken.address); + expect(newSourceTokenBalance).to.eq(expectedSourceTokenBalance); + }); + + it("should update the positions on the SetToken correctly", async () => { + const initialPositions = await setToken.getPositions(); + const initialFirstPosition = (await setToken.getPositions())[0]; + + await subject(); + + const currentPositions = await setToken.getPositions(); + const newFirstPosition = (await setToken.getPositions())[0]; + const newSecondPosition = (await setToken.getPositions())[1]; + + const unitProtocolFee = feePercentage.mul(destinationTokenQuantity).div(ether(1)); + expect(initialPositions.length).to.eq(1); + expect(currentPositions.length).to.eq(2); + expect(newFirstPosition.component).to.eq(sourceToken.address); + expect(newFirstPosition.unit).to.eq(initialFirstPosition.unit.sub(sourceTokenQuantity)); + expect(newFirstPosition.module).to.eq(ADDRESS_ZERO); + expect(newSecondPosition.component).to.eq(destinationToken.address); + expect(newSecondPosition.unit).to.eq(destinationTokenQuantity.sub(unitProtocolFee)); + expect(newSecondPosition.module).to.eq(ADDRESS_ZERO); + }); + + it("should emit the correct ComponentExchanged event", async () => { + const totalSourceQuantity = issueQuantity.mul(sourceTokenQuantity).div(ether(1)); + const totalDestinationQuantity = issueQuantity.mul(destinationTokenQuantity).div(ether(1)); + const totalFee = feePercentage.mul(totalDestinationQuantity).div(ether(1)); + const managerRebate = preciseMul(totalFee, managerRebatePercentage); + const protocolFee = totalFee.sub(managerRebate); + + await expect(subject()).to.emit(tradeModuleV2, "ComponentExchanged").withArgs( + setToken.address, + subjectSourceToken, + subjectDestinationToken, + kyberExchangeAdapter.address, + totalSourceQuantity, + totalDestinationQuantity.sub(totalFee), + protocolFee, + managerRebate + ); + }); + + describe("when receive token is more than total position units tracked on SetToken", async () => { + let extraTokenQuantity: BigNumber; + + beforeEach(async () => { + extraTokenQuantity = ether(1); + destinationToken = destinationToken.connect(owner.wallet); + // Transfer destination token to SetToken + await destinationToken.transfer(setToken.address, extraTokenQuantity); + }); + + it("should transfer the correct components minus fee to the SetToken", async () => { + const oldDestinationTokenBalance = await destinationToken.balanceOf(setToken.address); + + await subject(); + + const totalDestinationQuantity = issueQuantity.mul(destinationTokenQuantity).div(ether(1)); + const totalFee = feePercentage.mul(totalDestinationQuantity).div(ether(1)); + const expectedDestinationTokenBalance = oldDestinationTokenBalance + .add(totalDestinationQuantity) + .sub(totalFee); + + const newDestinationTokenBalance = await destinationToken.balanceOf(setToken.address); + expect(newDestinationTokenBalance).to.eq(expectedDestinationTokenBalance); + }); + + it("should transfer the fees to the correct recipients", async () => { + const protocolFeeRecipient = await setup.controller.feeRecipient(); + const preProtocolFeeRecipientBalance = await destinationToken.balanceOf(protocolFeeRecipient); + const preManagerRebateRecipientBalance = await destinationToken.balanceOf(manager.address); + + await subject(); + const totalDestinationQuantity = issueQuantity.mul(destinationTokenQuantity).div(ether(1)); + const totalFee = feePercentage.mul(totalDestinationQuantity).div(ether(1)); + const managerRebate = preciseMul(totalFee, managerRebatePercentage); + const protocolFee = totalFee.sub(managerRebate); + + const postProtocolFeeRecipientBalance = await destinationToken.balanceOf(protocolFeeRecipient); + const postManagerRebateRecipientBalance = await destinationToken.balanceOf(manager.address); + expect(postProtocolFeeRecipientBalance.sub(preProtocolFeeRecipientBalance)).to.eq(protocolFee); + expect(postManagerRebateRecipientBalance.sub(preManagerRebateRecipientBalance)).to.eq(managerRebate); + }); + + it("should update the positions on the SetToken correctly", async () => { + const initialPositions = await setToken.getPositions(); + const initialFirstPosition = (await setToken.getPositions())[0]; + + await subject(); + + const currentPositions = await setToken.getPositions(); + const newFirstPosition = (await setToken.getPositions())[0]; + const newSecondPosition = (await setToken.getPositions())[1]; + + const unitProtocolFee = feePercentage.mul(destinationTokenQuantity).div(ether(1)); + expect(initialPositions.length).to.eq(1); + expect(currentPositions.length).to.eq(2); + expect(newFirstPosition.component).to.eq(sourceToken.address); + expect(newFirstPosition.unit).to.eq(initialFirstPosition.unit.sub(sourceTokenQuantity)); + expect(newFirstPosition.module).to.eq(ADDRESS_ZERO); + expect(newSecondPosition.component).to.eq(destinationToken.address); + expect(newSecondPosition.unit).to.eq(destinationTokenQuantity.sub(unitProtocolFee)); + expect(newSecondPosition.module).to.eq(ADDRESS_ZERO); + }); + }); + + describe("when send token is more than total position units tracked on SetToken", async () => { + let extraTokenQuantity: BigNumber; + + beforeEach(async () => { + extraTokenQuantity = ether(1); + sourceToken = sourceToken.connect(owner.wallet); + // Transfer source token to SetToken + await sourceToken.transfer(setToken.address, extraTokenQuantity); + }); + + it("should transfer the correct components from the SetToken", async () => { + const oldSourceTokenBalance = await sourceToken.balanceOf(setToken.address); + + await subject(); + const totalSourceQuantity = issueQuantity.mul(sourceTokenQuantity).div(ether(1)); + const expectedSourceTokenBalance = oldSourceTokenBalance.sub(totalSourceQuantity); + + const newSourceTokenBalance = await sourceToken.balanceOf(setToken.address); + expect(newSourceTokenBalance).to.eq(expectedSourceTokenBalance); + }); + + it("should update the positions on the SetToken correctly", async () => { + const initialPositions = await setToken.getPositions(); + const initialFirstPosition = (await setToken.getPositions())[0]; + await subject(); + + const currentPositions = await setToken.getPositions(); + const newFirstPosition = (await setToken.getPositions())[0]; + const newSecondPosition = (await setToken.getPositions())[1]; + + const unitProtocolFee = feePercentage.mul(destinationTokenQuantity).div(ether(1)); + expect(initialPositions.length).to.eq(1); + expect(currentPositions.length).to.eq(2); + expect(newFirstPosition.component).to.eq(sourceToken.address); + expect(newFirstPosition.unit).to.eq(initialFirstPosition.unit.sub(sourceTokenQuantity)); + expect(newFirstPosition.module).to.eq(ADDRESS_ZERO); + expect(newSecondPosition.component).to.eq(destinationToken.address); + expect(newSecondPosition.unit).to.eq(destinationTokenQuantity.sub(unitProtocolFee)); + expect(newSecondPosition.module).to.eq(ADDRESS_ZERO); + }); + }); + }); + + describe("when SetToken is locked", async () => { + beforeEach(async () => { + // Add mock module to controller + setup.controller = setup.controller.connect(owner.wallet); + await setup.controller.addModule(mockModule.address); + + // Add new mock module to SetToken + setToken = setToken.connect(manager.wallet); + await setToken.addModule(mockModule.address); + + // Lock SetToken + setToken = setToken.connect(mockModule.wallet); + await setToken.initializeModule(); + await setToken.lock(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("When locked, only the locker can call"); + }); + }); + + describe("when the exchange is not valid", async () => { + beforeEach(async () => { + subjectAdapterName = "NOTVALIDEXCHANGE"; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be valid adapter"); + }); + }); + + describe("when quantity of token to sell is 0", async () => { + beforeEach(async () => { + subjectSourceQuantity = ZERO; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Token to sell must be nonzero"); + }); + }); + + describe("when quantity sold is more than total units available", async () => { + beforeEach(async () => { + // Set to 1 base unit more WBTC + subjectSourceQuantity = wbtcUnits.add(1); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Unit cant be greater than existing"); + }); + }); + + describe("when slippage is greater than allowed", async () => { + beforeEach(async () => { + // Set to 1 base unit above the exchange rate + subjectMinDestinationQuantity = wbtcRate.add(1); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Slippage greater than allowed"); + }); + }); + + describe("when the caller is not the SetToken manager", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be the SetToken manager"); + }); + }); + + describe("when SetToken is not valid", async () => { + beforeEach(async () => { + const nonEnabledSetToken = await setup.createNonControllerEnabledSetToken( + [setup.weth.address], + [ether(1)], + [tradeModuleV2.address], + manager.address + ); + + subjectSetToken = nonEnabledSetToken.address; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be a valid and initialized SetToken"); + }); + }); + }); + + describe("when module is not initialized", async () => { + beforeEach(async () => { + isInitialized = false; + await initializeContracts(); + initializeSubjectVariables(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be a valid and initialized SetToken"); + }); + }); + }); + + context("when trading a Default component on Uniswap", async () => { + cacheBeforeEach(async () => { + await setup.weth.connect(owner.wallet).approve(uniswapSetup.router.address, ether(3400)); + await setup.wbtc.connect(owner.wallet).approve(uniswapSetup.router.address, bitcoin(100)); + await uniswapSetup.router.addLiquidity( + setup.weth.address, + setup.wbtc.address, + ether(3400), + bitcoin(100), + ether(3395), + ether(99.5), + owner.address, + MAX_UINT_256 + ); + + tradeModuleV2 = tradeModuleV2.connect(manager.wallet); + await tradeModuleV2.initialize(setToken.address, manager.address); + + sourceTokenQuantity = wbtcUnits; + const sourceTokenDecimals = await sourceToken.decimals(); + destinationTokenQuantity = wbtcRate.mul(sourceTokenQuantity).div(10 ** sourceTokenDecimals); + + // Transfer sourceToken from owner to manager for issuance + sourceToken = sourceToken.connect(owner.wallet); + await sourceToken.transfer(manager.address, wbtcUnits.mul(100)); + + // Approve tokens to Controller and call issue + sourceToken = sourceToken.connect(manager.wallet); + await sourceToken.approve(setup.issuanceModule.address, ethers.constants.MaxUint256); + + // Deploy mock issuance hook and initialize issuance module + setup.issuanceModule = setup.issuanceModule.connect(manager.wallet); + mockPreIssuanceHook = await deployer.mocks.deployManagerIssuanceHookMock(); + await setup.issuanceModule.initialize(setToken.address, mockPreIssuanceHook.address); + + issueQuantity = ether(1); + await setup.issuanceModule.issue(setToken.address, issueQuantity, owner.address); + }); + + beforeEach(() => { + subjectSourceToken = sourceToken.address; + subjectDestinationToken = destinationToken.address; + subjectSourceQuantity = sourceTokenQuantity; + subjectSetToken = setToken.address; + subjectAdapterName = uniswapAdapterName; + subjectData = EMPTY_BYTES; + subjectMinDestinationQuantity = destinationTokenQuantity.sub(ether(1)); // Receive a min of 32 WETH for 1 WBTC + subjectCaller = manager; + }); + + async function subject(): Promise { + tradeModuleV2 = tradeModuleV2.connect(subjectCaller.wallet); + return tradeModuleV2.trade( + subjectSetToken, + subjectAdapterName, + subjectSourceToken, + subjectSourceQuantity, + subjectDestinationToken, + subjectMinDestinationQuantity, + subjectData + ); + } + + it("should transfer the correct components to the SetToken", async () => { + const oldDestinationTokenBalance = await destinationToken.balanceOf(setToken.address); + const [, expectedReceiveQuantity] = await uniswapSetup.router.getAmountsOut( + subjectSourceQuantity, + [subjectSourceToken, subjectDestinationToken] + ); + + await subject(); + + const expectedDestinationTokenBalance = oldDestinationTokenBalance.add(expectedReceiveQuantity); + const newDestinationTokenBalance = await destinationToken.balanceOf(setToken.address); + expect(newDestinationTokenBalance).to.eq(expectedDestinationTokenBalance); + }); + + it("should transfer the correct components from the SetToken", async () => { + const oldSourceTokenBalance = await sourceToken.balanceOf(setToken.address); + + await subject(); + + const totalSourceQuantity = issueQuantity.mul(sourceTokenQuantity).div(ether(1)); + const expectedSourceTokenBalance = oldSourceTokenBalance.sub(totalSourceQuantity); + const newSourceTokenBalance = await sourceToken.balanceOf(setToken.address); + expect(newSourceTokenBalance).to.eq(expectedSourceTokenBalance); + }); + + it("should update the positions on the SetToken correctly", async () => { + const initialPositions = await setToken.getPositions(); + const [, expectedReceiveQuantity] = await uniswapSetup.router.getAmountsOut( + subjectSourceQuantity, + [subjectSourceToken, subjectDestinationToken] + ); + + await subject(); + + // All WBTC is sold for WETH + const currentPositions = await setToken.getPositions(); + const newFirstPosition = (await setToken.getPositions())[0]; + + expect(initialPositions.length).to.eq(1); + expect(currentPositions.length).to.eq(1); + expect(newFirstPosition.component).to.eq(destinationToken.address); + expect(newFirstPosition.unit).to.eq(expectedReceiveQuantity); + expect(newFirstPosition.module).to.eq(ADDRESS_ZERO); + }); + + describe("when path is through multiple trading pairs", async () => { + beforeEach(async () => { + await setup.weth.connect(owner.wallet).approve(uniswapSetup.router.address, ether(1000)); + await setup.dai.connect(owner.wallet).approve(uniswapSetup.router.address, ether(1000000)); + await uniswapSetup.router.addLiquidity( + setup.weth.address, + setup.dai.address, + ether(1000), + ether(1000000), + ether(995), + ether(995000), + owner.address, + MAX_UINT_256 + ); + + subjectDestinationToken = setup.dai.address; + const tradePath = [subjectSourceToken, setup.weth.address, subjectDestinationToken]; + subjectData = utils.defaultAbiCoder.encode( + ["address[]"], + [tradePath] + ); + }); + + it("should transfer the correct components to the SetToken", async () => { + const oldDestinationTokenBalance = await setup.dai.balanceOf(setToken.address); + const [, , expectedReceiveQuantity] = await uniswapSetup.router.getAmountsOut( + subjectSourceQuantity, + [subjectSourceToken, setup.weth.address, subjectDestinationToken] + ); + + await subject(); + + const expectedDestinationTokenBalance = oldDestinationTokenBalance.add(expectedReceiveQuantity); + const newDestinationTokenBalance = await setup.dai.balanceOf(setToken.address); + expect(newDestinationTokenBalance).to.eq(expectedDestinationTokenBalance); + }); + }); + }); + + context("when trading a Default component with a transfer fee on Uniswap", async () => { + cacheBeforeEach(async () => { + tokenWithFee = await deployer.mocks.deployTokenWithFeeMock(owner.address, ether(10000), ether(0.01)); + await tokenWithFee.connect(owner.wallet).approve(uniswapSetup.router.address, ether(10000)); + await setup.wbtc.connect(owner.wallet).approve(uniswapSetup.router.address, bitcoin(100)); + const poolPair = await uniswapSetup.createNewPair(tokenWithFee.address, setup.wbtc.address); + await uniswapSetup.router.addLiquidity( + tokenWithFee.address, + setup.wbtc.address, + ether(3400), + bitcoin(100), + ether(3000), + bitcoin(99.5), + owner.address, + MAX_UINT_256 + ); + await tokenWithFee.transfer(poolPair.address, ether(0.01)); + + tradeModuleV2 = tradeModuleV2.connect(manager.wallet); + await tradeModuleV2.initialize(setToken.address, manager.address); + + const wbtcSendQuantity = wbtcUnits; + const destinationTokenDecimals = await setup.wbtc.decimals(); + sourceTokenQuantity = wbtcRate.mul(wbtcSendQuantity).div(10 ** destinationTokenDecimals); + + // Transfer sourceToken from owner to manager for issuance + await setup.wbtc.connect(owner.wallet).transfer(manager.address, wbtcUnits.mul(100)); + + // Approve tokens to Controller and call issue + await setup.wbtc.connect(manager.wallet).approve(setup.issuanceModule.address, ethers.constants.MaxUint256); + + // Deploy mock issuance hook and initialize issuance module + setup.issuanceModule = setup.issuanceModule.connect(manager.wallet); + mockPreIssuanceHook = await deployer.mocks.deployManagerIssuanceHookMock(); + await setup.issuanceModule.initialize(setToken.address, mockPreIssuanceHook.address); + + issueQuantity = ether(1); + await setup.issuanceModule.issue(setToken.address, issueQuantity, owner.address); + + // Trade into token with fee + await tradeModuleV2.connect(manager.wallet).trade( + setToken.address, + uniswapTransferFeeAdapterName, + setup.wbtc.address, + wbtcSendQuantity, + tokenWithFee.address, + ZERO, + EMPTY_BYTES + ); + }); + + beforeEach(() => { + // Trade token with fee back to WBTC + subjectSourceToken = tokenWithFee.address; + subjectDestinationToken = setup.wbtc.address; + subjectSourceQuantity = sourceTokenQuantity; + subjectSetToken = setToken.address; + subjectAdapterName = uniswapTransferFeeAdapterName; + subjectData = EMPTY_BYTES; + subjectMinDestinationQuantity = ZERO; + subjectCaller = manager; + }); + + async function subject(): Promise { + tradeModuleV2 = tradeModuleV2.connect(subjectCaller.wallet); + return tradeModuleV2.trade( + subjectSetToken, + subjectAdapterName, + subjectSourceToken, + subjectSourceQuantity, + subjectDestinationToken, + subjectMinDestinationQuantity, + subjectData + ); + } + + it("should transfer the correct components to the SetToken", async () => { + const oldDestinationTokenBalance = await setup.wbtc.balanceOf(setToken.address); + const [, expectedReceiveQuantity] = await uniswapSetup.router.getAmountsOut( + subjectSourceQuantity.sub(ether(0.01)), // Sub transfer fee + [subjectSourceToken, subjectDestinationToken] + ); + + await subject(); + + const expectedDestinationTokenBalance = oldDestinationTokenBalance.add(expectedReceiveQuantity); + const newDestinationTokenBalance = await setup.wbtc.balanceOf(setToken.address); + expect(newDestinationTokenBalance).to.eq(expectedDestinationTokenBalance); + }); + + it("should transfer the correct components from the SetToken", async () => { + const oldSourceTokenBalance = await tokenWithFee.balanceOf(setToken.address); + + await subject(); + + const totalSourceQuantity = issueQuantity.mul(sourceTokenQuantity).div(ether(1)); + const expectedSourceTokenBalance = oldSourceTokenBalance.sub(totalSourceQuantity); + const newSourceTokenBalance = await tokenWithFee.balanceOf(setToken.address); + expect(newSourceTokenBalance).to.eq(expectedSourceTokenBalance); + }); + + it("should update the positions on the SetToken correctly", async () => { + const initialPositions = await setToken.getPositions(); + const [, expectedReceiveQuantity] = await uniswapSetup.router.getAmountsOut( + subjectSourceQuantity.sub(ether(0.01)), // Sub transfer fee + [subjectSourceToken, subjectDestinationToken] + ); + + await subject(); + + // All WBTC is sold for WETH + const currentPositions = await setToken.getPositions(); + const newSecondPosition = (await setToken.getPositions())[1]; + + expect(initialPositions.length).to.eq(1); + expect(currentPositions.length).to.eq(2); + expect(newSecondPosition.component).to.eq(subjectDestinationToken); + expect(newSecondPosition.unit).to.eq(expectedReceiveQuantity); + expect(newSecondPosition.module).to.eq(ADDRESS_ZERO); + }); + + describe("when path is through multiple trading pairs", async () => { + beforeEach(async () => { + await setup.wbtc.connect(owner.wallet).approve(uniswapSetup.router.address, bitcoin(1000)); + await setup.dai.connect(owner.wallet).approve(uniswapSetup.router.address, ether(1000000)); + await uniswapSetup.router.addLiquidity( + setup.wbtc.address, + setup.dai.address, + bitcoin(10), + ether(1000000), + ether(995), + ether(995000), + owner.address, + MAX_UINT_256 + ); + + subjectDestinationToken = setup.dai.address; + const tradePath = [subjectSourceToken, setup.wbtc.address, subjectDestinationToken]; + subjectData = utils.defaultAbiCoder.encode( + ["address[]"], + [tradePath] + ); + }); + + it("should transfer the correct components to the SetToken", async () => { + const oldDestinationTokenBalance = await setup.dai.balanceOf(setToken.address); + const [, , expectedReceiveQuantity] = await uniswapSetup.router.getAmountsOut( + subjectSourceQuantity.sub(ether(0.01)), // Sub transfer fee + [subjectSourceToken, setup.wbtc.address, subjectDestinationToken] + ); + + await subject(); + + const expectedDestinationTokenBalance = oldDestinationTokenBalance.add(expectedReceiveQuantity); + const newDestinationTokenBalance = await setup.dai.balanceOf(setToken.address); + expect(newDestinationTokenBalance).to.eq(expectedDestinationTokenBalance); + }); + }); + }); + + context("when trading a Default component on Uniswap version 2 adapter", async () => { + cacheBeforeEach(async () => { + await setup.weth.connect(owner.wallet).approve(uniswapSetup.router.address, ether(10000)); + await setup.wbtc.connect(owner.wallet).approve(uniswapSetup.router.address, bitcoin(100)); + await uniswapSetup.router.addLiquidity( + setup.weth.address, + setup.wbtc.address, + ether(3400), + bitcoin(100), + ether(3395), + ether(99.5), + owner.address, + MAX_UINT_256 + ); + await setup.dai.connect(owner.wallet).approve(uniswapSetup.router.address, ether(1000000)); + await uniswapSetup.router.addLiquidity( + setup.weth.address, + setup.dai.address, + ether(1000), + ether(1000000), + ether(995), + ether(995000), + owner.address, + MAX_UINT_256 + ); + + tradeModuleV2 = tradeModuleV2.connect(manager.wallet); + await tradeModuleV2.initialize(setToken.address, manager.address); + + sourceTokenQuantity = wbtcUnits; + const sourceTokenDecimals = await sourceToken.decimals(); + destinationTokenQuantity = wbtcRate.mul(sourceTokenQuantity).div(10 ** sourceTokenDecimals); + + // Transfer sourceToken from owner to manager for issuance + sourceToken = sourceToken.connect(owner.wallet); + await sourceToken.transfer(manager.address, wbtcUnits.mul(100)); + + // Approve tokens to Controller and call issue + sourceToken = sourceToken.connect(manager.wallet); + await sourceToken.approve(setup.issuanceModule.address, ethers.constants.MaxUint256); + + // Deploy mock issuance hook and initialize issuance module + setup.issuanceModule = setup.issuanceModule.connect(manager.wallet); + mockPreIssuanceHook = await deployer.mocks.deployManagerIssuanceHookMock(); + await setup.issuanceModule.initialize(setToken.address, mockPreIssuanceHook.address); + + issueQuantity = ether(1); + await setup.issuanceModule.issue(setToken.address, issueQuantity, owner.address); + }); + + async function subject(): Promise { + tradeModuleV2 = tradeModuleV2.connect(subjectCaller.wallet); + return tradeModuleV2.trade( + subjectSetToken, + subjectAdapterName, + subjectSourceToken, + subjectSourceQuantity, + subjectDestinationToken, + subjectMinDestinationQuantity, + subjectData + ); + } + + describe("when path is through one pair and swaps exact tokens for tokens", async () => { + beforeEach(async () => { + subjectSourceToken = sourceToken.address; + subjectDestinationToken = destinationToken.address; + subjectSourceQuantity = sourceTokenQuantity; + subjectSetToken = setToken.address; + subjectMinDestinationQuantity = destinationTokenQuantity.sub(ether(1)); // Receive a min of 32 WETH for 1 WBTC + subjectAdapterName = uniswapAdapterV2Name; + const tradePath = [subjectSourceToken, subjectDestinationToken]; + const shouldSwapExactTokenForToken = true; + subjectData = await uniswapExchangeAdapterV2.getUniswapExchangeData(tradePath, shouldSwapExactTokenForToken); + subjectCaller = manager; + }); + + it("should transfer the correct components to the SetToken", async () => { + const oldDestinationTokenBalance = await destinationToken.balanceOf(setToken.address); + const [, expectedReceiveQuantity] = await uniswapSetup.router.getAmountsOut( + subjectSourceQuantity, + [subjectSourceToken, subjectDestinationToken] + ); + + await subject(); + + const expectedDestinationTokenBalance = oldDestinationTokenBalance.add(expectedReceiveQuantity); + const newDestinationTokenBalance = await destinationToken.balanceOf(setToken.address); + expect(newDestinationTokenBalance).to.eq(expectedDestinationTokenBalance); + }); + + it("should transfer the correct components from the SetToken", async () => { + const oldSourceTokenBalance = await sourceToken.balanceOf(setToken.address); + + await subject(); + + const totalSourceQuantity = issueQuantity.mul(sourceTokenQuantity).div(ether(1)); + const expectedSourceTokenBalance = oldSourceTokenBalance.sub(totalSourceQuantity); + const newSourceTokenBalance = await sourceToken.balanceOf(setToken.address); + expect(newSourceTokenBalance).to.eq(expectedSourceTokenBalance); + }); + + it("should update the positions on the SetToken correctly", async () => { + const initialPositions = await setToken.getPositions(); + const [, expectedReceiveQuantity] = await uniswapSetup.router.getAmountsOut( + subjectSourceQuantity, + [subjectSourceToken, subjectDestinationToken] + ); + + await subject(); + + // All WBTC is sold for WETH + const currentPositions = await setToken.getPositions(); + const newFirstPosition = (await setToken.getPositions())[0]; + + expect(initialPositions.length).to.eq(1); + expect(currentPositions.length).to.eq(1); + expect(newFirstPosition.component).to.eq(destinationToken.address); + expect(newFirstPosition.unit).to.eq(expectedReceiveQuantity); + expect(newFirstPosition.module).to.eq(ADDRESS_ZERO); + }); + }); + + describe("when path is through one pair and swaps for exact tokens", async () => { + beforeEach(async () => { + subjectSourceToken = sourceToken.address; + subjectDestinationToken = destinationToken.address; + subjectSourceQuantity = sourceTokenQuantity; + subjectSetToken = setToken.address; + subjectMinDestinationQuantity = ether(1); + subjectAdapterName = uniswapAdapterV2Name; + const tradePath = [subjectSourceToken, subjectDestinationToken]; + const shouldSwapExactTokenForToken = false; + subjectData = await uniswapExchangeAdapterV2.getUniswapExchangeData(tradePath, shouldSwapExactTokenForToken); + subjectCaller = manager; + }); + + it("should transfer the correct components to the SetToken", async () => { + const oldSourceTokenBalance = await setup.wbtc.balanceOf(setToken.address); + const [notionalSendQuantity, , ] = await uniswapSetup.router.getAmountsIn( + subjectMinDestinationQuantity, // In this case, this is the exact destination quantity + [subjectSourceToken, subjectDestinationToken] + ); + await subject(); + + const expectedSourceTokenBalance = oldSourceTokenBalance.sub(notionalSendQuantity); + const newSourceTokenBalance = await setup.wbtc.balanceOf(setToken.address); + expect(newSourceTokenBalance).to.eq(expectedSourceTokenBalance); + }); + }); + + describe("when path is through multiple trading pairs and swaps exact tokens for tokens", async () => { + beforeEach(async () => { + subjectSourceToken = sourceToken.address; + subjectDestinationToken = setup.dai.address; + subjectMinDestinationQuantity = ether(100); + subjectSourceQuantity = sourceTokenQuantity; + subjectSetToken = setToken.address; + subjectAdapterName = uniswapAdapterV2Name; + const tradePath = [subjectSourceToken, setup.weth.address, subjectDestinationToken]; + const shouldSwapExactTokenForToken = true; + subjectData = await uniswapExchangeAdapterV2.getUniswapExchangeData(tradePath, shouldSwapExactTokenForToken); + subjectCaller = manager; + }); + + it("should transfer the correct components to the SetToken", async () => { + const oldDestinationTokenBalance = await setup.dai.balanceOf(setToken.address); + const [, , expectedReceiveQuantity] = await uniswapSetup.router.getAmountsOut( + subjectSourceQuantity, + [subjectSourceToken, setup.weth.address, subjectDestinationToken] + ); + + await subject(); + + const expectedDestinationTokenBalance = oldDestinationTokenBalance.add(expectedReceiveQuantity); + const newDestinationTokenBalance = await setup.dai.balanceOf(setToken.address); + expect(newDestinationTokenBalance).to.eq(expectedDestinationTokenBalance); + }); + }); + + describe("when path is through multiple trading pairs and swaps for exact tokens", async () => { + beforeEach(async () => { + subjectSourceToken = sourceToken.address; + subjectDestinationToken = setup.dai.address; + subjectMinDestinationQuantity = ether(1000); + subjectSourceQuantity = sourceTokenQuantity; + subjectSetToken = setToken.address; + subjectAdapterName = uniswapAdapterV2Name; + const tradePath = [subjectSourceToken, setup.weth.address, subjectDestinationToken]; + const shouldSwapExactTokenForToken = false; + subjectData = await uniswapExchangeAdapterV2.getUniswapExchangeData(tradePath, shouldSwapExactTokenForToken); + subjectCaller = manager; + }); + + it("should transfer the correct components to the SetToken", async () => { + const oldSourceTokenBalance = await setup.wbtc.balanceOf(setToken.address); + const [notionalSendQuantity, , ] = await uniswapSetup.router.getAmountsIn( + subjectMinDestinationQuantity, // In this case, this is the exact destination quantity + [subjectSourceToken, setup.weth.address, subjectDestinationToken] + ); + await subject(); + + const expectedSourceTokenBalance = oldSourceTokenBalance.sub(notionalSendQuantity); + const newSourceTokenBalance = await setup.wbtc.balanceOf(setToken.address); + const newDestinationTokenBalance = await setup.dai.balanceOf(setToken.address); + + expect(newDestinationTokenBalance).to.eq(subjectMinDestinationQuantity); + expect(newSourceTokenBalance).to.eq(expectedSourceTokenBalance); + }); + + it("should update the positions on the SetToken correctly", async () => { + const initialPositions = await setToken.getPositions(); + const [sendQuantity, , ] = await uniswapSetup.router.getAmountsIn( + subjectMinDestinationQuantity, // In this case, this is the exact destination quantity + [subjectSourceToken, setup.weth.address, subjectDestinationToken] + ); + const expectedSourceTokenUnit = initialPositions[0].unit.sub(sendQuantity); + + await subject(); + + const currentPositions = await setToken.getPositions(); + const newFirstPosition = (await setToken.getPositions())[0]; + const newSecondPosition = (await setToken.getPositions())[1]; + + expect(initialPositions.length).to.eq(1); + expect(currentPositions.length).to.eq(2); + expect(newFirstPosition.component).to.eq(subjectSourceToken); + expect(newFirstPosition.unit).to.eq(expectedSourceTokenUnit); + expect(newFirstPosition.module).to.eq(ADDRESS_ZERO); + expect(newSecondPosition.component).to.eq(subjectDestinationToken); + expect(newSecondPosition.unit).to.eq(subjectMinDestinationQuantity); + expect(newSecondPosition.module).to.eq(ADDRESS_ZERO); + }); + }); + }); + + context("when trading a Default component on One Inch", async () => { + cacheBeforeEach(async () => { + // Add Set token as token sender / recipient + oneInchExchangeMock = oneInchExchangeMock.connect(owner.wallet); + await oneInchExchangeMock.addSetTokenAddress(setToken.address); + + // Fund One Inch exchange with destinationToken WETH + await destinationToken.transfer(oneInchExchangeMock.address, ether(1000)); + + tradeModuleV2 = tradeModuleV2.connect(manager.wallet); + await tradeModuleV2.initialize(setToken.address, manager.address); + + // Trade 1 WBTC. Note: 1inch mock is hardcoded to trade 1 WBTC unit regardless of Set supply + sourceTokenQuantity = wbtcUnits; + const sourceTokenDecimals = await sourceToken.decimals(); + destinationTokenQuantity = wbtcRate.mul(sourceTokenQuantity).div(10 ** sourceTokenDecimals); + + // Transfer sourceToken from owner to manager for issuance + sourceToken = sourceToken.connect(owner.wallet); + await sourceToken.transfer(manager.address, wbtcUnits.mul(100)); + + // Approve tokens to Controller and call issue + sourceToken = sourceToken.connect(manager.wallet); + await sourceToken.approve(setup.issuanceModule.address, ethers.constants.MaxUint256); + + // Deploy mock issuance hook and initialize issuance module + setup.issuanceModule = setup.issuanceModule.connect(manager.wallet); + mockPreIssuanceHook = await deployer.mocks.deployManagerIssuanceHookMock(); + await setup.issuanceModule.initialize(setToken.address, mockPreIssuanceHook.address); + + // Issue 1 SetToken. Note: 1inch mock is hardcoded to trade 1 WBTC unit regardless of Set supply + issueQuantity = ether(1); + await setup.issuanceModule.issue(setToken.address, issueQuantity, owner.address); + }); + + beforeEach(() => { + subjectSourceToken = sourceToken.address; + subjectDestinationToken = destinationToken.address; + subjectSourceQuantity = sourceTokenQuantity; + subjectSetToken = setToken.address; + subjectAdapterName = oneInchAdapterName; + // Encode function data. Inputs are unused in the mock One Inch contract + subjectData = oneInchExchangeMock.interface.encodeFunctionData("swap", [ + sourceToken.address, // Send token + destinationToken.address, // Receive token + sourceTokenQuantity, // Send quantity + destinationTokenQuantity.sub(ether(1)), // Min receive quantity + ZERO, + ADDRESS_ZERO, + [ADDRESS_ZERO], + EMPTY_BYTES, + [ZERO], + [ZERO], + ]); + subjectMinDestinationQuantity = destinationTokenQuantity.sub(ether(1)); // Receive a min of 32 WETH for 1 WBTC + subjectCaller = manager; + }); + + async function subject(): Promise { + tradeModuleV2 = tradeModuleV2.connect(subjectCaller.wallet); + return tradeModuleV2.trade( + subjectSetToken, + subjectAdapterName, + subjectSourceToken, + subjectSourceQuantity, + subjectDestinationToken, + subjectMinDestinationQuantity, + subjectData + ); + } + + it("should transfer the correct components to the SetToken", async () => { + const oldDestinationTokenBalance = await destinationToken.balanceOf(setToken.address); + + await subject(); + + const totalDestinationQuantity = issueQuantity.mul(destinationTokenQuantity).div(ether(1)); + const expectedDestinationTokenBalance = oldDestinationTokenBalance.add(totalDestinationQuantity); + const newDestinationTokenBalance = await destinationToken.balanceOf(setToken.address); + expect(newDestinationTokenBalance).to.eq(expectedDestinationTokenBalance); + }); + + it("should transfer the correct components from the SetToken", async () => { + const oldSourceTokenBalance = await sourceToken.balanceOf(setToken.address); + + await subject(); + + const totalSourceQuantity = issueQuantity.mul(sourceTokenQuantity).div(ether(1)); + const expectedSourceTokenBalance = oldSourceTokenBalance.sub(totalSourceQuantity); + const newSourceTokenBalance = await sourceToken.balanceOf(setToken.address); + expect(newSourceTokenBalance).to.eq(expectedSourceTokenBalance); + }); + + it("should transfer the correct components to the exchange", async () => { + const oldSourceTokenBalance = await sourceToken.balanceOf(oneInchExchangeMock.address); + + await subject(); + + const totalSourceQuantity = issueQuantity.mul(sourceTokenQuantity).div(ether(1)); + const expectedSourceTokenBalance = oldSourceTokenBalance.add(totalSourceQuantity); + const newSourceTokenBalance = await sourceToken.balanceOf(oneInchExchangeMock.address); + expect(newSourceTokenBalance).to.eq(expectedSourceTokenBalance); + }); + + it("should transfer the correct components from the exchange", async () => { + const oldDestinationTokenBalance = await destinationToken.balanceOf(oneInchExchangeMock.address); + + await subject(); + + const totalDestinationQuantity = issueQuantity.mul(destinationTokenQuantity).div(ether(1)); + const expectedDestinationTokenBalance = oldDestinationTokenBalance.sub(totalDestinationQuantity); + const newDestinationTokenBalance = await destinationToken.balanceOf(oneInchExchangeMock.address); + expect(newDestinationTokenBalance).to.eq(expectedDestinationTokenBalance); + }); + + it("should update the positions on the SetToken correctly", async () => { + const initialPositions = await setToken.getPositions(); + + await subject(); + + // All WBTC is sold for WETH + const currentPositions = await setToken.getPositions(); + const newFirstPosition = (await setToken.getPositions())[0]; + + expect(initialPositions.length).to.eq(1); + expect(currentPositions.length).to.eq(1); + expect(newFirstPosition.component).to.eq(destinationToken.address); + expect(newFirstPosition.unit).to.eq(destinationTokenQuantity); + expect(newFirstPosition.module).to.eq(ADDRESS_ZERO); + }); + + describe("when function signature does not match 1inch", async () => { + beforeEach(async () => { + // Encode random function + subjectData = oneInchExchangeMock.interface.encodeFunctionData("addSetTokenAddress", [ADDRESS_ZERO]); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Not One Inch Swap Function"); + }); + }); + + describe("when send token does not match calldata", async () => { + beforeEach(async () => { + // Get random source token + const randomToken = await getRandomAccount(); + subjectData = oneInchExchangeMock.interface.encodeFunctionData("swap", [ + randomToken.address, // Send token + destinationToken.address, // Receive token + sourceTokenQuantity, // Send quantity + destinationTokenQuantity.sub(ether(1)), // Min receive quantity + ZERO, + ADDRESS_ZERO, + [ADDRESS_ZERO], + EMPTY_BYTES, + [ZERO], + [ZERO], + ]); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Invalid send token"); + }); + }); + + describe("when receive token does not match calldata", async () => { + beforeEach(async () => { + // Get random source token + const randomToken = await getRandomAccount(); + subjectData = oneInchExchangeMock.interface.encodeFunctionData("swap", [ + sourceToken.address, // Send token + randomToken.address, // Receive token + sourceTokenQuantity, // Send quantity + destinationTokenQuantity.sub(ether(1)), // Min receive quantity + ZERO, + ADDRESS_ZERO, + [ADDRESS_ZERO], + EMPTY_BYTES, + [ZERO], + [ZERO], + ]); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Invalid receive token"); + }); + }); + + describe("when send token quantity does not match calldata", async () => { + beforeEach(async () => { + subjectData = oneInchExchangeMock.interface.encodeFunctionData("swap", [ + sourceToken.address, // Send token + destinationToken.address, // Receive token + ZERO, // Send quantity + destinationTokenQuantity.sub(ether(1)), // Min receive quantity + ZERO, + ADDRESS_ZERO, + [ADDRESS_ZERO], + EMPTY_BYTES, + [ZERO], + [ZERO], + ]); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Source quantity mismatch"); + }); + }); + + describe("when min receive token quantity does not match calldata", async () => { + beforeEach(async () => { + subjectData = oneInchExchangeMock.interface.encodeFunctionData("swap", [ + sourceToken.address, // Send token + destinationToken.address, // Receive token + sourceTokenQuantity, // Send quantity + ZERO, // Min receive quantity + ZERO, + ADDRESS_ZERO, + [ADDRESS_ZERO], + EMPTY_BYTES, + [ZERO], + [ZERO], + ]); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Min destination quantity mismatch"); + }); + }); + }); + + context("when trading a Default component on 0xAPI", async () => { + cacheBeforeEach(async () => { + // Add Set token as token sender / recipient + zeroExMock = zeroExMock.connect(owner.wallet); + await zeroExMock.addSetTokenAddress(setToken.address); + + // Fund One Inch exchange with destinationToken WETH + await destinationToken.transfer(zeroExMock.address, ether(1000)); + + tradeModuleV2 = tradeModuleV2.connect(manager.wallet); + await tradeModuleV2.initialize(setToken.address, manager.address); + + // Trade 1 WBTC. Note: 1inch mock is hardcoded to trade 1 WBTC unit regardless of Set supply + sourceTokenQuantity = wbtcUnits; + const sourceTokenDecimals = await sourceToken.decimals(); + destinationTokenQuantity = wbtcRate.mul(sourceTokenQuantity).div(10 ** sourceTokenDecimals); + + // Transfer sourceToken from owner to manager for issuance + sourceToken = sourceToken.connect(owner.wallet); + await sourceToken.transfer(manager.address, wbtcUnits.mul(100)); + + // Approve tokens to Controller and call issue + sourceToken = sourceToken.connect(manager.wallet); + await sourceToken.approve(setup.issuanceModule.address, ethers.constants.MaxUint256); + + // Deploy mock issuance hook and initialize issuance module + setup.issuanceModule = setup.issuanceModule.connect(manager.wallet); + mockPreIssuanceHook = await deployer.mocks.deployManagerIssuanceHookMock(); + await setup.issuanceModule.initialize(setToken.address, mockPreIssuanceHook.address); + + // Issue 1 SetToken. Note: 1inch mock is hardcoded to trade 1 WBTC unit regardless of Set supply + issueQuantity = ether(1); + await setup.issuanceModule.issue(setToken.address, issueQuantity, owner.address); + }); + + beforeEach(() => { + subjectSourceToken = sourceToken.address; + subjectDestinationToken = destinationToken.address; + subjectSourceQuantity = sourceTokenQuantity; + subjectSetToken = setToken.address; + subjectAdapterName = zeroExApiAdapterName; + // Encode function data. Inputs are unused in the mock One Inch contract + subjectData = zeroExMock.interface.encodeFunctionData("transformERC20", [ + sourceToken.address, // Send token + destinationToken.address, // Receive token + sourceTokenQuantity, // Send quantity + destinationTokenQuantity.sub(ether(1)), // Min receive quantity + [], + ]); + subjectMinDestinationQuantity = destinationTokenQuantity.sub(ether(1)); // Receive a min of 32 WETH for 1 WBTC + subjectCaller = manager; + }); + + async function subject(): Promise { + tradeModuleV2 = tradeModuleV2.connect(subjectCaller.wallet); + return tradeModuleV2.trade( + subjectSetToken, + subjectAdapterName, + subjectSourceToken, + subjectSourceQuantity, + subjectDestinationToken, + subjectMinDestinationQuantity, + subjectData + ); + } + + it("should transfer the correct components to the SetToken", async () => { + const oldDestinationTokenBalance = await destinationToken.balanceOf(setToken.address); + + await subject(); + + const totalDestinationQuantity = issueQuantity.mul(destinationTokenQuantity).div(ether(1)); + const expectedDestinationTokenBalance = oldDestinationTokenBalance.add(totalDestinationQuantity); + const newDestinationTokenBalance = await destinationToken.balanceOf(setToken.address); + expect(newDestinationTokenBalance).to.eq(expectedDestinationTokenBalance); + }); + + it("should transfer the correct components from the SetToken", async () => { + const oldSourceTokenBalance = await sourceToken.balanceOf(setToken.address); + + await subject(); + + const totalSourceQuantity = issueQuantity.mul(sourceTokenQuantity).div(ether(1)); + const expectedSourceTokenBalance = oldSourceTokenBalance.sub(totalSourceQuantity); + const newSourceTokenBalance = await sourceToken.balanceOf(setToken.address); + expect(newSourceTokenBalance).to.eq(expectedSourceTokenBalance); + }); + + it("should transfer the correct components to the exchange", async () => { + const oldSourceTokenBalance = await sourceToken.balanceOf(zeroExMock.address); + + await subject(); + + const totalSourceQuantity = issueQuantity.mul(sourceTokenQuantity).div(ether(1)); + const expectedSourceTokenBalance = oldSourceTokenBalance.add(totalSourceQuantity); + const newSourceTokenBalance = await sourceToken.balanceOf(zeroExMock.address); + expect(newSourceTokenBalance).to.eq(expectedSourceTokenBalance); + }); + + it("should transfer the correct components from the exchange", async () => { + const oldDestinationTokenBalance = await destinationToken.balanceOf(zeroExMock.address); + + await subject(); + + const totalDestinationQuantity = issueQuantity.mul(destinationTokenQuantity).div(ether(1)); + const expectedDestinationTokenBalance = oldDestinationTokenBalance.sub(totalDestinationQuantity); + const newDestinationTokenBalance = await destinationToken.balanceOf(zeroExMock.address); + expect(newDestinationTokenBalance).to.eq(expectedDestinationTokenBalance); + }); + + it("should update the positions on the SetToken correctly", async () => { + const initialPositions = await setToken.getPositions(); + + await subject(); + + // All WBTC is sold for WETH + const currentPositions = await setToken.getPositions(); + const newFirstPosition = (await setToken.getPositions())[0]; + + expect(initialPositions.length).to.eq(1); + expect(currentPositions.length).to.eq(1); + expect(newFirstPosition.component).to.eq(destinationToken.address); + expect(newFirstPosition.unit).to.eq(destinationTokenQuantity); + expect(newFirstPosition.module).to.eq(ADDRESS_ZERO); + }); + + describe("when function signature is not supported", async () => { + beforeEach(async () => { + // Encode random function + subjectData = zeroExMock.interface.encodeFunctionData("addSetTokenAddress", [ADDRESS_ZERO]); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Unsupported 0xAPI function selector"); + }); + }); + + describe("when send token does not match calldata", async () => { + beforeEach(async () => { + // Get random source token + const randomToken = await getRandomAccount(); + subjectData = zeroExMock.interface.encodeFunctionData("transformERC20", [ + randomToken.address, // Send token + destinationToken.address, // Receive token + sourceTokenQuantity, // Send quantity + destinationTokenQuantity.sub(ether(1)), // Min receive quantity + [], + ]); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Mismatched input token"); + }); + }); + + describe("when receive token does not match calldata", async () => { + beforeEach(async () => { + // Get random source token + const randomToken = await getRandomAccount(); + subjectData = zeroExMock.interface.encodeFunctionData("transformERC20", [ + sourceToken.address, // Send token + randomToken.address, // Receive token + sourceTokenQuantity, // Send quantity + destinationTokenQuantity.sub(ether(1)), // Min receive quantity + [], + ]); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Mismatched output token"); + }); + }); + + describe("when send token quantity does not match calldata", async () => { + beforeEach(async () => { + subjectData = zeroExMock.interface.encodeFunctionData("transformERC20", [ + sourceToken.address, // Send token + destinationToken.address, // Receive token + ZERO, // Send quantity + destinationTokenQuantity.sub(ether(1)), // Min receive quantity + [], + ]); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Mismatched input token quantity"); + }); + }); + + describe("when min receive token quantity does not match calldata", async () => { + beforeEach(async () => { + subjectData = zeroExMock.interface.encodeFunctionData("transformERC20", [ + sourceToken.address, // Send token + destinationToken.address, // Receive token + sourceTokenQuantity, // Send quantity + ZERO, // Min receive quantity + [], + ]); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Mismatched output token quantity"); + }); + }); + }); + + context("when trading a Default component on Uniswap V3", async () => { + cacheBeforeEach(async () => { + await setup.weth.connect(owner.wallet).approve(uniswapV3Setup.nftPositionManager.address, ether(350)); + await setup.wbtc.connect(owner.wallet).approve(uniswapV3Setup.nftPositionManager.address, bitcoin(25)); + + await uniswapV3Setup.addLiquidityWide( + setup.weth, + setup.wbtc, + 3000, + ether(350), + bitcoin(25), + owner.address + ); + + tradeModuleV2 = tradeModuleV2.connect(manager.wallet); + await tradeModuleV2.initialize(setToken.address, manager.address); + + sourceTokenQuantity = wbtcUnits; + + // Transfer sourceToken from owner to manager for issuance + sourceToken = sourceToken.connect(owner.wallet); + await sourceToken.transfer(manager.address, wbtcUnits.mul(100)); + + // Approve tokens to Controller and call issue + sourceToken = sourceToken.connect(manager.wallet); + await sourceToken.approve(setup.issuanceModule.address, ethers.constants.MaxUint256); + + // Deploy mock issuance hook and initialize issuance module + setup.issuanceModule = setup.issuanceModule.connect(manager.wallet); + mockPreIssuanceHook = await deployer.mocks.deployManagerIssuanceHookMock(); + await setup.issuanceModule.initialize(setToken.address, mockPreIssuanceHook.address); + + issueQuantity = ether(1); + await setup.issuanceModule.issue(setToken.address, issueQuantity, owner.address); + }); + + beforeEach(async () => { + subjectSourceToken = sourceToken.address; + subjectDestinationToken = destinationToken.address; + subjectSourceQuantity = sourceTokenQuantity; + subjectSetToken = setToken.address; + subjectAdapterName = uniswapV3AdapterName; + subjectData = await uniswapV3ExchangeAdapter.generateDataParam([setup.wbtc.address, setup.weth.address], [3000]); + subjectMinDestinationQuantity = BigNumber.from(0); + subjectCaller = manager; + }); + + async function subject(): Promise { + tradeModuleV2 = tradeModuleV2.connect(subjectCaller.wallet); + return tradeModuleV2.trade( + subjectSetToken, + subjectAdapterName, + subjectSourceToken, + subjectSourceQuantity, + subjectDestinationToken, + subjectMinDestinationQuantity, + subjectData + ); + } + + it("should transfer the correct components to the SetToken", async () => { + const oldDestinationTokenBalance = await destinationToken.balanceOf(setToken.address); + const expectedReceiveQuantity = await uniswapV3Setup.quoter.callStatic.quoteExactInputSingle( + subjectSourceToken, + subjectDestinationToken, + 3000, + subjectSourceQuantity, + 0 + ); + + await subject(); + + const expectedDestinationTokenBalance = oldDestinationTokenBalance.add(expectedReceiveQuantity); + const newDestinationTokenBalance = await destinationToken.balanceOf(setToken.address); + expect(newDestinationTokenBalance).to.eq(expectedDestinationTokenBalance); + }); + + it("should transfer the correct components from the SetToken", async () => { + const oldSourceTokenBalance = await sourceToken.balanceOf(setToken.address); + + await subject(); + + const totalSourceQuantity = issueQuantity.mul(sourceTokenQuantity).div(ether(1)); + const expectedSourceTokenBalance = oldSourceTokenBalance.sub(totalSourceQuantity); + const newSourceTokenBalance = await sourceToken.balanceOf(setToken.address); + expect(newSourceTokenBalance).to.eq(expectedSourceTokenBalance); + }); + + it("should update the positions on the SetToken correctly", async () => { + const initialPositions = await setToken.getPositions(); + const expectedReceiveQuantity = await uniswapV3Setup.quoter.callStatic.quoteExactInputSingle( + subjectSourceToken, + subjectDestinationToken, + 3000, + subjectSourceQuantity, + 0 + ); + + await subject(); + + // All WBTC is sold for WETH + const currentPositions = await setToken.getPositions(); + const newFirstPosition = (await setToken.getPositions())[0]; + + expect(initialPositions.length).to.eq(1); + expect(currentPositions.length).to.eq(1); + expect(newFirstPosition.component).to.eq(destinationToken.address); + expect(newFirstPosition.unit).to.eq(expectedReceiveQuantity); + expect(newFirstPosition.module).to.eq(ADDRESS_ZERO); + }); + + describe("when path is through multiple trading pairs", async () => { + beforeEach(async () => { + await setup.weth.connect(owner.wallet).approve(uniswapV3Setup.nftPositionManager.address, ether(1000)); + await setup.dai.connect(owner.wallet).approve(uniswapV3Setup.nftPositionManager.address, ether(1000000)); + + await uniswapV3Setup.addLiquidityWide( + setup.weth, + setup.dai, + 3000, + ether(1000), + ether(1000000), + owner.address + ); + + subjectDestinationToken = setup.dai.address; + + const tradePath = [subjectSourceToken, setup.weth.address, subjectDestinationToken]; + const fees = [3000, 3000]; + + subjectData = await uniswapV3ExchangeAdapter.generateDataParam(tradePath, fees); + }); + + it("should transfer the correct components to the SetToken", async () => { + const oldDestinationTokenBalance = await setup.dai.balanceOf(setToken.address); + const expectedReceiveQuantity = await uniswapV3Setup.quoter.callStatic.quoteExactInput( + subjectData, + subjectSourceQuantity + ); + + await subject(); + + const expectedDestinationTokenBalance = oldDestinationTokenBalance.add(expectedReceiveQuantity); + const newDestinationTokenBalance = await setup.dai.balanceOf(setToken.address); + expect(newDestinationTokenBalance).to.eq(expectedDestinationTokenBalance); + }); + }); + }); + }); + + describe("#updateFeeRecipient", async () => { + let subjectSetToken: Address; + let subjectNewFeeRecipient: Address; + let subjectCaller: Account; + + beforeEach(async () => { + tradeModuleV2 = tradeModuleV2.connect(manager.wallet); + await tradeModuleV2.initialize(setToken.address, manager.address); + + subjectNewFeeRecipient = await getRandomAddress(); + subjectSetToken = setToken.address; + subjectCaller = manager; + }); + + async function subject(): Promise { + return tradeModuleV2.connect(subjectCaller.wallet).updateFeeRecipient( + subjectSetToken, + subjectNewFeeRecipient + ); + } + + it("should have set the new fee recipient address", async () => { + await subject(); + + const managerRebateRecipient = await tradeModuleV2.managerRebateRecipient(subjectSetToken); + + expect(managerRebateRecipient).to.eq(subjectNewFeeRecipient); + }); + + it("should emit the correct FeeRecipientUpdated event", async () => { + await expect(subject()).to.emit(tradeModuleV2, "FeeRecipientUpdated").withArgs( + subjectSetToken, + subjectNewFeeRecipient + ); + }); + + describe("when fee recipient address is null address", async () => { + beforeEach(async () => { + subjectNewFeeRecipient = ADDRESS_ZERO; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Recipient must be non-zero address."); + }); + }); + + describe("when fee recipient address is same address", async () => { + beforeEach(async () => { + subjectNewFeeRecipient = await tradeModuleV2.managerRebateRecipient(subjectSetToken); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Same fee recipient passed"); + }); + }); + + describe("when SetToken is not valid", async () => { + beforeEach(async () => { + const nonEnabledSetToken = await setup.createNonControllerEnabledSetToken( + [setup.weth.address], + [ether(1)], + [tradeModuleV2.address], + manager.address + ); + + subjectSetToken = nonEnabledSetToken.address; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be a valid and initialized SetToken"); + }); + }); + + describe("when the caller is not the SetToken manager", async () => { + beforeEach(async () => { + subjectCaller = owner; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be the SetToken manager"); + }); + }); + }); + + describe("#removeModule", async () => { + let subjectModule: Address; + + beforeEach(async () => { + tradeModuleV2 = tradeModuleV2.connect(manager.wallet); + await tradeModuleV2.initialize(setToken.address, manager.address); + + subjectModule = tradeModuleV2.address; + }); + + async function subject(): Promise { + setToken = setToken.connect(manager.wallet); + return setToken.removeModule(subjectModule); + } + + it("should remove the module", async () => { + await subject(); + const isModuleEnabled = await setToken.isInitializedModule(tradeModuleV2.address); + expect(isModuleEnabled).to.eq(false); + }); + + it("should delete the manager recipient state", async () => { + await subject(); + const managerRebateRecipient = await tradeModuleV2.managerRebateRecipient(setToken.address); + expect(managerRebateRecipient).to.eq(ADDRESS_ZERO); + }); + }); + }); +}); diff --git a/utils/contracts/index.ts b/utils/contracts/index.ts index b75de1ad0..725c75c1d 100644 --- a/utils/contracts/index.ts +++ b/utils/contracts/index.ts @@ -42,6 +42,7 @@ export { ExplicitERC20Mock } from "../../typechain/ExplicitERC20Mock"; export { ForceFunderMock } from "../../typechain/ForceFunderMock"; export { GaugeControllerMock } from "../../typechain/GaugeControllerMock"; export { GeneralIndexModule } from "../../typechain/GeneralIndexModule"; +export { GeneralIndexModuleV2 } from "../../typechain/GeneralIndexModuleV2"; export { GodModeMock } from "../../typechain/GodModeMock"; export { GovernanceAdapterMock } from "../../typechain/GovernanceAdapterMock"; export { GovernanceModule } from "../../typechain/GovernanceModule"; @@ -90,6 +91,7 @@ export { SynthetixExchangerMock } from "../../typechain/SynthetixExchangerMock"; export { SynthMock } from "../../typechain/SynthMock"; export { TokenSwap } from "../../typechain/TokenSwap"; export { TradeModule } from "../../typechain/TradeModule"; +export { TradeModuleV2 } from "../../typechain/TradeModuleV2"; export { TradeAdapterMock } from "../../typechain/TradeAdapterMock"; export { Uint256ArrayUtilsMock } from "../../typechain/Uint256ArrayUtilsMock"; export { Uni } from "../../typechain/Uni"; diff --git a/utils/deploys/deployModules.ts b/utils/deploys/deployModules.ts index 45e0e81a6..6035a03f7 100644 --- a/utils/deploys/deployModules.ts +++ b/utils/deploys/deployModules.ts @@ -10,6 +10,7 @@ import { CustomOracleNavIssuanceModule, DebtIssuanceModule, GeneralIndexModule, + GeneralIndexModuleV2, GovernanceModule, IssuanceModule, NavIssuanceModule, @@ -17,6 +18,7 @@ import { StakingModule, StreamingFeeModule, TradeModule, + TradeModuleV2, WrapModule, WrapModuleV2 } from "../contracts"; @@ -32,6 +34,7 @@ import { CustomOracleNavIssuanceModule__factory } from "../../typechain/factorie import { DebtIssuanceModule__factory } from "../../typechain/factories/DebtIssuanceModule__factory"; import { DebtIssuanceModuleV2__factory } from "../../typechain/factories/DebtIssuanceModuleV2__factory"; import { GeneralIndexModule__factory } from "../../typechain/factories/GeneralIndexModule__factory"; +import { GeneralIndexModuleV2__factory } from "../../typechain/factories/GeneralIndexModuleV2__factory"; import { GovernanceModule__factory } from "../../typechain/factories/GovernanceModule__factory"; import { IssuanceModule__factory } from "../../typechain/factories/IssuanceModule__factory"; import { NavIssuanceModule__factory } from "../../typechain/factories/NavIssuanceModule__factory"; @@ -39,6 +42,7 @@ import { SingleIndexModule__factory } from "../../typechain/factories/SingleInde import { StakingModule__factory } from "../../typechain/factories/StakingModule__factory"; import { StreamingFeeModule__factory } from "../../typechain/factories/StreamingFeeModule__factory"; import { TradeModule__factory } from "../../typechain/factories/TradeModule__factory"; +import { TradeModuleV2__factory } from "../../typechain/factories/TradeModuleV2__factory"; import { WrapModule__factory } from "../../typechain/factories/WrapModule__factory"; import { WrapModuleV2__factory } from "../../typechain/factories/WrapModuleV2__factory"; @@ -93,6 +97,10 @@ export default class DeployModules { return await new TradeModule__factory(this._deployerSigner).deploy(controller); } + public async deployTradeModuleV2(controller: Address): Promise { + return await new TradeModuleV2__factory(this._deployerSigner).deploy(controller); + } + public async deployWrapModule(controller: Address, weth: Address): Promise { return await new WrapModule__factory(this._deployerSigner).deploy(controller, weth); } @@ -139,6 +147,16 @@ export default class DeployModules { ); } + public async deployGeneralIndexModuleV2( + controller: Address, + weth: Address + ): Promise { + return await new GeneralIndexModuleV2__factory(this._deployerSigner).deploy( + controller, + weth + ); + } + public async deployGovernanceModule(controller: Address): Promise { return await new GovernanceModule__factory(this._deployerSigner).deploy(controller); } diff --git a/yarn.lock b/yarn.lock index 43639a979..c083e5862 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5115,10 +5115,10 @@ har-validator@~5.1.3: ajv "^6.12.3" har-schema "^2.0.0" -hardhat@^2.6.1: - version "2.6.1" - resolved "https://registry.yarnpkg.com/hardhat/-/hardhat-2.6.1.tgz#4553ca555c1ba8ed7c3c5a5a93e6082a2869b3ae" - integrity sha512-0LozdYbPsiTc6ZXsfDQUTV3L0p4CMO5TRbd5qmeWiCYGmhd+7Mvdg4N+nA8w0g3gZ2OKFUmHIYlAbExI488ceQ== +hardhat@^2.6.4: + version "2.6.5" + resolved "https://registry.yarnpkg.com/hardhat/-/hardhat-2.6.5.tgz#61d3e22da34e1b175bbe599f77396b32f9788b58" + integrity sha512-sBhREWZjQTtR/KMMp2F3ySuDqL0norjNq68geR3nlXRHXYKuNKeL7xqVsmldekt3sVB5Wh1WX7xDX79kvUr+fA== dependencies: "@ethereumjs/block" "^3.4.0" "@ethereumjs/blockchain" "^5.4.0" @@ -9158,7 +9158,7 @@ ts-generator@^0.1.1: resolve "^1.8.1" ts-essentials "^1.0.0" -ts-node@^8.10.1: +ts-node@^8.10.2: version "8.10.2" resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-8.10.2.tgz#eee03764633b1234ddd37f8db9ec10b75ec7fb8d" integrity sha512-ISJJGgkIpDdBhWVu3jufsWpK3Rzo7bdiIXJjQc0ynKxVOVcg2oIrf2H2cejminGrptVc6q6/uynAHNCuWGbpVA== @@ -9329,10 +9329,10 @@ typedarray@^0.0.6: resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= -typescript@4: - version "4.4.2" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.4.2.tgz#6d618640d430e3569a1dfb44f7d7e600ced3ee86" - integrity sha512-gzP+t5W4hdy4c+68bfcv0t400HVJMMd2+H9B7gae1nQlBzCqvrXX+6GL/b3GAgyTH966pzrZ70/fRjwAtZksSQ== +typescript@^4.4.3: + version "4.4.4" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.4.4.tgz#2cd01a1a1f160704d3101fd5a58ff0f9fcb8030c" + integrity sha512-DqGhF5IKoBl8WNf8C1gu8q0xZSInh9j1kJJMqT3a94w1JzVaBU4EXOSMrz9yDqMT0xt3selp83fuFMQ0uzv6qA== typewise-core@^1.2, typewise-core@^1.2.0: version "1.2.0"