From ddfab38bd717a3e40a53cd4543d343f53efbd391 Mon Sep 17 00:00:00 2001 From: Sachin Date: Fri, 18 Mar 2022 09:34:59 +0530 Subject: [PATCH] fix(PerpV2BasisTradingModule): bytecode size limit bug (#200) * Set allowUnlimitedContractSize to false * Fix PerpV2BasisTradingModule bytecode size limit Add hardhat-contract-sizer as dev dependency Add PerpV2Positions library Add functions to PerpV2 library Modify PerpV2LeverageModuel to use the new libraries Fix existing interfaces * Remove contractSizer config settings * Delete commented stuff * Add PerpV2Positions mock * Fix tests by linking libraries during deployment * Fix failing tests * Fix warnings * Use public getPositionNotionalInfo * Fix failing integration & viewer tests * Update to V2 for every contract; Make PositionsV2 library functions external * Link PositionV2 library and fix PerpV2PositionV2 linking * Fix failing perpV2BasisTradingModule tests * Remove unnecessary todo * Add tests for PositionV2 and ModuleBaseV2; Increase coverage * Fix javadocs; Add changelogs; Add missing test cases; Add PerpV2LeveageModuleV2<>SIM integration tests * Add changelog in PerpV2LeverageModuleV2 * Fix javadocs and hardhat config * Add tests for PerpV2LibraryV2 * Updated version to 0.1.13-basis.0 * Add PerpV2LibraryV2 tests * Add tests for PerpV2Positions library * Move formatAdjustments to PerpV2Positioons library * Remove .only and bump package * Attempt to fix existing flaky test * Fix variable name; Removing trailing whitespaces * Override initalize function * Fix coverage; Add tests for intialize(old) function --- .../interfaces/IPerpV2LeverageModule.sol | 18 +- .../external/perp-v2/IMarketRegistry.sol | 38 - contracts/mocks/PositionV2Mock.sol | 139 + .../integration/lib/PerpV2LibraryV2Mock.sol | 156 + .../integration/lib/PerpV2PositionsMock.sol | 110 + .../mocks/protocol/lib/ModuleBaseV2Mock.sol | 106 + .../PerpV2LeverageModuleViewer.sol | 7 +- contracts/protocol/integration/lib/PerpV2.sol | 6 +- .../integration/lib/PerpV2LibraryV2.sol | 347 + .../integration/lib/PerpV2Positions.sol | 208 + contracts/protocol/lib/ModuleBaseV2.sol | 237 + contracts/protocol/lib/PositionV2.sol | 262 + .../modules/PerpV2BasisTradingModule.sol | 122 +- .../protocol/modules/PerpV2LeverageModule.sol | 7 +- .../modules/PerpV2LeverageModuleV2.sol | 1132 +++ hardhat.config.ts | 10 +- package.json | 3 +- .../perpV2LeverageSlippageIssuance.spec.ts | 8 +- .../perpV2LeverageV2SlippageIssuance.spec.ts | 1337 ++++ .../perpV2LeverageModuleViewer.spec.ts | 2 +- .../integration/lib/perpV2LibraryV2.spec.ts | 608 ++ .../integration/lib/perpV2Positions.spec.ts | 427 ++ test/protocol/lib/moduleBaseV2.spec.ts | 675 ++ test/protocol/lib/positionV2.spec.ts | 797 +++ .../modules/perpV2BasisTradingModule.spec.ts | 43 +- .../modules/perpV2LeverageModule.spec.ts | 4 +- .../modules/perpV2LeverageModuleV2.spec.ts | 6062 +++++++++++++++++ utils/contracts/index.ts | 8 + utils/deploys/deployLibraries.ts | 20 +- utils/deploys/deployMocks.ts | 46 +- utils/deploys/deployModules.ts | 50 +- yarn.lock | 22 + 32 files changed, 12872 insertions(+), 145 deletions(-) create mode 100644 contracts/mocks/PositionV2Mock.sol create mode 100644 contracts/mocks/protocol/integration/lib/PerpV2LibraryV2Mock.sol create mode 100644 contracts/mocks/protocol/integration/lib/PerpV2PositionsMock.sol create mode 100644 contracts/mocks/protocol/lib/ModuleBaseV2Mock.sol create mode 100644 contracts/protocol/integration/lib/PerpV2LibraryV2.sol create mode 100644 contracts/protocol/integration/lib/PerpV2Positions.sol create mode 100644 contracts/protocol/lib/ModuleBaseV2.sol create mode 100644 contracts/protocol/lib/PositionV2.sol create mode 100644 contracts/protocol/modules/PerpV2LeverageModuleV2.sol create mode 100644 test/integration/perpV2LeverageV2SlippageIssuance.spec.ts create mode 100644 test/protocol/integration/lib/perpV2LibraryV2.spec.ts create mode 100644 test/protocol/integration/lib/perpV2Positions.spec.ts create mode 100644 test/protocol/lib/moduleBaseV2.spec.ts create mode 100644 test/protocol/lib/positionV2.spec.ts create mode 100644 test/protocol/modules/perpV2LeverageModuleV2.spec.ts diff --git a/contracts/interfaces/IPerpV2LeverageModule.sol b/contracts/interfaces/IPerpV2LeverageModule.sol index 26075975b..090ae9f97 100644 --- a/contracts/interfaces/IPerpV2LeverageModule.sol +++ b/contracts/interfaces/IPerpV2LeverageModule.sol @@ -28,7 +28,7 @@ import { IExchange } from "./external/perp-v2/IExchange.sol"; import { IVault } from "./external/perp-v2/IVault.sol"; import { IQuoter } from "./external/perp-v2/IQuoter.sol"; import { IMarketRegistry } from "./external/perp-v2/IMarketRegistry.sol"; - +import { PerpV2Positions } from "../protocol/integration/lib/PerpV2Positions.sol"; /** * @title IPerpV2LeverageModule @@ -42,18 +42,6 @@ interface IPerpV2LeverageModule { /* ============ Structs ============ */ - struct PositionNotionalInfo { - address baseToken; // Virtual token minted by the Perp protocol - int256 baseBalance; // Base position notional quantity in 10**18 decimals. When negative, position is short - int256 quoteBalance; // vUSDC "debt" notional quantity minted to open position. When positive, position is short - } - - struct PositionUnitInfo { - address baseToken; // Virtual token minted by the Perp protocol - int256 baseUnit; // Base position unit. When negative, position is short - int256 quoteUnit; // vUSDC "debt" position unit. When positive, position is short - } - // Note: when `pendingFundingPayments` is positive it will be credited to account on settlement, // when negative it's a debt owed that will be repaid on settlement. (PerpProtocol.Exchange returns the value // with the opposite meaning, e.g positively signed payments are owed by account to system). @@ -252,7 +240,7 @@ interface IPerpV2LeverageModule { * + baseBalance: baseToken balance as notional quantity (10**18) * + quoteBalance: USDC quote asset balance as notional quantity (10**18) */ - function getPositionNotionalInfo(ISetToken _setToken) external view returns (PositionNotionalInfo[] memory); + function getPositionNotionalInfo(ISetToken _setToken) external view returns (PerpV2Positions.PositionNotionalInfo[] memory); /** * @dev Returns a PositionUnitInfo array representing all positions open for the SetToken. @@ -265,7 +253,7 @@ interface IPerpV2LeverageModule { * + baseUnit: baseToken balance as position unit (10**18) * + quoteUnit: USDC quote asset balance as position unit (10**18) */ - function getPositionUnitInfo(ISetToken _setToken) external view returns (PositionUnitInfo[] memory); + function getPositionUnitInfo(ISetToken _setToken) external view returns (PerpV2Positions.PositionUnitInfo[] memory); /** * @dev Gets Perp account info for SetToken. Returns an AccountInfo struct containing account wide diff --git a/contracts/interfaces/external/perp-v2/IMarketRegistry.sol b/contracts/interfaces/external/perp-v2/IMarketRegistry.sol index 1a311f003..c553e9a51 100644 --- a/contracts/interfaces/external/perp-v2/IMarketRegistry.sol +++ b/contracts/interfaces/external/perp-v2/IMarketRegistry.sol @@ -20,53 +20,15 @@ pragma solidity 0.6.10; pragma experimental ABIEncoderV2; interface IMarketRegistry { - // - // STRUCT - // - struct MarketInfo { - address pool; - uint24 exchangeFeeRatio; - uint24 uniswapFeeRatio; - uint24 insuranceFundFeeRatio; - } - - // - // EVENT - // - event PoolAdded(address indexed baseToken, uint24 indexed feeRatio, address indexed pool); - event FeeRatioChanged(address baseToken, uint24 feeRatio); - event InsuranceFundFeeRatioChanged(uint24 feeRatio); - event MaxOrdersPerMarketChanged(uint8 maxOrdersPerMarket); - - // - // FUNCTION - // - - function addPool(address baseToken, uint24 feeRatio) external returns (address); - - function setFeeRatio(address baseToken, uint24 feeRatio) external; - - function setInsuranceFundFeeRatio(address baseToken, uint24 insuranceFundFeeRatioArg) external; - - function setMaxOrdersPerMarket(uint8 maxOrdersPerMarketArg) external; - // // EXTERNAL VIEW // function getPool(address baseToken) external view returns (address); - function getFeeRatio(address baseToken) external view returns (uint24); - - function getInsuranceFundFeeRatio(address baseToken) external view returns (uint24); - - function getMarketInfo(address baseToken) external view returns (MarketInfo memory); - function getQuoteToken() external view returns (address); function getUniswapV3Factory() external view returns (address); - function getMaxOrdersPerMarket() external view returns (uint8); - function hasPool(address baseToken) external view returns (bool); } diff --git a/contracts/mocks/PositionV2Mock.sol b/contracts/mocks/PositionV2Mock.sol new file mode 100644 index 000000000..e3b2d9d4f --- /dev/null +++ b/contracts/mocks/PositionV2Mock.sol @@ -0,0 +1,139 @@ +/* + Copyright 2020 Set Labs Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + SPDX-License-Identifier: Apache License, Version 2.0 +*/ + +pragma solidity 0.6.10; +pragma experimental "ABIEncoderV2"; + +import { ISetToken } from "../interfaces/ISetToken.sol"; +import { PositionV2 } from "../protocol/lib/PositionV2.sol"; + + +// Mock contract implementation of PositionV2 functions +contract PositionV2Mock { + constructor() + public + {} + + function initialize(ISetToken _setToken) external { + _setToken.initializeModule(); + } + + function testHasDefaultPosition(ISetToken _setToken, address _component) external view returns(bool) { + return PositionV2.hasDefaultPosition(_setToken, _component); + } + + function testHasExternalPosition(ISetToken _setToken, address _component) external view returns(bool) { + return PositionV2.hasExternalPosition(_setToken, _component); + } + function testHasSufficientDefaultUnits(ISetToken _setToken, address _component, uint256 _unit) external view returns(bool) { + return PositionV2.hasSufficientDefaultUnits(_setToken, _component, _unit); + } + function testHasSufficientExternalUnits( + ISetToken _setToken, + address _component, + address _module, + uint256 _unit + ) + external + view + returns(bool) + { + return PositionV2.hasSufficientExternalUnits(_setToken, _component, _module, _unit); + } + + function testEditDefaultPosition(ISetToken _setToken, address _component, uint256 _newUnit) external { + return PositionV2.editDefaultPosition(_setToken, _component, _newUnit); + } + + function testEditExternalPosition( + ISetToken _setToken, + address _component, + address _module, + int256 _newUnit, + bytes memory _data + ) + external + { + PositionV2.editExternalPosition(_setToken, _component, _module, _newUnit, _data); + } + + function testGetDefaultTotalNotional( + uint256 _setTokenSupply, + uint256 _positionUnit + ) + external + pure + returns (uint256) + { + return PositionV2.getDefaultTotalNotional(_setTokenSupply, _positionUnit); + } + + function testGetDefaultPositionUnit( + uint256 _setTokenSupply, + uint256 _totalNotional + ) + external + pure + returns (uint256) + { + return PositionV2.getDefaultPositionUnit(_setTokenSupply, _totalNotional); + } + + function testGetDefaultTrackedBalance(ISetToken _setToken, address _component) + external + view + returns (uint256) + { + return PositionV2.getDefaultTrackedBalance(_setToken, _component); + } + + function testCalculateAndEditDefaultPosition( + ISetToken _setToken, + address _component, + uint256 _setTotalSupply, + uint256 _componentPreviousBalance + ) + external + returns (uint256, uint256, uint256) + { + return PositionV2.calculateAndEditDefaultPosition( + _setToken, + _component, + _setTotalSupply, + _componentPreviousBalance + ); + } + + function testCalculateDefaultEditPositionUnit( + uint256 _setTokenSupply, + uint256 _preTotalNotional, + uint256 _postTotalNotional, + uint256 _prePositionUnit + ) + external + pure + returns (uint256) + { + return PositionV2.calculateDefaultEditPositionUnit( + _setTokenSupply, + _preTotalNotional, + _postTotalNotional, + _prePositionUnit + ); + } +} \ No newline at end of file diff --git a/contracts/mocks/protocol/integration/lib/PerpV2LibraryV2Mock.sol b/contracts/mocks/protocol/integration/lib/PerpV2LibraryV2Mock.sol new file mode 100644 index 000000000..2d7cc7f7a --- /dev/null +++ b/contracts/mocks/protocol/integration/lib/PerpV2LibraryV2Mock.sol @@ -0,0 +1,156 @@ +/* + 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 { IClearingHouse } from "../../../../interfaces/external/perp-v2/IClearingHouse.sol"; +import { IVault } from "../../../../interfaces/external/perp-v2/IVault.sol"; +import { IQuoter } from "../../../../interfaces/external/perp-v2/IQuoter.sol"; + +import { PerpV2LibraryV2 } from "../../../../protocol/integration/lib/PerpV2LibraryV2.sol"; +import { ISetToken } from "../../../../interfaces/ISetToken.sol"; + +/** + * @title PerpV2LibraryV2Mock + * @author Set Protocol + * + * Mock for PerpV2LibraryV2 Library contract. Used for testing PerpV2LibraryV2 Library contract, as the library + * contract can't be tested directly using ethers.js. + */ +contract PerpV2LibraryV2Mock { + + /* ============ External ============ */ + + function testGetDepositCalldata( + IVault _vault, + IERC20 _asset, + uint256 _amountNotional + ) + public + pure + returns (address, uint256, bytes memory) + { + return PerpV2LibraryV2.getDepositCalldata(_vault, _asset, _amountNotional); + } + + function testInvokeDeposit( + ISetToken _setToken, + IVault _vault, + IERC20 _asset, + uint256 _amountNotional + ) + external + { + return PerpV2LibraryV2.invokeDeposit(_setToken, _vault, _asset, _amountNotional); + } + + function testGetWithdrawCalldata( + IVault _vault, + IERC20 _asset, + uint256 _amountNotional + ) + public + pure + returns (address, uint256, bytes memory) + { + return PerpV2LibraryV2.getWithdrawCalldata(_vault, _asset, _amountNotional); + } + + function testInvokeWithdraw( + ISetToken _setToken, + IVault _vault, + IERC20 _asset, + uint256 _amountNotional + ) + external + { + return PerpV2LibraryV2.invokeWithdraw(_setToken, _vault, _asset, _amountNotional); + } + + function testGetOpenPositionCalldata( + IClearingHouse _clearingHouse, + IClearingHouse.OpenPositionParams memory _params + ) + public + pure + returns (address, uint256, bytes memory) + { + return PerpV2LibraryV2.getOpenPositionCalldata(_clearingHouse, _params); + } + + function testInvokeOpenPosition( + ISetToken _setToken, + IClearingHouse _clearingHouse, + IClearingHouse.OpenPositionParams memory _params + ) + external + returns (uint256 deltaBase, uint256 deltaQuote) + { + return PerpV2LibraryV2.invokeOpenPosition(_setToken, _clearingHouse, _params); + } + + function testGetSwapCalldata( + IQuoter _quoter, + IQuoter.SwapParams memory _params + ) + public + pure + returns (address, uint256, bytes memory) + { + return PerpV2LibraryV2.getSwapCalldata(_quoter, _params); + } + + function testInvokeSwap( + ISetToken _setToken, + IQuoter _quoter, + IQuoter.SwapParams memory _params + ) + external + returns (IQuoter.SwapResponse memory) + { + return PerpV2LibraryV2.invokeSwap(_setToken, _quoter, _params); + } + + function testSimulateTrade( + PerpV2LibraryV2.ActionInfo memory _actionInfo, + IQuoter _perpQuoter + ) + external + returns (uint256, uint256) + { + return PerpV2LibraryV2.simulateTrade(_actionInfo, _perpQuoter); + } + + function testExecuteTrade( + PerpV2LibraryV2.ActionInfo memory _actionInfo, + IClearingHouse _perpClearingHouse + ) + external + returns (uint256, uint256) + { + return PerpV2LibraryV2.executeTrade(_actionInfo, _perpClearingHouse); + } + + /* ============ Helper Functions ============ */ + + function initializeModuleOnSet(ISetToken _setToken) external { + _setToken.initializeModule(); + } +} diff --git a/contracts/mocks/protocol/integration/lib/PerpV2PositionsMock.sol b/contracts/mocks/protocol/integration/lib/PerpV2PositionsMock.sol new file mode 100644 index 000000000..6e5e86e14 --- /dev/null +++ b/contracts/mocks/protocol/integration/lib/PerpV2PositionsMock.sol @@ -0,0 +1,110 @@ +/* + Copyright 2022 Set Labs Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + SPDX-License-Identifier: Apache License, Version 2.0 +*/ + +pragma solidity 0.6.10; +pragma experimental "ABIEncoderV2"; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import { IAccountBalance } from "../../../../interfaces/external/perp-v2/IAccountBalance.sol"; +import { ISetToken } from "../../../../interfaces/ISetToken.sol"; +import { PerpV2Positions } from "../../../../protocol/integration/lib/PerpV2Positions.sol"; + +/** + * @title PerpV2PositionsMock + * @author Set Protocol + * + * Mock for PerpV2Positions Library contract. Used for testing PerpV2Positions Library contract, as the library + * contract can't be tested directly using ethers.js + */ +contract PerpV2PositionsMock { + + /* ============ External Functions ============ */ + + function testGetNetQuoteBalance( + ISetToken _setToken, + address[] memory _baseTokens, + IAccountBalance _perpAccountBalance + ) + external + view + returns (int256 netQuoteBalance) + { + return PerpV2Positions.getNetQuoteBalance( + _setToken, + _baseTokens, + _perpAccountBalance + ); + } + + function testGetPositionNotionalInfo( + ISetToken _setToken, + address[] memory _baseTokens, + IAccountBalance _perpAccountBalance + ) + public + view + returns (PerpV2Positions.PositionNotionalInfo[] memory) + { + return PerpV2Positions.getPositionNotionalInfo( + _setToken, + _baseTokens, + _perpAccountBalance + ); + } + + function testGetPositionUnitInfo( + ISetToken _setToken, + address[] memory _baseTokens, + IAccountBalance _perpAccountBalance + ) + external + view + returns (PerpV2Positions.PositionUnitInfo[] memory) + { + return PerpV2Positions.getPositionUnitInfo( + _setToken, + _baseTokens, + _perpAccountBalance + ); + } + + function testFormatAdjustments( + ISetToken _setToken, + address _adjustmentComponent, + int256 _currentExternalPositionUnit, + int256 _newExternalPositionUnit + ) + external + view + returns (int256[] memory, int256[] memory) + { + return PerpV2Positions.formatAdjustments( + _setToken, + _adjustmentComponent, + _currentExternalPositionUnit, + _newExternalPositionUnit + ); + } + + /* ============ Helper Functions ============ */ + + function initializeModuleOnSet(ISetToken _setToken) external { + _setToken.initializeModule(); + } +} \ No newline at end of file diff --git a/contracts/mocks/protocol/lib/ModuleBaseV2Mock.sol b/contracts/mocks/protocol/lib/ModuleBaseV2Mock.sol new file mode 100644 index 000000000..80a884b00 --- /dev/null +++ b/contracts/mocks/protocol/lib/ModuleBaseV2Mock.sol @@ -0,0 +1,106 @@ +/* + Copyright 2022 Set Labs Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + SPDX-License-Identifier: Apache License, Version 2.0 +*/ + +pragma solidity 0.6.10; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import { IController } from "../../../interfaces/IController.sol"; +import { ISetToken } from "../../../interfaces/ISetToken.sol"; +import { ModuleBaseV2 } from "../../../protocol/lib/ModuleBaseV2.sol"; + +contract ModuleBaseV2Mock is ModuleBaseV2 { + + bool public removed; + + constructor(IController _controller) public ModuleBaseV2(_controller) {} + + /* ============ External Functions ============ */ + + function testTransferFrom(IERC20 _token, address _from, address _to, uint256 _quantity) external { + return transferFrom(_token, _from, _to, _quantity); + } + + + function testIsSetPendingInitialization(ISetToken _setToken) external view returns(bool) { + return isSetPendingInitialization(_setToken); + } + + function testIsSetManager(ISetToken _setToken, address _toCheck) external view returns(bool) { + return isSetManager(_setToken, _toCheck); + } + + function testIsSetValidAndInitialized(ISetToken _setToken) external view returns(bool) { + return isSetValidAndInitialized(_setToken); + } + + function testOnlyManagerAndValidSet(ISetToken _setToken) + external + view + onlyManagerAndValidSet(_setToken) + {} + + function testGetAndValidateAdapter(string memory _integrationName) external view returns(address) { + return getAndValidateAdapter(_integrationName); + } + + function testGetAndValidateAdapterWithHash(bytes32 _integrationHash) external view returns(address) { + return getAndValidateAdapterWithHash(_integrationHash); + } + + function testGetModuleFee(uint256 _feeIndex, uint256 _quantity) external view returns(uint256) { + return getModuleFee(_feeIndex, _quantity); + } + + function testPayProtocolFeeFromSetToken( + ISetToken _setToken, + address _component, + uint256 _feeQuantity + ) external { + payProtocolFeeFromSetToken(_setToken, _component, _feeQuantity); + } + + function testOnlySetManager(ISetToken _setToken) + external + view + onlySetManager(_setToken, msg.sender) + {} + + function testOnlyModule(ISetToken _setToken) + external + view + onlyModule(_setToken) + {} + + + function removeModule() external override { + removed = true; + } + + function testOnlyValidAndInitializedSet(ISetToken _setToken) + external view onlyValidAndInitializedSet(_setToken) {} + + function testOnlyValidInitialization(ISetToken _setToken) + external view onlyValidAndPendingSet(_setToken) {} + + /* ============ Helper Functions ============ */ + + function initializeModuleOnSet(ISetToken _setToken) external { + _setToken.initializeModule(); + } +} \ No newline at end of file diff --git a/contracts/protocol-viewers/PerpV2LeverageModuleViewer.sol b/contracts/protocol-viewers/PerpV2LeverageModuleViewer.sol index ca08d120c..12acaae8a 100644 --- a/contracts/protocol-viewers/PerpV2LeverageModuleViewer.sol +++ b/contracts/protocol-viewers/PerpV2LeverageModuleViewer.sol @@ -30,6 +30,7 @@ import { IClearingHouseConfig } from "../interfaces/external/perp-v2/IClearingHo import { IIndexPrice } from "../interfaces/external/perp-v2/IIndexPrice.sol"; import { IPerpV2LeverageModule } from "../interfaces/IPerpV2LeverageModule.sol"; import { ISetToken } from "../interfaces/ISetToken.sol"; +import { PerpV2Positions } from "../protocol/integration/lib/PerpV2Positions.sol"; import { PreciseUnitMath } from "../lib/PreciseUnitMath.sol"; @@ -175,7 +176,7 @@ contract PerpV2LeverageModuleViewer { returns (VAssetDisplayInfo[] memory assetInfo) { uint256 setTotalSupply = _setToken.totalSupply(); - IPerpV2LeverageModule.PositionNotionalInfo[] memory positionInfo = perpModule.getPositionNotionalInfo(_setToken); + PerpV2Positions.PositionNotionalInfo[] memory positionInfo = perpModule.getPositionNotionalInfo(_setToken); int256 totalCollateralValue = _calculateTotalCollateralValue(_setToken); @@ -184,7 +185,7 @@ contract PerpV2LeverageModuleViewer { int256 vQuoteBalance; for (uint256 i = 0; i < positionsLength; i++) { - IPerpV2LeverageModule.PositionNotionalInfo memory position = positionInfo[i]; + PerpV2Positions.PositionNotionalInfo memory position = positionInfo[i]; uint256 indexPrice = IIndexPrice(position.baseToken).getIndexPrice(0); assetInfo[i] = VAssetDisplayInfo({ symbol: ERC20(position.baseToken).symbol(), @@ -236,7 +237,7 @@ contract PerpV2LeverageModuleViewer { * @return Leverage ratio of vAsset relative to current total collateral */ function _calculateCurrentLeverageRatio( - IPerpV2LeverageModule.PositionNotionalInfo memory _position, + PerpV2Positions.PositionNotionalInfo memory _position, uint256 _indexPrice, int256 _totalCollateralValue ) diff --git a/contracts/protocol/integration/lib/PerpV2.sol b/contracts/protocol/integration/lib/PerpV2.sol index 7be99a0e1..88c5ecbff 100644 --- a/contracts/protocol/integration/lib/PerpV2.sol +++ b/contracts/protocol/integration/lib/PerpV2.sol @@ -1,18 +1,14 @@ /* 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 */ @@ -268,4 +264,4 @@ library PerpV2 { bytes memory returnValue = _setToken.invoke(address(_quoter), 0, swapCalldata); return abi.decode(returnValue, (IQuoter.SwapResponse)); } -} +} \ No newline at end of file diff --git a/contracts/protocol/integration/lib/PerpV2LibraryV2.sol b/contracts/protocol/integration/lib/PerpV2LibraryV2.sol new file mode 100644 index 000000000..4bad03833 --- /dev/null +++ b/contracts/protocol/integration/lib/PerpV2LibraryV2.sol @@ -0,0 +1,347 @@ +/* + Copyright 2022 Set Labs Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + SPDX-License-Identifier: Apache License, Version 2.0 +*/ + +pragma solidity 0.6.10; +pragma experimental ABIEncoderV2; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IClearingHouse } from "../../../interfaces/external/perp-v2/IClearingHouse.sol"; +import { IQuoter } from "../../../interfaces/external/perp-v2/IQuoter.sol"; +import { IVault } from "../../../interfaces/external/perp-v2/IVault.sol"; +import { ISetToken } from "../../../interfaces/ISetToken.sol"; +import { PreciseUnitMath } from "../../../lib/PreciseUnitMath.sol"; + +/** + * @title PerpV2LibraryV2 + * @author Set Protocol + * + * Collection of helper functions for interacting with PerpV2 integrations. + * + * CHANGELOG: + * - Add ActionInfo struct. + * - Add `executeTrade` and `simulateTrade` functions. + */ +library PerpV2LibraryV2 { + + struct ActionInfo { + ISetToken setToken; + address baseToken; // Virtual token minted by the Perp protocol + bool isBuy; // When true, `baseToken` is being bought, when false, sold + uint256 baseTokenAmount; // Base token quantity in 10**18 decimals + uint256 oppositeAmountBound; // vUSDC pay or receive quantity bound + // (see `PerpV2LeverageModuleV2#_createActionInfoNotional` for details) + } + + /* ============ External ============ */ + + /** + * Gets Perp vault `deposit` calldata + * + * When invoked, calldata deposits an `_amountNotional` of collateral asset into the Perp Protocol vault + * + * @param _vault Perp protocol vault + * @param _asset Collateral asset to deposit + * @param _amountNotional Notional amount in collateral decimals to deposit + * + * @return address Vault address + * @return uint256 Call value + * @return calldata Deposit calldata + */ + function getDepositCalldata( + IVault _vault, + IERC20 _asset, + uint256 _amountNotional + ) + public + pure + returns (address, uint256, bytes memory) + { + bytes memory callData = abi.encodeWithSignature( + "deposit(address,uint256)", + _asset, + _amountNotional + ); + + return (address(_vault), 0, callData); + } + + /** + * Invoke `deposit` on Vault from SetToken + * + * Deposits an `_amountNotional` of collateral asset into the Perp Protocol vault + * + * @param _setToken Address of the SetToken + * @param _vault Address of Perp Protocol vault contract + * @param _asset The address of the collateral asset to deposit + * @param _amountNotional Notional amount in collateral decimals to deposit + */ + function invokeDeposit( + ISetToken _setToken, + IVault _vault, + IERC20 _asset, + uint256 _amountNotional + ) + external + { + ( , , bytes memory depositCalldata) = getDepositCalldata( + _vault, + _asset, + _amountNotional + ); + + _setToken.invoke(address(_vault), 0, depositCalldata); + } + + /** + * Get Perp Vault `withdraw` method calldata + * + * When invoked, calldata withdraws an `_amountNotional` of collateral asset from the Perp protocol vault + * + * @param _vault Address of the Perp Protocol vault contract + * @param _asset The address of the collateral asset to withdraw + * @param _amountNotional The notional amount in collateral decimals to be withdrawn + * + * @return address Vault contract address + * @return uint256 Call value + * @return bytes Withdraw calldata + */ + function getWithdrawCalldata( + IVault _vault, + IERC20 _asset, + uint256 _amountNotional + ) + public + pure + returns (address, uint256, bytes memory) + { + bytes memory callData = abi.encodeWithSignature( + "withdraw(address,uint256)", + _asset, + _amountNotional + ); + + return (address(_vault), 0, callData); + } + + /** + * Invoke `withdraw` on Vault from SetToken + * + * Withdraws an `_amountNotional` of collateral asset from the Perp protocol vault + * + * @param _setToken Address of the SetToken + * @param _vault Address of the Perp Protocol vault contract + * @param _asset The address of the collateral asset to withdraw + * @param _amountNotional The notional amount in collateral decimals to be withdrawn * + */ + function invokeWithdraw( + ISetToken _setToken, + IVault _vault, + IERC20 _asset, + uint256 _amountNotional + ) + external + { + ( , , bytes memory withdrawCalldata) = getWithdrawCalldata( + _vault, + _asset, + _amountNotional + ); + + _setToken.invoke(address(_vault), 0, withdrawCalldata); + } + + /** + * Get Perp ClearingHouse `openPosition` method calldata + * + * When invoked, calldata executes a trade via the Perp protocol ClearingHouse contract + * + * @param _clearingHouse Address of the Clearinghouse contract + * @param _params OpenPositionParams struct. For details see definition + * in contracts/interfaces/external/perp-v2/IClearingHouse.sol + * + * @return address ClearingHouse contract address + * @return uint256 Call value + * @return bytes `openPosition` calldata + */ + function getOpenPositionCalldata( + IClearingHouse _clearingHouse, + IClearingHouse.OpenPositionParams memory _params + ) + public + pure + returns (address, uint256, bytes memory) + { + bytes memory callData = abi.encodeWithSignature( + "openPosition((address,bool,bool,uint256,uint256,uint256,uint160,bytes32))", + _params + ); + + return (address(_clearingHouse), 0, callData); + } + + /** + * Invoke `openPosition` on ClearingHouse from SetToken + * + * Executes a trade via the Perp protocol ClearingHouse contract + * + * @param _setToken Address of the SetToken + * @param _clearingHouse Address of the Clearinghouse contract + * @param _params OpenPositionParams struct. For details see definition + * in contracts/interfaces/external/perp-v2/IClearingHouse.sol + * + * @return deltaBase Positive or negative change in base token balance resulting from trade + * @return deltaQuote Positive or negative change in quote token balance resulting from trade + */ + function invokeOpenPosition( + ISetToken _setToken, + IClearingHouse _clearingHouse, + IClearingHouse.OpenPositionParams memory _params + ) + public + returns (uint256 deltaBase, uint256 deltaQuote) + { + ( , , bytes memory openPositionCalldata) = getOpenPositionCalldata( + _clearingHouse, + _params + ); + + bytes memory returnValue = _setToken.invoke(address(_clearingHouse), 0, openPositionCalldata); + return abi.decode(returnValue, (uint256,uint256)); + } + + /** + * Get Perp Quoter `swap` method calldata + * + * When invoked, calldata simulates a trade on the Perp exchange via the Perp periphery contract Quoter + * + * @param _quoter Address of the Quoter contract + * @param _params SwapParams struct. For details see definition + * in contracts/interfaces/external/perp-v2/IQuoter.sol + * + * @return address ClearingHouse contract address + * @return uint256 Call value + * @return bytes `swap` calldata + */ + function getSwapCalldata( + IQuoter _quoter, + IQuoter.SwapParams memory _params + ) + public + pure + returns (address, uint256, bytes memory) + { + bytes memory callData = abi.encodeWithSignature( + "swap((address,bool,bool,uint256,uint160))", + _params + ); + + return (address(_quoter), 0, callData); + } + + /** + * Invoke `swap` method on Perp Quoter contract + * + * Simulates a trade on the Perp exchange via the Perp periphery contract Quoter + * + * @param _setToken Address of the SetToken + * @param _quoter Address of the Quoter contract + * @param _params SwapParams struct. For details see definition + * in contracts/interfaces/external/perp-v2/IQuoter.sol + * + * @return swapResponse Struct which includes deltaAvailableBase and deltaAvailableQuote + * properties (equiv. to deltaQuote, deltaBase) returned from `openPostion` + */ + function invokeSwap( + ISetToken _setToken, + IQuoter _quoter, + IQuoter.SwapParams memory _params + ) + public + returns (IQuoter.SwapResponse memory) + { + ( , , bytes memory swapCalldata) = getSwapCalldata( + _quoter, + _params + ); + + bytes memory returnValue = _setToken.invoke(address(_quoter), 0, swapCalldata); + return abi.decode(returnValue, (IQuoter.SwapResponse)); + } + + /** + * @dev Formats Perp Periphery Quoter.swap call and executes via SetToken. + * + * See `executeTrade` method comments for details about `isBaseToQuote` and `isExactInput` configuration. + * + * @param _perpQuoter Instance of PerpV2 quoter + * @param _actionInfo ActionInfo object + * @return uint256 The base position delta resulting from the trade + * @return uint256 The quote asset position delta resulting from the trade + */ + function simulateTrade(ActionInfo memory _actionInfo, IQuoter _perpQuoter) external returns (uint256, uint256) { + IQuoter.SwapParams memory params = IQuoter.SwapParams({ + baseToken: _actionInfo.baseToken, + isBaseToQuote: !_actionInfo.isBuy, + isExactInput: !_actionInfo.isBuy, + amount: _actionInfo.baseTokenAmount, + sqrtPriceLimitX96: 0 + }); + + IQuoter.SwapResponse memory swapResponse = invokeSwap(_actionInfo.setToken, _perpQuoter, params); + return (swapResponse.deltaAvailableBase, swapResponse.deltaAvailableQuote); + } + + /** + * @dev Formats Perp Protocol openPosition call and executes via SetToken. + * + * `isBaseToQuote`, `isExactInput` and `oppositeAmountBound` are configured as below: + * | ---------------------------------------------------|---------------------------- | + * | Action | isBuy | isB2Q | Exact In / Out | Opposite Bound Description | + * | ------- |-------- |--------|-----------------------|---------------------------- | + * | Buy | true | false | exact output (false) | Max quote to pay | + * | Sell | false | true | exact input (true) | Min quote to receive | + * |----------------------------------------------------|---------------------------- | + * + * @param _perpClearingHouse Instance of PerpV2 ClearingHouse + * @param _actionInfo PerpV2.ActionInfo object + * @return uint256 The base position delta resulting from the trade + * @return uint256 The quote asset position delta resulting from the trade + */ + function executeTrade( + ActionInfo memory _actionInfo, + IClearingHouse _perpClearingHouse + ) + external + returns (uint256, uint256) + { + // When isBaseToQuote is true, `baseToken` is being sold, when false, bought + // When isExactInput is true, `amount` is the swap input, when false, the swap output + IClearingHouse.OpenPositionParams memory params = IClearingHouse.OpenPositionParams({ + baseToken: _actionInfo.baseToken, + isBaseToQuote: !_actionInfo.isBuy, + isExactInput: !_actionInfo.isBuy, + amount: _actionInfo.baseTokenAmount, + oppositeAmountBound: _actionInfo.oppositeAmountBound, + deadline: PreciseUnitMath.maxUint256(), + sqrtPriceLimitX96: 0, + referralCode: bytes32(0) + }); + + return invokeOpenPosition(_actionInfo.setToken, _perpClearingHouse, params); + } +} diff --git a/contracts/protocol/integration/lib/PerpV2Positions.sol b/contracts/protocol/integration/lib/PerpV2Positions.sol new file mode 100644 index 000000000..decf44c44 --- /dev/null +++ b/contracts/protocol/integration/lib/PerpV2Positions.sol @@ -0,0 +1,208 @@ +/* + Copyright 2022 Set Labs Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + SPDX-License-Identifier: Apache License, Version 2.0 +*/ + +pragma solidity 0.6.10; +pragma experimental ABIEncoderV2; + +import "@openzeppelin/contracts/math/SignedSafeMath.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import { AddressArrayUtils } from "../../../lib/AddressArrayUtils.sol"; +import { IAccountBalance } from "../../../interfaces/external/perp-v2/IAccountBalance.sol"; +import { ISetToken } from "../../../interfaces/ISetToken.sol"; +import { Position } from "../../../protocol/lib/Position.sol"; +import { PreciseUnitMath } from "../../../lib/PreciseUnitMath.sol"; +import { SafeCast } from "@openzeppelin/contracts/utils/SafeCast.sol"; +import { UnitConversionUtils } from "../../../lib/UnitConversionUtils.sol"; + +/** + * @title PerpV2Positions + * @author Set Protocol + * + * Collection of PerpV2 getter functions. + */ +library PerpV2Positions { + using Position for ISetToken; + using SignedSafeMath for int256; + using SafeCast for uint256; + using PreciseUnitMath for int256; + using AddressArrayUtils for address[]; + + struct PositionNotionalInfo { + address baseToken; // Virtual token minted by the Perp protocol + int256 baseBalance; // Base position notional quantity in 10**18 decimals. When negative, position is short + int256 quoteBalance; // vUSDC "debt" notional quantity minted to open position. When positive, position is short + } + + struct PositionUnitInfo { + address baseToken; // Virtual token minted by the Perp protocol + int256 baseUnit; // Base position unit. When negative, position is short + int256 quoteUnit; // vUSDC "debt" position unit. When positive, position is short + } + + /** + * @dev Retrieves net quote balance of all open positions. + * + * @param _setToken Instance of SetToken + * @param _baseTokens PerpV2 market addresses in which SetToken has positions + * @param _perpAccountBalance Instance of PerpV2 AccountBalance + * @return netQuoteBalance Net quote balance of all open positions + */ + function getNetQuoteBalance( + ISetToken _setToken, + address[] memory _baseTokens, + IAccountBalance _perpAccountBalance + ) + external + view + returns (int256 netQuoteBalance) + { + uint256 numBaseTokens = _baseTokens.length; + for (uint256 i = 0; i < numBaseTokens; i++) { + netQuoteBalance = netQuoteBalance.add( + _perpAccountBalance.getQuote(address(_setToken), _baseTokens[i]) + ); + } + } + + /** + * @dev Returns a PositionUnitNotionalInfo array representing all positions open for the SetToken. + * + * @param _setToken Instance of SetToken + * @param _baseTokens PerpV2 market addresses in which SetToken has positions + * @param _perpAccountBalance Instance of PerpV2 AccountBalance + * + * @return PositionUnitInfo array, in which each element has properties: + * + * + baseToken: address, + * + baseBalance: baseToken balance as notional quantity (10**18) + * + quoteBalance: USDC quote asset balance as notional quantity (10**18) + */ + function getPositionNotionalInfo( + ISetToken _setToken, + address[] memory _baseTokens, + IAccountBalance _perpAccountBalance + ) + public + view + returns (PositionNotionalInfo[] memory) + { + uint256 numBaseTokens = _baseTokens.length; + PositionNotionalInfo[] memory positionInfo = new PositionNotionalInfo[](numBaseTokens); + + for(uint i = 0; i < numBaseTokens; i++){ + address baseToken = _baseTokens[i]; + positionInfo[i] = PositionNotionalInfo({ + baseToken: baseToken, + baseBalance: _perpAccountBalance.getBase( + address(_setToken), + baseToken + ), + quoteBalance: _perpAccountBalance.getQuote( + address(_setToken), + baseToken + ) + }); + } + + return positionInfo; + } + + /** + * @dev Returns a PerpV2Positions.PositionUnitInfo array representing all positions open for the SetToken. + * + * @param _setToken Instance of SetToken + * @param _baseTokens PerpV2 market addresses in which SetToken has positions + * @param _perpAccountBalance Instance of PerpV2 AccountBalance + * + * @return PerpV2Positions.PositionUnitInfo array, in which each element has properties: + * + * + baseToken: address, + * + baseUnit: baseToken balance as position unit (10**18) + * + quoteUnit: USDC quote asset balance as position unit (10**18) + */ + function getPositionUnitInfo( + ISetToken _setToken, + address[] memory _baseTokens, + IAccountBalance _perpAccountBalance + ) + external + view + returns (PositionUnitInfo[] memory) + { + int256 totalSupply = _setToken.totalSupply().toInt256(); + PositionNotionalInfo[] memory positionNotionalInfo = getPositionNotionalInfo( + _setToken, + _baseTokens, + _perpAccountBalance + ); + + uint256 positionLength = positionNotionalInfo.length; + PositionUnitInfo[] memory positionUnitInfo = new PositionUnitInfo[](positionLength); + + for(uint i = 0; i < positionLength; i++){ + PositionNotionalInfo memory currentPosition = positionNotionalInfo[i]; + positionUnitInfo[i] = PositionUnitInfo({ + baseToken: currentPosition.baseToken, + baseUnit: currentPosition.baseBalance.preciseDiv(totalSupply), + quoteUnit: currentPosition.quoteBalance.preciseDiv(totalSupply) + }); + } + + return positionUnitInfo; + } + + /** + * @dev Returns issuance or redemption adjustments in the format expected by `SlippageIssuanceModule`. + * The last recorded externalPositionUnit (current) is subtracted from a dynamically generated + * externalPositionUnit (new) and set in an `equityAdjustments` array which is the same length as + * the SetToken's components array, at the same index the collateral token occupies in the components + * array. All other values are left unset (0). An empty-value components length debtAdjustments + * array is also returned. + * + * @param _setToken Instance of the SetToken + * @param _adjustComponent Address of component token whose position unit is to be adjusted + * @param _currentExternalPositionUnit Current external position unit of `_adjustComponent` + * @param _newExternalPositionUnit New external position unit of `_adjustComponent` + * @return int256[] Components-length array with equity adjustment value at appropriate index + * @return int256[] Components-length array of zeroes (debt adjustements) + */ + function formatAdjustments( + ISetToken _setToken, + address _adjustComponent, + int256 _currentExternalPositionUnit, + int256 _newExternalPositionUnit + ) + external + view + returns (int256[] memory, int256[] memory) + { + address[] memory components = _setToken.getComponents(); + + int256[] memory equityAdjustments = new int256[](components.length); + int256[] memory debtAdjustments = new int256[](components.length); + + (uint256 index, bool isIn) = components.indexOf(_adjustComponent); + + if (isIn) { + equityAdjustments[index] = _newExternalPositionUnit.sub(_currentExternalPositionUnit); + } + + return (equityAdjustments, debtAdjustments); + } +} \ No newline at end of file diff --git a/contracts/protocol/lib/ModuleBaseV2.sol b/contracts/protocol/lib/ModuleBaseV2.sol new file mode 100644 index 000000000..31ed14c69 --- /dev/null +++ b/contracts/protocol/lib/ModuleBaseV2.sol @@ -0,0 +1,237 @@ +/* + Copyright 2022 Set Labs Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + SPDX-License-Identifier: Apache License, Version 2.0 +*/ + +pragma solidity 0.6.10; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import { AddressArrayUtils } from "../../lib/AddressArrayUtils.sol"; +import { ExplicitERC20 } from "../../lib/ExplicitERC20.sol"; +import { IController } from "../../interfaces/IController.sol"; +import { IModule } from "../../interfaces/IModule.sol"; +import { ISetToken } from "../../interfaces/ISetToken.sol"; +import { Invoke } from "./Invoke.sol"; +import { PositionV2 } from "./PositionV2.sol"; +import { PreciseUnitMath } from "../../lib/PreciseUnitMath.sol"; +import { ResourceIdentifier } from "./ResourceIdentifier.sol"; +import { SafeCast } from "@openzeppelin/contracts/utils/SafeCast.sol"; +import { SafeMath } from "@openzeppelin/contracts/math/SafeMath.sol"; +import { SignedSafeMath } from "@openzeppelin/contracts/math/SignedSafeMath.sol"; + +/** + * @title ModuleBaseV2 + * @author Set Protocol + * + * Abstract class that houses common Module-related state and functions. + * + * CHANGELOG: + * - Uses PositionV2 linkable library. + * + */ +abstract contract ModuleBaseV2 is IModule { + using AddressArrayUtils for address[]; + using Invoke for ISetToken; + using PositionV2 for ISetToken; + using PreciseUnitMath for uint256; + using ResourceIdentifier for IController; + using SafeCast for int256; + using SafeCast for uint256; + using SafeMath for uint256; + using SignedSafeMath for int256; + + /* ============ State Variables ============ */ + + // Address of the controller + IController public controller; + + /* ============ Modifiers ============ */ + + modifier onlyManagerAndValidSet(ISetToken _setToken) { + _validateOnlyManagerAndValidSet(_setToken); + _; + } + + modifier onlySetManager(ISetToken _setToken, address _caller) { + _validateOnlySetManager(_setToken, _caller); + _; + } + + modifier onlyValidAndInitializedSet(ISetToken _setToken) { + _validateOnlyValidAndInitializedSet(_setToken); + _; + } + + /** + * Throws if the sender is not a SetToken's module or module not enabled + */ + modifier onlyModule(ISetToken _setToken) { + _validateOnlyModule(_setToken); + _; + } + + /** + * Utilized during module initializations to check that the module is in pending state + * and that the SetToken is valid + */ + modifier onlyValidAndPendingSet(ISetToken _setToken) { + _validateOnlyValidAndPendingSet(_setToken); + _; + } + + /* ============ Constructor ============ */ + + /** + * Set state variables and map asset pairs to their oracles + * + * @param _controller Address of controller contract + */ + constructor(IController _controller) public { + controller = _controller; + } + + /* ============ Internal Functions ============ */ + + /** + * Transfers tokens from an address (that has set allowance on the module). + * + * @param _token The address of the ERC20 token + * @param _from The address to transfer from + * @param _to The address to transfer to + * @param _quantity The number of tokens to transfer + */ + function transferFrom(IERC20 _token, address _from, address _to, uint256 _quantity) internal { + ExplicitERC20.transferFrom(_token, _from, _to, _quantity); + } + + /** + * Gets the integration for the module with the passed in name. Validates that the address is not empty + */ + function getAndValidateAdapter(string memory _integrationName) internal view returns(address) { + bytes32 integrationHash = getNameHash(_integrationName); + return getAndValidateAdapterWithHash(integrationHash); + } + + /** + * Gets the integration for the module with the passed in hash. Validates that the address is not empty + */ + function getAndValidateAdapterWithHash(bytes32 _integrationHash) internal view returns(address) { + address adapter = controller.getIntegrationRegistry().getIntegrationAdapterWithHash( + address(this), + _integrationHash + ); + + require(adapter != address(0), "Must be valid adapter"); + return adapter; + } + + /** + * Gets the total fee for this module of the passed in index (fee % * quantity) + */ + function getModuleFee(uint256 _feeIndex, uint256 _quantity) internal view returns(uint256) { + uint256 feePercentage = controller.getModuleFee(address(this), _feeIndex); + return _quantity.preciseMul(feePercentage); + } + + /** + * Pays the _feeQuantity from the _setToken denominated in _token to the protocol fee recipient + */ + function payProtocolFeeFromSetToken(ISetToken _setToken, address _token, uint256 _feeQuantity) internal { + if (_feeQuantity > 0) { + _setToken.strictInvokeTransfer(_token, controller.feeRecipient(), _feeQuantity); + } + } + + /** + * Returns true if the module is in process of initialization on the SetToken + */ + function isSetPendingInitialization(ISetToken _setToken) internal view returns(bool) { + return _setToken.isPendingModule(address(this)); + } + + /** + * Returns true if the address is the SetToken's manager + */ + function isSetManager(ISetToken _setToken, address _toCheck) internal view returns(bool) { + return _setToken.manager() == _toCheck; + } + + /** + * Returns true if SetToken must be enabled on the controller + * and module is registered on the SetToken + */ + function isSetValidAndInitialized(ISetToken _setToken) internal view returns(bool) { + return controller.isSet(address(_setToken)) && + _setToken.isInitializedModule(address(this)); + } + + /** + * Hashes the string and returns a bytes32 value + */ + function getNameHash(string memory _name) internal pure returns(bytes32) { + return keccak256(bytes(_name)); + } + + /* ============== Modifier Helpers =============== + * Internal functions used to reduce bytecode size + */ + + /** + * Caller must SetToken manager and SetToken must be valid and initialized + */ + function _validateOnlyManagerAndValidSet(ISetToken _setToken) internal view { + require(isSetManager(_setToken, msg.sender), "Must be the SetToken manager"); + require(isSetValidAndInitialized(_setToken), "Must be a valid and initialized SetToken"); + } + + /** + * Caller must SetToken manager + */ + function _validateOnlySetManager(ISetToken _setToken, address _caller) internal view { + require(isSetManager(_setToken, _caller), "Must be the SetToken manager"); + } + + /** + * SetToken must be valid and initialized + */ + function _validateOnlyValidAndInitializedSet(ISetToken _setToken) internal view { + require(isSetValidAndInitialized(_setToken), "Must be a valid and initialized SetToken"); + } + + /** + * Caller must be initialized module and module must be enabled on the controller + */ + function _validateOnlyModule(ISetToken _setToken) internal view { + require( + _setToken.moduleStates(msg.sender) == ISetToken.ModuleState.INITIALIZED, + "Only the module can call" + ); + + require( + controller.isModule(msg.sender), + "Module must be enabled on controller" + ); + } + + /** + * SetToken must be in a pending state and module must be in pending state + */ + function _validateOnlyValidAndPendingSet(ISetToken _setToken) internal view { + require(controller.isSet(address(_setToken)), "Must be controller-enabled SetToken"); + require(isSetPendingInitialization(_setToken), "Must be pending initialization"); + } +} \ No newline at end of file diff --git a/contracts/protocol/lib/PositionV2.sol b/contracts/protocol/lib/PositionV2.sol new file mode 100644 index 000000000..65d5b9f9f --- /dev/null +++ b/contracts/protocol/lib/PositionV2.sol @@ -0,0 +1,262 @@ +/* + Copyright 2022 Set Labs Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + SPDX-License-Identifier: Apache License, Version 2.0 +*/ + +pragma solidity 0.6.10; +pragma experimental "ABIEncoderV2"; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeCast } from "@openzeppelin/contracts/utils/SafeCast.sol"; +import { SafeMath } from "@openzeppelin/contracts/math/SafeMath.sol"; +import { SignedSafeMath } from "@openzeppelin/contracts/math/SignedSafeMath.sol"; + +import { ISetToken } from "../../interfaces/ISetToken.sol"; +import { PreciseUnitMath } from "../../lib/PreciseUnitMath.sol"; + + +/** + * @title PositionV2 + * @author Set Protocol + * + * Collection of helper functions for handling and updating SetToken Positions. + * + * CHANGELOG: + * - `Position` library has all internal functions which are inlined to the module contract during compilation. + * Inlining functions increases bytecode size of the module contract. This library contains the same functions + * as `Position` library but all the functions have public/external access modifier. Thus, making this version + * linkable which helps in reducing bytecode size of the module contract. + */ +library PositionV2 { + using SafeCast for uint256; + using SafeMath for uint256; + using SafeCast for int256; + using SignedSafeMath for int256; + using PreciseUnitMath for uint256; + + /* ============ Helper ============ */ + + /** + * Returns whether the SetToken has a default position for a given component (if the real unit is > 0) + */ + function hasDefaultPosition(ISetToken _setToken, address _component) public view returns(bool) { + return _setToken.getDefaultPositionRealUnit(_component) > 0; + } + + /** + * Returns whether the SetToken has an external position for a given component (if # of position modules is > 0) + */ + function hasExternalPosition(ISetToken _setToken, address _component) public view returns(bool) { + return _setToken.getExternalPositionModules(_component).length > 0; + } + + /** + * Returns whether the SetToken component default position real unit is greater than or equal to units passed in. + */ + function hasSufficientDefaultUnits(ISetToken _setToken, address _component, uint256 _unit) external view returns(bool) { + return _setToken.getDefaultPositionRealUnit(_component) >= _unit.toInt256(); + } + + /** + * Returns whether the SetToken component external position is greater than or equal to the real units passed in. + */ + function hasSufficientExternalUnits( + ISetToken _setToken, + address _component, + address _positionModule, + uint256 _unit + ) + external + view + returns(bool) + { + return _setToken.getExternalPositionRealUnit(_component, _positionModule) >= _unit.toInt256(); + } + + /** + * If the position does not exist, create a new Position and add to the SetToken. If it already exists, + * then set the position units. If the new units is 0, remove the position. Handles adding/removing of + * components where needed (in light of potential external positions). + * + * @param _setToken Address of SetToken being modified + * @param _component Address of the component + * @param _newUnit Quantity of Position units - must be >= 0 + */ + function editDefaultPosition(ISetToken _setToken, address _component, uint256 _newUnit) public { + bool isPositionFound = hasDefaultPosition(_setToken, _component); + if (!isPositionFound && _newUnit > 0) { + // If there is no Default Position and no External Modules, then component does not exist + if (!hasExternalPosition(_setToken, _component)) { + _setToken.addComponent(_component); + } + } else if (isPositionFound && _newUnit == 0) { + // If there is a Default Position and no external positions, remove the component + if (!hasExternalPosition(_setToken, _component)) { + _setToken.removeComponent(_component); + } + } + + _setToken.editDefaultPositionUnit(_component, _newUnit.toInt256()); + } + + /** + * Update an external position and remove and external positions or components if necessary. The logic flows as follows: + * 1) If component is not already added then add component and external position. + * 2) If component is added but no existing external position using the passed module exists then add the external position. + * 3) If the existing position is being added to then just update the unit and data + * 4) If the position is being closed and no other external positions or default positions are associated with the component + * then untrack the component and remove external position. + * 5) If the position is being closed and other existing positions still exist for the component then just remove the + * external position. + * + * @param _setToken SetToken being updated + * @param _component Component position being updated + * @param _module Module external position is associated with + * @param _newUnit Position units of new external position + * @param _data Arbitrary data associated with the position + */ + function editExternalPosition( + ISetToken _setToken, + address _component, + address _module, + int256 _newUnit, + bytes memory _data + ) + external + { + if (_newUnit != 0) { + if (!_setToken.isComponent(_component)) { + _setToken.addComponent(_component); + _setToken.addExternalPositionModule(_component, _module); + } else if (!_setToken.isExternalPositionModule(_component, _module)) { + _setToken.addExternalPositionModule(_component, _module); + } + _setToken.editExternalPositionUnit(_component, _module, _newUnit); + _setToken.editExternalPositionData(_component, _module, _data); + } else { + require(_data.length == 0, "Passed data must be null"); + // If no default or external position remaining then remove component from components array + if (_setToken.getExternalPositionRealUnit(_component, _module) != 0) { + address[] memory positionModules = _setToken.getExternalPositionModules(_component); + if (_setToken.getDefaultPositionRealUnit(_component) == 0 && positionModules.length == 1) { + require(positionModules[0] == _module, "External positions must be 0 to remove component"); + _setToken.removeComponent(_component); + } + _setToken.removeExternalPositionModule(_component, _module); + } + } + } + + /** + * Get total notional amount of Default position + * + * @param _setTokenSupply Supply of SetToken in precise units (10^18) + * @param _positionUnit Quantity of Position units + * + * @return Total notional amount of units + */ + function getDefaultTotalNotional(uint256 _setTokenSupply, uint256 _positionUnit) external pure returns (uint256) { + return _setTokenSupply.preciseMul(_positionUnit); + } + + /** + * Get position unit from total notional amount + * + * @param _setTokenSupply Supply of SetToken in precise units (10^18) + * @param _totalNotional Total notional amount of component prior to + * @return Default position unit + */ + function getDefaultPositionUnit(uint256 _setTokenSupply, uint256 _totalNotional) external pure returns (uint256) { + return _totalNotional.preciseDiv(_setTokenSupply); + } + + /** + * Get the total tracked balance - total supply * position unit + * + * @param _setToken Address of the SetToken + * @param _component Address of the component + * @return Notional tracked balance + */ + function getDefaultTrackedBalance(ISetToken _setToken, address _component) external view returns(uint256) { + int256 positionUnit = _setToken.getDefaultPositionRealUnit(_component); + return _setToken.totalSupply().preciseMul(positionUnit.toUint256()); + } + + /** + * Calculates the new default position unit and performs the edit with the new unit + * + * @param _setToken Address of the SetToken + * @param _component Address of the component + * @param _setTotalSupply Current SetToken supply + * @param _componentPreviousBalance Pre-action component balance + * @return Current component balance + * @return Previous position unit + * @return New position unit + */ + function calculateAndEditDefaultPosition( + ISetToken _setToken, + address _component, + uint256 _setTotalSupply, + uint256 _componentPreviousBalance + ) + external + returns(uint256, uint256, uint256) + { + uint256 currentBalance = IERC20(_component).balanceOf(address(_setToken)); + uint256 positionUnit = _setToken.getDefaultPositionRealUnit(_component).toUint256(); + + uint256 newTokenUnit; + if (currentBalance > 0) { + newTokenUnit = calculateDefaultEditPositionUnit( + _setTotalSupply, + _componentPreviousBalance, + currentBalance, + positionUnit + ); + } else { + newTokenUnit = 0; + } + + editDefaultPosition(_setToken, _component, newTokenUnit); + + return (currentBalance, positionUnit, newTokenUnit); + } + + /** + * Calculate the new position unit given total notional values pre and post executing an action that changes SetToken state + * The intention is to make updates to the units without accidentally picking up airdropped assets as well. + * + * @param _setTokenSupply Supply of SetToken in precise units (10^18) + * @param _preTotalNotional Total notional amount of component prior to executing action + * @param _postTotalNotional Total notional amount of component after the executing action + * @param _prePositionUnit Position unit of SetToken prior to executing action + * @return New position unit + */ + function calculateDefaultEditPositionUnit( + uint256 _setTokenSupply, + uint256 _preTotalNotional, + uint256 _postTotalNotional, + uint256 _prePositionUnit + ) + public + pure + returns (uint256) + { + // If pre action total notional amount is greater then subtract post action total notional and calculate new position units + uint256 airdroppedAmount = _preTotalNotional.sub(_prePositionUnit.preciseMul(_setTokenSupply)); + return _postTotalNotional.sub(airdroppedAmount).preciseDiv(_setTokenSupply); + } +} diff --git a/contracts/protocol/modules/PerpV2BasisTradingModule.sol b/contracts/protocol/modules/PerpV2BasisTradingModule.sol index aa06171c5..96008f11e 100644 --- a/contracts/protocol/modules/PerpV2BasisTradingModule.sol +++ b/contracts/protocol/modules/PerpV2BasisTradingModule.sol @@ -27,8 +27,7 @@ import { IQuoter } from "../../interfaces/external/perp-v2/IQuoter.sol"; import { ISetToken } from "../../interfaces/ISetToken.sol"; import { IVault } from "../../interfaces/external/perp-v2/IVault.sol"; import { ModuleBase } from "../lib/ModuleBase.sol"; -import { PerpV2 } from "../integration/lib/PerpV2.sol"; -import { PerpV2LeverageModule } from "./PerpV2LeverageModule.sol"; +import { PerpV2LeverageModuleV2 } from "./PerpV2LeverageModuleV2.sol"; import { Position } from "../lib/Position.sol"; import { PreciseUnitMath } from "../../lib/PreciseUnitMath.sol"; @@ -36,14 +35,14 @@ import { PreciseUnitMath } from "../../lib/PreciseUnitMath.sol"; * @title PerpV2BasisTradingModule * @author Set Protocol * - * @notice Smart contract that extends functionality offered by PerpV2LeverageModule. It tracks funding that is settled due to - * actions on Perpetual protocol and allows it to be withdrawn by the manager. The withdrawn funding can be reinvested in the Set + * @notice Smart contract that extends functionality offered by PerpV2LeverageModuleV2. It tracks funding that is settled due to + * actions on Perpetual protocol and allows it to be withdrawn by the manager. The withdrawn funding can be reinvested in the Set * to create a yield generating basis trading product. The manager can also collect performance fees on the withdrawn funding. * * NOTE: The external position unit is only updated on an as-needed basis during issuance/redemption. It does not reflect the current * value of the Set's perpetual position. The current value can be calculated from getPositionNotionalInfo. */ -contract PerpV2BasisTradingModule is PerpV2LeverageModule { +contract PerpV2BasisTradingModule is PerpV2LeverageModuleV2 { /* ============ Structs ============ */ @@ -78,10 +77,10 @@ contract PerpV2BasisTradingModule is PerpV2LeverageModule { * @param _protocolFee Amount of performance fee accrued to protocol (USDC) */ event FundingWithdrawn( - ISetToken indexed _setToken, + ISetToken indexed _setToken, IERC20 _collateralToken, - uint256 _amountWithdrawn, - uint256 _managerFee, + uint256 _amountWithdrawn, + uint256 _managerFee, uint256 _protocolFee ); @@ -95,7 +94,7 @@ contract PerpV2BasisTradingModule is PerpV2LeverageModule { // Mapping to store fee settings for each SetToken mapping(ISetToken => FeeState) public feeSettings; - // Mapping to store funding that has been settled on Perpetual Protocol due to actions via this module + // Mapping to store funding that has been settled on Perpetual Protocol due to actions via this module // and hasn't been withdrawn for reinvesting yet. Values are stored in precise units (10e18). mapping(ISetToken => uint256) public settledFunding; @@ -119,7 +118,7 @@ contract PerpV2BasisTradingModule is PerpV2LeverageModule { uint256 _maxPerpPositionsPerSet ) public - PerpV2LeverageModule( + PerpV2LeverageModuleV2( _controller, _perpVault, _perpQuoter, @@ -131,7 +130,14 @@ contract PerpV2BasisTradingModule is PerpV2LeverageModule { /* ============ External Functions ============ */ /** - * @dev MANAGER ONLY: Initializes this module to the SetToken and sets fee settings. Either the SetToken needs to + * @dev Reverts upon calling. Use `intialize(_setToken, _settings)` instead. + */ + function initialize(ISetToken /*_setToken*/) public override(PerpV2LeverageModuleV2) { + revert("Use intialize(_setToken, _settings) instead"); + } + + /** + * @dev MANAGER ONLY: Initializes this module to the SetToken and sets fee settings. Either the SetToken needs to * be on the allowed list or anySetAllowed needs to be true. * * @param _setToken Instance of the SetToken to initialize @@ -144,20 +150,20 @@ contract PerpV2BasisTradingModule is PerpV2LeverageModule { { _validateFeeState(_settings); - // Initialize by calling PerpV2LeverageModule#initialize. + // Initialize by calling PerpV2LeverageModuleV2#initialize. // Verifies caller is manager. Verifies Set is valid, allowed and in pending state. - PerpV2LeverageModule.initialize(_setToken); + PerpV2LeverageModuleV2.initialize(_setToken); feeSettings[_setToken] = _settings; } /** - * @dev MANAGER ONLY: Similar to PerpV2LeverageModule#trade. Allows manager to buy or sell perps to change exposure + * @dev MANAGER ONLY: Similar to PerpV2LeverageModuleV2#trade. Allows manager to buy or sell perps to change exposure * to the underlying baseToken. Any pending funding that would be settled during opening a position on Perpetual - * protocol is added to (or subtracted from) `settledFunding[_setToken]` and can be withdrawn later by the + * protocol is added to (or subtracted from) `settledFunding[_setToken]` and can be withdrawn later by the * SetToken manager. * NOTE: Calling a `nonReentrant` function from another `nonReentrant` function is not supported. Hence, we can't - * add the `nonReentrant` modifier here because `PerpV2LeverageModule#trade` function has a reentrancy check. + * add the `nonReentrant` modifier here because `PerpV2LeverageModuleV2#trade` function has a reentrancy check. * NOTE: This method doesn't update the externalPositionUnit because it is a function of UniswapV3 virtual * token market prices and needs to be generated on the fly to be meaningful. * @@ -178,8 +184,8 @@ contract PerpV2BasisTradingModule is PerpV2LeverageModule { // Track funding before it is settled _updateSettledFunding(_setToken); - // Trade using PerpV2LeverageModule#trade. - PerpV2LeverageModule.trade( + // Trade using PerpV2LeverageModuleV2#trade. + PerpV2LeverageModuleV2.trade( _setToken, _baseToken, _baseQuantityUnits, @@ -208,16 +214,16 @@ contract PerpV2BasisTradingModule is PerpV2LeverageModule { { _updateSettledFunding(_setToken); - uint256 settledFunding = settledFunding[_setToken].fromPreciseUnitToDecimals(collateralDecimals); + uint256 settledFundingInCollateralDecimals = settledFunding[_setToken].fromPreciseUnitToDecimals(collateralDecimals); - if (_notionalFunding > settledFunding) { _notionalFunding = settledFunding; } + if (_notionalFunding > settledFundingInCollateralDecimals) { _notionalFunding = settledFundingInCollateralDecimals; } uint256 collateralBalanceBeforeWithdraw = collateralToken.balanceOf(address(_setToken)); _withdraw(_setToken, _notionalFunding); (uint256 managerFee, uint256 protocolFee) = _handleFees(_setToken, _notionalFunding); - + _updateWithdrawFundingState(_setToken, _notionalFunding, collateralBalanceBeforeWithdraw); emit FundingWithdrawn(_setToken, collateralToken, _notionalFunding, managerFee, protocolFee); @@ -230,13 +236,13 @@ contract PerpV2BasisTradingModule is PerpV2LeverageModule { * * NOTE: Function will revert if there is greater than a position unit amount of USDC of account value. */ - function removeModule() public override(PerpV2LeverageModule) { - // Call PerpV2LeverageModule#removeModule to delete positions mapping and unregister on other modules. + function removeModule() public override(PerpV2LeverageModuleV2) { + // Call PerpV2LeverageModuleV2#removeModule to delete positions mapping and unregister on other modules. // Verifies Set is valid and initialized. - PerpV2LeverageModule.removeModule(); + PerpV2LeverageModuleV2.removeModule(); ISetToken setToken = ISetToken(msg.sender); - + // Not charging any fees delete feeSettings[setToken]; delete settledFunding[setToken]; @@ -258,14 +264,14 @@ contract PerpV2BasisTradingModule is PerpV2LeverageModule { uint256 _setTokenQuantity ) public - override(PerpV2LeverageModule) + override(PerpV2LeverageModuleV2) { // Track funding before it is settled _updateSettledFunding(_setToken); - - // Call PerpV2LeverageModule#moduleIssueHook to set external position unit. + + // Call PerpV2LeverageModuleV2#moduleIssueHook to set external position unit. // Validates caller is module. - PerpV2LeverageModule.moduleIssueHook(_setToken, _setTokenQuantity); + PerpV2LeverageModuleV2.moduleIssueHook(_setToken, _setTokenQuantity); } /** @@ -273,8 +279,8 @@ contract PerpV2BasisTradingModule is PerpV2LeverageModule { * positions to make redemption capital withdrawable from PerpV2 vault. Sets the `externalPositionUnit` * equal to the realizable value of account in position units (as measured by the trade outcomes for * this redemption) net performance fees to be paid by the redeemer for his share of positive funding yield. - * Any `owedRealizedPnl` and pending funding payments are socialized in this step so that redeemer - * pays/receives their share of them. Should only be called ONCE during redeem. Any pending funding payment + * Any `owedRealizedPnl` and pending funding payments are socialized in this step so that redeemer + * pays/receives their share of them. Should only be called ONCE during redeem. Any pending funding payment * that would be settled during trading out of positions on Perpetual protocol is added to (or subtracted from) * `settledFunding[_setToken]` and can be withdrawn later by the manager. * @@ -286,7 +292,7 @@ contract PerpV2BasisTradingModule is PerpV2LeverageModule { uint256 _setTokenQuantity ) external - override(PerpV2LeverageModule) + override(PerpV2LeverageModuleV2) onlyModule(_setToken) { if (_setToken.totalSupply() == 0) return; @@ -296,7 +302,7 @@ contract PerpV2BasisTradingModule is PerpV2LeverageModule { _updateSettledFunding(_setToken); int256 newExternalPositionUnit = _executePositionTrades(_setToken, _setTokenQuantity, false, false); - + if (settledFunding[_setToken] > 0) { // Calculate performance fee unit // Performance fee unit = (Tracked settled funding * Performance fee) / Set total supply @@ -304,11 +310,11 @@ contract PerpV2BasisTradingModule is PerpV2LeverageModule { .preciseDiv(_setToken.totalSupply()) .preciseMulCeil(_performanceFeePercentage(_setToken)) .fromPreciseUnitToDecimals(collateralDecimals); - + // Subtract performance fee unit from calculated external position unit // Issuance module calculates equity amount to be transferred out using, // equity amount = (newExternalPositionUnit - performanceFeeUnit) * _setTokenQuantity - // where, `performanceFeeUnit * _setTokenQuantity` is share of the total performance fee to + // where, `performanceFeeUnit * _setTokenQuantity` is share of the total performance fee to // be paid by the redeemer newExternalPositionUnit = newExternalPositionUnit.sub(performanceFeeUnit.toInt256()); } @@ -337,15 +343,15 @@ contract PerpV2BasisTradingModule is PerpV2LeverageModule { onlyManagerAndValidSet(_setToken) { require(_newFee < feeSettings[_setToken].maxPerformanceFeePercentage, "Fee must be less than max"); - + // We require `settledFunding[_setToken]` to be zero. Hence, we do not call `_updateSettledFunding` here, which // eases the UX of updating performance fees for the manager. Although, manager loses the ability to collect fees // on pending funding that has been accrued on PerpV2 but not tracked on this module. - - // Assert all settled funding (in USD) has been withdrawn. Comparing USD amount allows us to neglect small + + // Assert all settled funding (in USD) has been withdrawn. Comparing USD amount allows us to neglect small // dust amounts that aren't withdrawable. require( - settledFunding[_setToken].fromPreciseUnitToDecimals(collateralDecimals) == 0, + settledFunding[_setToken].fromPreciseUnitToDecimals(collateralDecimals) == 0, "Non-zero settled funding remains" ); @@ -389,14 +395,12 @@ contract PerpV2BasisTradingModule is PerpV2LeverageModule { uint256 _setTokenQuantity ) external - override(PerpV2LeverageModule) + override(PerpV2LeverageModuleV2) returns (int256[] memory, int256[] memory _) { - address[] memory components = _setToken.getComponents(); + int256 newExternalPositionUnitNetFees = 0; if (positions[_setToken].length > 0) { - int256 newExternalPositionUnit = _executePositionTrades(_setToken, _setTokenQuantity, false, true); - // Calculate performance fee unit // Performance fee unit = (Tracked settled funding * Performance fee) / Set total supply uint256 performanceFeeUnit = _getUpdatedSettledFunding(_setToken) @@ -404,24 +408,24 @@ contract PerpV2BasisTradingModule is PerpV2LeverageModule { .preciseMulCeil(_performanceFeePercentage(_setToken)) .fromPreciseUnitToDecimals(collateralDecimals); + int256 newExternalPositionUnit = _executePositionTrades(_setToken, _setTokenQuantity, false, true); + // Subtract performance fee unit from calculated external position unit // Issuance module calculates equity amount to be transferred out using, // equity amount = (newExternalPositionUnit - performanceFeeUnit) * _setTokenQuantity - // where, `performanceFeeUnit * _setTokenQuantity` is share of the total performance fee to - // be paid by the redeemer - int256 netNewExternalPositionUnit = newExternalPositionUnit.sub(performanceFeeUnit.toInt256()); - - return _formatAdjustments(_setToken, components, netNewExternalPositionUnit); - } else { - return _formatAdjustments(_setToken, components, 0); + // where, `performanceFeeUnit * _setTokenQuantity` is share of the total performance fee to + // be paid by the redeemer + newExternalPositionUnitNetFees = newExternalPositionUnit.sub(performanceFeeUnit.toInt256()); } + + return _formatAdjustments(_setToken, newExternalPositionUnitNetFees); } /* ============ Internal Functions ============ */ /** - * @dev Updates tracked settled funding. Once funding is settled to `owedRealizedPnl` on Perpetual protocol, it is difficult to - * extract out the funding value again on-chain. This function is called in an external function and is used to track and store + * @dev Updates tracked settled funding. Once funding is settled to `owedRealizedPnl` on Perpetual protocol, it is difficult to + * extract out the funding value again on-chain. This function is called in an external function and is used to track and store * pending funding payment that is about to be settled due to subsequent logic in the external function. * * @param _setToken Instance of SetToken @@ -432,24 +436,24 @@ contract PerpV2BasisTradingModule is PerpV2LeverageModule { /** * @dev Adds pending funding payment to tracked settled funding. Returns updated settled funding value. - * + * * NOTE: Tracked settled funding value can not be less than zero, hence it is reset to zero if pending funding * payment is negative and |pending funding payment| >= |settledFunding[_setToken]|. * - * @param _setToken Instance of SetToken + * @param _setToken Instance of SetToken */ - function _getUpdatedSettledFunding(ISetToken _setToken) internal returns (uint256) { + function _getUpdatedSettledFunding(ISetToken _setToken) internal view returns (uint256) { // NOTE: pendingFundingPayments are represented as in the Perp system as "funding owed" // e.g a positive number is a debt which gets subtracted from owedRealizedPnl on settlement. // We are flipping its sign here to reflect its settlement value. int256 pendingFundingToBeSettled = perpExchange.getAllPendingFundingPayment(address(_setToken)).neg(); - + if (pendingFundingToBeSettled >= 0) { return settledFunding[_setToken].add(pendingFundingToBeSettled.toUint256()); } if (settledFunding[_setToken] > pendingFundingToBeSettled.abs()) { - return settledFunding[_setToken].sub(pendingFundingToBeSettled.abs()); + return settledFunding[_setToken].sub(pendingFundingToBeSettled.abs()); } return 0; @@ -493,7 +497,7 @@ contract PerpV2BasisTradingModule is PerpV2LeverageModule { * * @param _setToken Instance of the SetToken * @param _notionalFunding Amount of funding withdrawn (in USDC decimals) - * @param _collateralBalanceBeforeWithdraw Balance of collateral token in the Set before withdrawing more USDC from Perp + * @param _collateralBalanceBeforeWithdraw Balance of collateral token in the Set before withdrawing more USDC from Perp */ function _updateWithdrawFundingState(ISetToken _setToken, uint256 _notionalFunding, uint256 _collateralBalanceBeforeWithdraw) internal { // Update default position unit to add the withdrawn funding (in USDC) @@ -514,7 +518,7 @@ contract PerpV2BasisTradingModule is PerpV2LeverageModule { * * @param _settings FeeState struct containing performance fee settings */ - function _validateFeeState(FeeState memory _settings) internal view { + function _validateFeeState(FeeState memory _settings) internal pure { require(_settings.feeRecipient != address(0), "Fee Recipient must be non-zero address"); require(_settings.maxPerformanceFeePercentage <= PreciseUnitMath.preciseUnit(), "Max fee must be <= 100%"); require(_settings.performanceFeePercentage <= _settings.maxPerformanceFeePercentage, "Fee must be <= max"); diff --git a/contracts/protocol/modules/PerpV2LeverageModule.sol b/contracts/protocol/modules/PerpV2LeverageModule.sol index 90ad619b7..72d42d27e 100644 --- a/contracts/protocol/modules/PerpV2LeverageModule.sol +++ b/contracts/protocol/modules/PerpV2LeverageModule.sol @@ -1,18 +1,14 @@ /* 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 */ @@ -921,7 +917,6 @@ contract PerpV2LeverageModule is ModuleBase, ReentrancyGuard, Ownable, SetTokenA function _withdrawAndUpdatePositions( ISetToken _setToken, uint256 _collateralQuantityUnits - ) internal returns (uint256) @@ -1268,4 +1263,4 @@ contract PerpV2LeverageModule is ModuleBase, ReentrancyGuard, Ownable, SetTokenA return (equityAdjustments, debtAdjustments); } -} +} \ No newline at end of file diff --git a/contracts/protocol/modules/PerpV2LeverageModuleV2.sol b/contracts/protocol/modules/PerpV2LeverageModuleV2.sol new file mode 100644 index 000000000..5912949b2 --- /dev/null +++ b/contracts/protocol/modules/PerpV2LeverageModuleV2.sol @@ -0,0 +1,1132 @@ +/* + Copyright 2022 Set Labs Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + SPDX-License-Identifier: Apache License, Version 2.0 +*/ + +pragma solidity 0.6.10; +pragma experimental "ABIEncoderV2"; + +import "@openzeppelin/contracts/math/SignedSafeMath.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; +import { ReentrancyGuard } from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import { IUniswapV3Pool } from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol"; + +import { PerpV2LibraryV2 } from "../integration/lib/PerpV2LibraryV2.sol"; +import { PerpV2Positions } from "../integration/lib/PerpV2Positions.sol"; +import { UniswapV3Math } from "../integration/lib/UniswapV3Math.sol"; +import { IAccountBalance } from "../../interfaces/external/perp-v2/IAccountBalance.sol"; +import { IClearingHouse } from "../../interfaces/external/perp-v2/IClearingHouse.sol"; +import { IClearingHouseConfig } from "../../interfaces/external/perp-v2/IClearingHouseConfig.sol"; +import { IExchange } from "../../interfaces/external/perp-v2/IExchange.sol"; +import { IIndexPrice } from "../../interfaces/external/perp-v2/IIndexPrice.sol"; +import { IVault } from "../../interfaces/external/perp-v2/IVault.sol"; +import { IQuoter } from "../../interfaces/external/perp-v2/IQuoter.sol"; +import { IMarketRegistry } from "../../interfaces/external/perp-v2/IMarketRegistry.sol"; +import { IController } from "../../interfaces/IController.sol"; +import { IDebtIssuanceModule } from "../../interfaces/IDebtIssuanceModule.sol"; +import { IModuleIssuanceHookV2 } from "../../interfaces/IModuleIssuanceHookV2.sol"; +import { ISetToken } from "../../interfaces/ISetToken.sol"; +import { ModuleBaseV2 } from "../lib/ModuleBaseV2.sol"; +import { SetTokenAccessible } from "../lib/SetTokenAccessible.sol"; +import { PreciseUnitMath } from "../../lib/PreciseUnitMath.sol"; +import { AddressArrayUtils } from "../../lib/AddressArrayUtils.sol"; +import { UnitConversionUtils } from "../../lib/UnitConversionUtils.sol"; + +/** + * @title PerpV2LeverageModuleV2 + * @author Set Protocol + * @notice Smart contract that enables leveraged trading using the PerpV2 protocol. Each SetToken can only manage a single Perp account + * represented as a positive equity external position whose value is the net Perp account value denominated in the collateral token + * deposited into the Perp Protocol. This module only allows Perp positions to be collateralized by one asset, USDC, set on deployment of + * this contract (see collateralToken) however it can take positions simultaneously in multiple base assets. + * + * Upon issuance and redemption positions are not EXACTLY replicated like for other position types since a trade is necessary to enter/exit + * the position on behalf of the issuer/redeemer. Any cost of entering/exiting the position (slippage) is carried by the issuer/redeemer. + * Any pending funding costs or PnL is carried by the current token holders. To be used safely this module MUST issue using the + * SlippageIssuanceModule or else issue and redeem transaction could be sandwich attacked. + * + * NOTE: The external position unit is only updated on an as-needed basis during issuance/redemption. It does not reflect the current + * value of the Set's perpetual position. The current value can be calculated from getPositionNotionalInfo. + * + * CHANGELOG: + * - This contract has the same functionality as `PerpV2LeverageModule` but smaller bytecode size. It extends ModuleBaseV2 (which uses + * linked PositionV2 library) and uses linked PerpV2LibraryV2 and PerpV2Positions library. This separation of logic across linked library + * contracts helps us to significantly decrease the bytecode size of this contract. + */ +contract PerpV2LeverageModuleV2 is ModuleBaseV2, ReentrancyGuard, Ownable, SetTokenAccessible, IModuleIssuanceHookV2 { + using PerpV2LibraryV2 for ISetToken; + using PreciseUnitMath for int256; + using SignedSafeMath for int256; + using UnitConversionUtils for int256; + using UniswapV3Math for uint160; + using UniswapV3Math for uint256; + using UnitConversionUtils for uint256; + using AddressArrayUtils for address[]; + + /* ============ Structs ============ */ + + // Note: when `pendingFundingPayments` is positive it will be credited to account on settlement, + // when negative it's a debt owed that will be repaid on settlement. (PerpProtocol.Exchange returns the value + // with the opposite meaning, e.g positively signed payments are owed by account to system). + struct AccountInfo { + int256 collateralBalance; // Quantity of collateral deposited in Perp vault in 10**18 decimals + int256 owedRealizedPnl; // USDC quantity of profit and loss in 10**18 decimals not yet settled to vault + int256 pendingFundingPayments; // USDC quantity of pending funding payments in 10**18 decimals + int256 netQuoteBalance; // USDC quantity of net quote balance for all open positions in Perp account + } + + /* ============ Events ============ */ + + /** + * @dev Emitted on trade + * @param _setToken Instance of SetToken + * @param _baseToken Virtual token minted by the Perp protocol + * @param _deltaBase Change in baseToken position size resulting from trade + * @param _deltaQuote Change in vUSDC position size resulting from trade + * @param _protocolFee Quantity in collateral decimals sent to fee recipient during lever trade + * @param _isBuy True when baseToken is being bought, false when being sold + */ + event PerpTraded( + ISetToken indexed _setToken, + address indexed _baseToken, + uint256 indexed _deltaBase, + uint256 _deltaQuote, + uint256 _protocolFee, + bool _isBuy + ); + + /** + * @dev Emitted on deposit (not issue or redeem) + * @param _setToken Instance of SetToken + * @param _collateralToken Token being deposited as collateral (USDC) + * @param _amountDeposited Amount of collateral being deposited into Perp + */ + event CollateralDeposited( + ISetToken indexed _setToken, + IERC20 indexed _collateralToken, + uint256 indexed _amountDeposited + ); + + /** + * @dev Emitted on withdraw (not issue or redeem) + * @param _setToken Instance of SetToken + * @param _collateralToken Token being withdrawn as collateral (USDC) + * @param _amountWithdrawn Amount of collateral being withdrawn from Perp + */ + event CollateralWithdrawn( + ISetToken indexed _setToken, + IERC20 indexed _collateralToken, + uint256 indexed _amountWithdrawn + ); + + /* ============ Constants ============ */ + + // String identifying the DebtIssuanceModule in the IntegrationRegistry. Note: Governance must add DefaultIssuanceModule as + // the string as the integration name + string constant internal DEFAULT_ISSUANCE_MODULE_NAME = "DefaultIssuanceModule"; + + // 0 index stores protocol fee % on the controller, charged in the _executeTrade function + uint256 constant internal PROTOCOL_TRADE_FEE_INDEX = 0; + + /* ============ State Variables ============ */ + + // Token (USDC) used as a vault deposit, Perp currently only supports USDC as it's settlement and collateral token + IERC20 public immutable collateralToken; + + // Decimals of collateral token. We set this in the constructor for later reading + uint8 internal immutable collateralDecimals; + + // PerpV2 contract which provides getters for base, quote, and owedRealizedPnl balances + IAccountBalance public immutable perpAccountBalance; + + // PerpV2 contract which provides a trading API + IClearingHouse public immutable perpClearingHouse; + + // PerpV2 contract which manages trading logic. Provides getters for UniswapV3 pools and pending funding balances + IExchange public immutable perpExchange; + + // PerpV2 contract which handles deposits and withdrawals. Provides getter for collateral balances + IVault public immutable perpVault; + + // PerpV2 contract which makes it possible to simulate a trade before it occurs + IQuoter public immutable perpQuoter; + + // PerpV2 contract which provides a getter for baseToken UniswapV3 pools + IMarketRegistry public immutable perpMarketRegistry; + + // PerpV2 operations are very gas intensive and there is a limit on the number of positions that can be opened in a single transaction + // during issuance/redemption. `maxPerpPositionsPerSet` is a safe limit set by governance taking Optimism's block gas limit into account. + uint256 public maxPerpPositionsPerSet; + + // Mapping of SetTokens to an array of virtual token addresses the Set has open positions for. + // Array is updated when new positions are opened or old positions are zeroed out. + mapping(ISetToken => address[]) internal positions; + + /* ============ Constructor ============ */ + + /** + * @dev Sets external PerpV2 Protocol contract addresses. Sets `collateralToken` and `collateralDecimals` + * to the Perp vault's settlement token (USDC) and its decimals, respectively. + * + * @param _controller Address of controller contract + * @param _perpVault Address of Perp Vault contract + * @param _perpQuoter Address of Perp Quoter contract + * @param _perpMarketRegistry Address of Perp MarketRegistry contract + */ + constructor( + IController _controller, + IVault _perpVault, + IQuoter _perpQuoter, + IMarketRegistry _perpMarketRegistry, + uint256 _maxPerpPositionsPerSet + ) + public + ModuleBaseV2(_controller) + SetTokenAccessible(_controller) + { + // Use temp variables to initialize immutables + address tempCollateralToken = _perpVault.getSettlementToken(); + collateralToken = IERC20(tempCollateralToken); + collateralDecimals = ERC20(tempCollateralToken).decimals(); + + perpAccountBalance = IAccountBalance(_perpVault.getAccountBalance()); + perpClearingHouse = IClearingHouse(_perpVault.getClearingHouse()); + perpExchange = IExchange(_perpVault.getExchange()); + perpVault = _perpVault; + perpQuoter = _perpQuoter; + perpMarketRegistry = _perpMarketRegistry; + maxPerpPositionsPerSet = _maxPerpPositionsPerSet; + } + + /* ============ External Functions ============ */ + + /** + * @dev MANAGER ONLY: Initializes this module to the SetToken. Either the SetToken needs to be on the + * allowed list or anySetAllowed needs to be true. + * + * @param _setToken Instance of the SetToken to initialize + */ + function initialize( + ISetToken _setToken + ) + public + virtual + onlySetManager(_setToken, msg.sender) + onlyValidAndPendingSet(_setToken) + onlyAllowedSet(_setToken) + { + // Initialize module before trying register + _setToken.initializeModule(); + + // Get debt issuance module registered to this module and require that it is initialized + require(_setToken.isInitializedModule( + getAndValidateAdapter(DEFAULT_ISSUANCE_MODULE_NAME)), + "Issuance not initialized" + ); + + // Try if register exists on any of the modules including the debt issuance module + address[] memory modules = _setToken.getModules(); + for(uint256 i = 0; i < modules.length; i++) { + try IDebtIssuanceModule(modules[i]).registerToIssuanceModule(_setToken) { + // This module registered itself on `modules[i]` issuance module. + } catch { + // Try will fail if `modules[i]` is not an instance of IDebtIssuanceModule and does not + // implement the `registerToIssuanceModule` function, or if the `registerToIssuanceModule` + // function call reverted. Irrespective of the reason for failure, continue to the next module. + } + } + } + + /** + * @dev MANAGER ONLY: Allows manager to buy or sell perps to change exposure to the underlying baseToken. + * Providing a positive value for `_baseQuantityUnits` buys vToken on UniswapV3 via Perp's ClearingHouse, + * Providing a negative value sells the token. `_quoteBoundQuantityUnits` defines a min-receive-like slippage + * bound for the amount of vUSDC quote asset the trade will either pay or receive as a result of the action. + * + * NOTE: This method doesn't update the externalPositionUnit because it is a function of UniswapV3 virtual + * token market prices and needs to be generated on the fly to be meaningful. + * + * In the tables below, basePositionUnit = baseTokenBalance / setTotalSupply. + * + * As a user when levering, e.g increasing the magnitude of your position, you'd trade as below + * | ----------------------------------------------------------------------------------------------- | + * | Type | Action | Goal | `quoteBoundQuantity` | `baseQuantityUnits` | + * | ----- |-------- | ------------------------- | --------------------------- | ------------------- | + * | Long | Buy | pay least amt. of vQuote | upper bound of input quote | positive | + * | Short | Sell | get most amt. of vQuote | lower bound of output quote | negative | + * | ----------------------------------------------------------------------------------------------- | + * + * As a user when delevering by partially closing your position, you'd trade as below + * ----------------------------------------------------------------------------------------------------------------------------------- + * | Type | Action | Goal | `quoteBoundQuantity` | `baseQuantityUnits` | + * | ----- |-------- | ------------------------- | --------------------------- | ----------------------------------------------------| + * | Long | Sell | get most amt. of vQuote | upper bound of input quote | negative, |baseQuantityUnits| < |basePositionUnit| | + * | Short | Buy | pay least amt. of vQuote | lower bound of output quote | positive, |baseQuantityUnits| < |basePositionUnit| | + * ----------------------------------------------------------------------------------------------------------------------------------- + * + * As a user when completely closing a position, you'd trade as below + * ------------------------------------------------------------------------------------------------------------------------------------------- + * | Type | Action | Goal | `quoteBoundQuantity` | `baseQuantityUnits` | + * | ----- |-----------------| ------------------------- | --------------------------- | ----------------------------------------------------| + * | Long | Sell to close | get most amt. of vQuote | upper bound of input quote | negative, baseQuantityUnits = -1 * basePositionUnit | + * | Short | Buy to close | pay least amt. of vQuote | lower bound of output quote | positive, baseQuantityUnits = -1 * basePositionUnit | + * ------------------------------------------------------------------------------------------------------------------------------------------- + * + * As a user when reversing a position, e.g going from a long position to a short position in a single trade, you'd trade as below + * ------------------------------------------------------------------------------------------------------------------------------------------- + * | Type | Action | Goal | `quoteBoundQuantity` | `baseQuantityUnits` | + * | ----- |-----------------|---------------------------| --------------------------- | ----------------------------------------------------| + * | Long | Sell to reverse | get most amt. of vQuote | upper bound of input quote | negative, |baseQuantityUnits| > |basePositionUnit| | + * | Short | Buy to reverse | pay least amt. of vQuote | lower bound of output quote | positive, |baseQuantityUnits| > |basePositionUnit| | + * ------------------------------------------------------------------------------------------------------------------------------------------- + * + * @param _setToken Instance of the SetToken + * @param _baseToken Address virtual token being traded + * @param _baseQuantityUnits Quantity of virtual token to trade in position units + * @param _quoteBoundQuantityUnits Max/min of vQuote asset to pay/receive when buying or selling + */ + function trade( + ISetToken _setToken, + address _baseToken, + int256 _baseQuantityUnits, + uint256 _quoteBoundQuantityUnits + ) + public + nonReentrant + onlyManagerAndValidSet(_setToken) + { + PerpV2LibraryV2.ActionInfo memory actionInfo = _createAndValidateActionInfo( + _setToken, + _baseToken, + _baseQuantityUnits, + _quoteBoundQuantityUnits + ); + + (uint256 deltaBase, uint256 deltaQuote) = PerpV2LibraryV2.executeTrade(actionInfo, perpClearingHouse); + + uint256 protocolFee = _accrueProtocolFee(_setToken, deltaQuote); + + _updatePositionList(_setToken, _baseToken); + + emit PerpTraded( + _setToken, + _baseToken, + deltaBase, + deltaQuote, + protocolFee, + actionInfo.isBuy + ); + } + + /** + * @dev MANAGER ONLY: Deposits default position collateral token into the PerpV2 Vault, increasing + * the size of the Perp account external position. This method is useful for establishing initial + * collateralization ratios, e.g the flow when setting up a 2X external position would be to deposit + * 100 units of USDC and execute a lever trade for ~200 vUSDC worth of vToken with the difference + * between these made up as automatically "issued" margin debt in the PerpV2 system. + * + * @param _setToken Instance of the SetToken + * @param _collateralQuantityUnits Quantity of collateral to deposit in position units + */ + function deposit( + ISetToken _setToken, + uint256 _collateralQuantityUnits + ) + public + nonReentrant + onlyManagerAndValidSet(_setToken) + { + require(_collateralQuantityUnits > 0, "Deposit amount is 0"); + + uint256 notionalDepositedQuantity = _depositAndUpdatePositions(_setToken, _collateralQuantityUnits); + + emit CollateralDeposited(_setToken, collateralToken, notionalDepositedQuantity); + } + + /** + * @dev MANAGER ONLY: Withdraws collateral token from the PerpV2 Vault to a default position on + * the SetToken. This method is useful when adjusting the overall composition of a Set which has + * a Perp account external position as one of several components. + * + * NOTE: Within PerpV2, `withdraw` settles `owedRealizedPnl` and any pending funding payments + * to the Perp vault prior to transfer. + * + * @param _setToken Instance of the SetToken + * @param _collateralQuantityUnits Quantity of collateral to withdraw in position units + */ + function withdraw( + ISetToken _setToken, + uint256 _collateralQuantityUnits + ) + public + nonReentrant + onlyManagerAndValidSet(_setToken) + { + require(_collateralQuantityUnits > 0, "Withdraw amount is 0"); + + uint256 notionalWithdrawnQuantity = _withdrawAndUpdatePositions(_setToken, _collateralQuantityUnits); + + emit CollateralWithdrawn(_setToken, collateralToken, notionalWithdrawnQuantity); + } + + /** + * @dev MANAGER ONLY: Removes this module from the SetToken, via call by the SetToken. Deletes + * position mappings associated with SetToken. + * + * NOTE: Function will revert if there is greater than a position unit amount of USDC of account value. + */ + function removeModule() public virtual override onlyValidAndInitializedSet(ISetToken(msg.sender)) { + ISetToken setToken = ISetToken(msg.sender); + + // Check that there is less than 1 position unit of USDC of account value (to tolerate PRECISE_UNIT math rounding errors). + // Account value is checked here because liquidation may result in a positive vault balance while net value is below zero. + int256 accountValueUnit = perpClearingHouse.getAccountValue(address(setToken)).preciseDiv(setToken.totalSupply().toInt256()); + require( + accountValueUnit.fromPreciseUnitToDecimals(collateralDecimals) <= 1, + "Account balance exists" + ); + + // `positions[setToken]` mapping stores an array of addresses. The base token addresses are removed from the array when the + // corresponding base token positions are zeroed out. Since no positions exist when removing the module, the stored array should + // already be empty, and the mapping can be deleted directly. + delete positions[setToken]; + + // Try if unregister exists on any of the modules + address[] memory modules = setToken.getModules(); + for(uint256 i = 0; i < modules.length; i++) { + try IDebtIssuanceModule(modules[i]).unregisterFromIssuanceModule(setToken) {} catch {} + } + } + + /** + * @dev MANAGER ONLY: Add registration of this module on the debt issuance module for the SetToken. + * + * Note: if the debt issuance module is not added to SetToken before this module is initialized, then + * this function needs to be called if the debt issuance module is later added and initialized to prevent state + * inconsistencies + * + * @param _setToken Instance of the SetToken + * @param _debtIssuanceModule Debt issuance module address to register + */ + function registerToModule(ISetToken _setToken, IDebtIssuanceModule _debtIssuanceModule) external onlyManagerAndValidSet(_setToken) { + require(_setToken.isInitializedModule(address(_debtIssuanceModule)), "Issuance not initialized"); + + _debtIssuanceModule.registerToIssuanceModule(_setToken); + } + + /** + * @dev MODULE ONLY: Hook called prior to issuance. Only callable by valid module. Should only be called ONCE + * during issue. Trades into current positions and sets the collateralToken's externalPositionUnit so that + * issuance module can transfer in the right amount of collateral accounting for accrued fees/pnl and slippage + * incurred during issuance. Any pending funding payments and accrued owedRealizedPnl are attributed to current + * Set holders. + * + * @param _setToken Instance of the SetToken + * @param _setTokenQuantity Quantity of Set to issue + */ + function moduleIssueHook( + ISetToken _setToken, + uint256 _setTokenQuantity + ) + public + virtual + override + onlyModule(_setToken) + { + if (_setToken.totalSupply() == 0) return; + if (!_setToken.hasExternalPosition(address(collateralToken))) return; + + int256 newExternalPositionUnit = _executePositionTrades(_setToken, _setTokenQuantity, true, false); + + // Set collateralToken externalPositionUnit such that DIM can use it for transfer calculation + _setToken.editExternalPositionUnit( + address(collateralToken), + address(this), + newExternalPositionUnit + ); + } + + /** + * @dev MODULE ONLY: Hook called prior to redemption in the issuance module. Trades out of existing + * positions to make redemption capital withdrawable from PerpV2 vault. Sets the `externalPositionUnit` + * equal to the realizable value of account in position units (as measured by the trade outcomes for + * this redemption). Any `owedRealizedPnl` and pending funding payments are socialized in this step so + * that redeemer pays/receives their share of them. Should only be called ONCE during redeem. + * + * @param _setToken Instance of the SetToken + * @param _setTokenQuantity Quantity of SetToken to redeem + */ + function moduleRedeemHook( + ISetToken _setToken, + uint256 _setTokenQuantity + ) + external + virtual + override + onlyModule(_setToken) + { + if (_setToken.totalSupply() == 0) return; + if (!_setToken.hasExternalPosition(address(collateralToken))) return; + + int256 newExternalPositionUnit = _executePositionTrades(_setToken, _setTokenQuantity, false, false); + + // Set USDC externalPositionUnit such that DIM can use it for transfer calculation + _setToken.editExternalPositionUnit( + address(collateralToken), + address(this), + newExternalPositionUnit + ); + } + + /** + * @dev MODULE ONLY: Hook called prior to looping through each component on issuance. Deposits + * collateral into Perp protocol from SetToken default position. + * @param _setToken Instance of the SetToken + * @param _setTokenQuantity Quantity of SetToken to issue + * @param _component Address of deposit collateral component + * @param _isEquity True if componentHook called from issuance module for equity flow, false otherwise + */ + function componentIssueHook( + ISetToken _setToken, + uint256 _setTokenQuantity, + IERC20 _component, + bool _isEquity + ) + external + override + onlyModule(_setToken) + { + if (_isEquity) { + int256 externalPositionUnit = _setToken.getExternalPositionRealUnit(address(_component), address(this)); + + // Use preciseMulCeil here to ensure correct collateralization if there are rounding errors. + uint256 usdcTransferInNotionalQuantity = _setTokenQuantity.preciseMulCeil(externalPositionUnit.toUint256()); + + _deposit(_setToken, usdcTransferInNotionalQuantity); + } + } + + /** + * @dev MODULE ONLY: Hook called prior to looping through each component on redemption. Withdraws + * collateral from Perp protocol to SetToken default position *without* updating the default position unit. + * Called by issuance module's `resolveEquityPositions` method which immediately transfers the collateral + * component from SetToken to redeemer after this hook executes. + * + * @param _setToken Instance of the SetToken + * @param _setTokenQuantity Quantity of SetToken to redeem + * @param _component Address of deposit collateral component + * @param _isEquity True if componentHook called from issuance module for equity flow, false otherwise + */ + function componentRedeemHook( + ISetToken _setToken, + uint256 _setTokenQuantity, + IERC20 _component, + bool _isEquity + ) + external + override + onlyModule(_setToken) + { + if (_isEquity) { + int256 externalPositionUnit = _setToken.getExternalPositionRealUnit(address(_component), address(this)); + uint256 usdcTransferOutNotionalQuantity = _setTokenQuantity.preciseMul(externalPositionUnit.toUint256()); + + _withdraw(_setToken, usdcTransferOutNotionalQuantity); + } + } + + /* ============ External Setter Functions ============ */ + + /** + * @dev GOVERNANCE ONLY: Update max perpetual positions per SetToken. Only callable by governance. + * + * @param _maxPerpPositionsPerSet New max perpetual positons per set + */ + function updateMaxPerpPositionsPerSet(uint256 _maxPerpPositionsPerSet) external onlyOwner { + maxPerpPositionsPerSet = _maxPerpPositionsPerSet; + } + + /* ============ External Getter Functions ============ */ + + /** + * @dev Gets the positive equity collateral externalPositionUnit that would be calculated for + * issuing a quantity of SetToken, representing the amount of collateral that would need to + * be transferred in per SetToken. Values in the returned arrays map to the same index in the + * SetToken's components array + * + * @param _setToken Instance of SetToken + * @param _setTokenQuantity Number of sets to issue + * + * @return equityAdjustments array containing a single element and an empty debtAdjustments array + */ + function getIssuanceAdjustments( + ISetToken _setToken, + uint256 _setTokenQuantity + ) + external + override + returns (int256[] memory, int256[] memory) + { + int256 newExternalPositionUnit = positions[_setToken].length > 0 + ? _executePositionTrades(_setToken, _setTokenQuantity, true, true) + : 0; + + return _formatAdjustments(_setToken, newExternalPositionUnit); + } + + /** + * @dev Gets the positive equity collateral externalPositionUnit that would be calculated for + * redeeming a quantity of SetToken representing the amount of collateral returned per SetToken. + * Values in the returned arrays map to the same index in the SetToken's components array. + * + * @param _setToken Instance of SetToken + * @param _setTokenQuantity Number of sets to issue + * + * @return equityAdjustments array containing a single element and an empty debtAdjustments array + */ + function getRedemptionAdjustments( + ISetToken _setToken, + uint256 _setTokenQuantity + ) + external + virtual + override + returns (int256[] memory, int256[] memory _) + { + int256 newExternalPositionUnit = positions[_setToken].length > 0 + ? _executePositionTrades(_setToken, _setTokenQuantity, false, true) + : 0; + + return _formatAdjustments(_setToken, newExternalPositionUnit); + } + + /** + * @dev Returns a PositionUnitNotionalInfo array representing all positions open for the SetToken. + * + * @param _setToken Instance of SetToken + * + * @return PositionUnitInfo array, in which each element has properties: + * + * + baseToken: address, + * + baseBalance: baseToken balance as notional quantity (10**18) + * + quoteBalance: USDC quote asset balance as notional quantity (10**18) + */ + function getPositionNotionalInfo(ISetToken _setToken) public view returns (PerpV2Positions.PositionNotionalInfo[] memory) { + return PerpV2Positions.getPositionNotionalInfo(_setToken, positions[_setToken], perpAccountBalance); + } + + /** + * @dev Returns a PositionUnitInfo array representing all positions open for the SetToken. + * + * @param _setToken Instance of SetToken + * + * @return PositionUnitInfo array, in which each element has properties: + * + * + baseToken: address, + * + baseUnit: baseToken balance as position unit (10**18) + * + quoteUnit: USDC quote asset balance as position unit (10**18) + */ + function getPositionUnitInfo(ISetToken _setToken) external view returns (PerpV2Positions.PositionUnitInfo[] memory) { + return PerpV2Positions.getPositionUnitInfo(_setToken, positions[_setToken], perpAccountBalance); + } + + /** + * @dev Gets Perp account info for SetToken. Returns an AccountInfo struct containing account wide + * (rather than position specific) balance info + * + * @param _setToken Instance of the SetToken + * + * @return accountInfo struct with properties for: + * + * + collateral balance (10**18, regardless of underlying collateral decimals) + * + owed realized Pnl` (10**18) + * + pending funding payments (10**18) + * + net quote balance (10**18) + */ + function getAccountInfo(ISetToken _setToken) public view returns (AccountInfo memory accountInfo) { + (int256 owedRealizedPnl,, ) = perpAccountBalance.getPnlAndPendingFee(address(_setToken)); + + // NOTE: pendingFundingPayments are represented as in the Perp system as "funding owed" + // e.g a positive number is a debt which gets subtracted from owedRealizedPnl on settlement. + // We are flipping its sign here to reflect its settlement value. + accountInfo = AccountInfo({ + collateralBalance: perpVault.getBalance(address(_setToken)).toPreciseUnitsFromDecimals(collateralDecimals), + owedRealizedPnl: owedRealizedPnl, + pendingFundingPayments: perpExchange.getAllPendingFundingPayment(address(_setToken)).neg(), + netQuoteBalance: PerpV2Positions.getNetQuoteBalance(_setToken, positions[_setToken], perpAccountBalance) + }); + } + + /* ============ Internal Functions ============ */ + + /** + * @dev MODULE ONLY: Hook called prior to issuance or redemption. Only callable by valid module. + * This method implements the core logic to replicate positions during issuance and redemption. Syncs + * the `positions` list before starting (because positions may have liquidated). Cycles through + * each position, trading `basePositionUnit * issueOrRedeemQuantity` and calculates the amount of + * USDC to transfer in/out for exchange, ensuring that issuer/redeemer pays slippage and that any + * pending payments like funding or owedRealizedPnl are socialized among existing Set holders + * appropriately. The hook which invokes this method sets the SetToken's externalPositionUnit using + * the positionUnit value returned here. Subsequent transfers in/out are managed by the issuance module + * which reads this value. + * + * The general formula for determining `accountValue` per Set is: + * + * `accountValue = collateral <--- + * + owedRealizedPnl } totalCollateralValue + * + pendingFundingPayment <--- + * + netQuoteBalance neg. when long, pos. when short + * +/- sum( |deltaQuoteResultingFromTrade| ) add when long, subtract when short + * + * (See docs for `_calculatePartialAccountValuePositionUnit` below for more detail about the + * account value components). + * + * NOTE: On issuance, this hook is run *BEFORE* USDC is transferred in and deposited to the Perp + * vault to pay for the issuer's Sets. This trading temporarily spikes the Perp account's + * margin ratio (capped at ~9X) and limits the amount of Set that can issued at once to + * a multiple of the current Perp account value (will vary depending on Set's leverage ratio). + * + * @param _setToken Instance of the SetToken + * @param _setTokenQuantity Quantity of Set to issue + * @param _isIssue If true, invocation is for issuance, redemption otherwise + * @param _isSimulation If true, trading is only simulated (to return issuance adjustments) + * @return int256 Amount of collateral to transfer in/out in position units + */ + function _executePositionTrades( + ISetToken _setToken, + uint256 _setTokenQuantity, + bool _isIssue, + bool _isSimulation + ) + internal + returns (int256) + { + _syncPositionList(_setToken); + int256 setTokenQuantityInt = _setTokenQuantity.toInt256(); + + // Note: `issued` naming convention used here for brevity. This logic is also run on redemption + // and variable may refer to the value which will be redeemed. + int256 accountValueIssued = _calculatePartialAccountValuePositionUnit(_setToken).preciseMul(setTokenQuantityInt); + + PerpV2Positions.PositionNotionalInfo[] memory positionInfo = getPositionNotionalInfo(_setToken); + uint256 positionLength = positionInfo.length; + int256 totalSupply = _setToken.totalSupply().toInt256(); + + for(uint i = 0; i < positionLength; i++) { + int256 baseTradeNotionalQuantity = positionInfo[i].baseBalance.preciseDiv(totalSupply).preciseMul(setTokenQuantityInt); + + // When redeeming, we flip the sign of baseTradeNotionalQuantity because we are reducing the size of the position, + // e.g selling base when long, buying base when short + PerpV2LibraryV2.ActionInfo memory actionInfo = _createActionInfoNotional( + _setToken, + positionInfo[i].baseToken, + _isIssue ? baseTradeNotionalQuantity : baseTradeNotionalQuantity.neg(), + 0 + ); + + // Execute or simulate trade. + // `deltaQuote` is always a positive number + (, uint256 deltaQuote) = _isSimulation + ? PerpV2LibraryV2.simulateTrade(actionInfo, perpQuoter) + : PerpV2LibraryV2.executeTrade(actionInfo, perpClearingHouse); + + // slippage is borne by the issuer + accountValueIssued = baseTradeNotionalQuantity >= 0 ? accountValueIssued.add(deltaQuote.toInt256()) : + accountValueIssued.sub(deltaQuote.toInt256()); + } + + // After trading, verify that accountValueIssued is not negative. In some post-liquidation states the + // account could be bankrupt and we represent that as zero. + if (accountValueIssued <= 0) { + return 0; + } + + // Return value in collateral decimals (e.g USDC = 6) + // Use preciseDivCeil when issuing to ensure we don't under-collateralize due to rounding error + return (_isIssue) + ? accountValueIssued.preciseDivCeil(setTokenQuantityInt).fromPreciseUnitToDecimals(collateralDecimals) + : accountValueIssued.preciseDiv(setTokenQuantityInt).fromPreciseUnitToDecimals(collateralDecimals); + } + + /** + * Calculates the "partial account value" position unit. This is the sum of the vault collateral balance, + * the net quote balance for all positions, and any pending funding or owed realized Pnl balances, + * as a position unit. It forms the base to which traded position values are added during issuance or redemption, + * and to which existing position values are added when calculating the externalPositionUnit. + * + * @param _setToken Instance of the SetToken + * @return accountValue Partial account value in position units + */ + function _calculatePartialAccountValuePositionUnit(ISetToken _setToken) internal view returns (int256 accountValue) { + AccountInfo memory accountInfo = getAccountInfo(_setToken); + + accountValue = accountInfo.collateralBalance + .add(accountInfo.owedRealizedPnl) + .add(accountInfo.pendingFundingPayments) + .add(accountInfo.netQuoteBalance) + .preciseDiv(_setToken.totalSupply().toInt256()); + } + + /** + * @dev Invoke deposit from SetToken using PerpV2 library. Creates a collateral deposit in Perp vault + * Updates the collateral token default position unit. This function is called directly by + * the componentIssue hook, skipping external position unit setting because that method is assumed + * to be the end of a call sequence (e.g manager will not need to read the updated value) + * + * @param _setToken Instance of SetToken + * @param _collateralNotionalQuantity Notional collateral quantity to deposit + */ + function _deposit(ISetToken _setToken, uint256 _collateralNotionalQuantity) internal { + _setToken.invokeApprove( + address(collateralToken), + address(perpVault), + _collateralNotionalQuantity + ); + + _setToken.invokeDeposit(perpVault, collateralToken, _collateralNotionalQuantity); + } + + /** + * Approves and deposits collateral units into Perp vault and additionally sets collateral token externalPositionUnit + * so Manager contracts have a value they can base calculations for further trading on within the same transaction. + * + * NOTE: This flow is only used when invoking the external `deposit` function - it converts collateral + * quantity units into a notional quantity. + * + * @param _setToken Instance of SetToken + * @param _collateralQuantityUnits Collateral quantity in position units to deposit + * @return uint256 Notional quantity deposited + */ + function _depositAndUpdatePositions( + ISetToken _setToken, + uint256 _collateralQuantityUnits + ) + internal + returns (uint256) + { + uint256 initialCollateralPositionBalance = collateralToken.balanceOf(address(_setToken)); + uint256 collateralNotionalQuantity = _collateralQuantityUnits.preciseMul(_setToken.totalSupply()); + + _deposit(_setToken, collateralNotionalQuantity); + + _setToken.calculateAndEditDefaultPosition( + address(collateralToken), + _setToken.totalSupply(), + initialCollateralPositionBalance + ); + + _setToken.editExternalPosition( + address(collateralToken), + address(this), + _calculateExternalPositionUnit(_setToken), + "" + ); + + return collateralNotionalQuantity; + } + + /** + * @dev Invoke withdraw from SetToken using PerpV2 library. Withdraws collateral token from Perp vault + * into a default position. This function is called directly by _accrueFee and _moduleRedeemHook, + * skipping position unit state updates because the funds withdrawn to SetToken are immediately + * forwarded to `feeRecipient` and SetToken owner respectively. + * + * @param _setToken Instance of SetToken + * @param _collateralNotionalQuantity Notional collateral quantity to withdraw + */ + function _withdraw(ISetToken _setToken, uint256 _collateralNotionalQuantity) internal { + if (_collateralNotionalQuantity == 0) return; + + _setToken.invokeWithdraw(perpVault, collateralToken, _collateralNotionalQuantity); + } + + /** + * Withdraws collateral units from Perp vault to SetToken and additionally sets both the collateralToken + * externalPositionUnit (so Manager contracts have a value they can base calculations for further + * trading on within the same transaction), and the collateral token default position unit. + * + * NOTE: This flow is only used when invoking the external `withdraw` function - it converts + * a collateral units quantity into a notional quantity before invoking withdraw. + * + * @param _setToken Instance of SetToken + * @param _collateralQuantityUnits Collateral quantity in position units to withdraw + * @return uint256 Notional quantity withdrawn + */ + function _withdrawAndUpdatePositions( + ISetToken _setToken, + uint256 _collateralQuantityUnits + ) + internal + returns (uint256) + { + uint256 initialCollateralPositionBalance = collateralToken.balanceOf(address(_setToken)); + uint256 collateralNotionalQuantity = _collateralQuantityUnits.preciseMul(_setToken.totalSupply()); + + _withdraw(_setToken, collateralNotionalQuantity); + + _setToken.calculateAndEditDefaultPosition( + address(collateralToken), + _setToken.totalSupply(), + initialCollateralPositionBalance + ); + + _setToken.editExternalPosition( + address(collateralToken), + address(this), + _calculateExternalPositionUnit(_setToken), + "" + ); + + return collateralNotionalQuantity; + } + + + /** + * @dev Calculates protocol fee on module and pays protocol fee from SetToken + * + * @param _setToken Instance of SetToken + * @param _exchangedQuantity Notional quantity of USDC exchanged in trade (e.g deltaQuote) + * @return uint256 Total protocol fee paid in underlying collateral decimals e.g (USDC = 6) + */ + function _accrueProtocolFee( + ISetToken _setToken, + uint256 _exchangedQuantity + ) + internal + returns(uint256) + { + uint256 protocolFee = getModuleFee(PROTOCOL_TRADE_FEE_INDEX, _exchangedQuantity); + uint256 protocolFeeInPreciseUnits = protocolFee.fromPreciseUnitToDecimals(collateralDecimals); + + _withdraw(_setToken, protocolFeeInPreciseUnits); + + payProtocolFeeFromSetToken(_setToken, address(collateralToken), protocolFeeInPreciseUnits); + + return protocolFeeInPreciseUnits; + } + + /** + * @dev Construct the PerpV2LibraryV2.ActionInfo struct for trading. This method takes POSITION UNIT amounts and passes to + * _createActionInfoNotional to create the struct. If the _baseTokenQuantity is zero then revert. If + * the _baseTokenQuantity = -(baseBalance/setSupply) then close the position entirely. This method is + * only called from `trade` - the issue/redeem flow uses createActionInfoNotional directly. + * + * @param _setToken Instance of the SetToken + * @param _baseToken Address of base token being traded into/out of + * @param _baseQuantityUnits Quantity of baseToken to trade in PositionUnits + * @param _quoteReceiveUnits Quantity of quote to receive if selling base and pay if buying, in PositionUnits + * + * @return PerpV2LibraryV2.ActionInfo Instance of constructed PerpV2LibraryV2.ActionInfo struct + */ + function _createAndValidateActionInfo( + ISetToken _setToken, + address _baseToken, + int256 _baseQuantityUnits, + uint256 _quoteReceiveUnits + ) + internal + view + returns(PerpV2LibraryV2.ActionInfo memory) + { + require(_baseQuantityUnits != 0, "Amount is 0"); + require(perpMarketRegistry.hasPool(_baseToken), "Base token does not exist"); + + uint256 totalSupply = _setToken.totalSupply(); + + int256 baseBalance = perpAccountBalance.getBase(address(_setToken), _baseToken); + int256 basePositionUnit = baseBalance.preciseDiv(totalSupply.toInt256()); + + int256 baseNotional = _baseQuantityUnits == basePositionUnit.neg() + ? baseBalance.neg() // To close position completely + : _baseQuantityUnits.preciseMul(totalSupply.toInt256()); + + return _createActionInfoNotional( + _setToken, + _baseToken, + baseNotional, + _quoteReceiveUnits.preciseMul(totalSupply) + ); + } + + /** + * @dev Construct the PerpV2LibraryV2.ActionInfo struct for trading. This method takes NOTIONAL token amounts and creates + * the struct. If the _baseTokenQuantity is greater than zero then we are buying the baseToken. This method + * is called during issue and redeem via `_executePositionTrades` and during trade via `_createAndValidateActionInfo`. + * + * (See _executeTrade method comments for details about `oppositeAmountBound` configuration) + * + * @param _setToken Instance of the SetToken + * @param _baseToken Address of base token being traded into/out of + * @param _baseTokenQuantity Notional quantity of baseToken to trade + * @param _quoteReceiveQuantity Notional quantity of quote to receive if selling base and pay if buying + * + * @return PerpV2LibraryV2.ActionInfo Instance of constructed PerpV2LibraryV2.ActionInfo struct + */ + function _createActionInfoNotional( + ISetToken _setToken, + address _baseToken, + int256 _baseTokenQuantity, + uint256 _quoteReceiveQuantity + ) + internal + pure + returns(PerpV2LibraryV2.ActionInfo memory) + { + // NOT checking that _baseTokenQuantity != 0 here because for places this is directly called + // (issue/redeem hooks) we know the position cannot be 0. We check in _createAndValidateActionInfo + // that quantity is 0 for inputs to trade. + bool isBuy = _baseTokenQuantity > 0; + + return PerpV2LibraryV2.ActionInfo({ + setToken: _setToken, + baseToken: _baseToken, + isBuy: isBuy, + baseTokenAmount: _baseTokenQuantity.abs(), + oppositeAmountBound: _quoteReceiveQuantity + }); + } + + /** + * @dev Update position address array if a token has been newly added or completely sold off + * during lever/delever + * + * @param _setToken Instance of SetToken + * @param _baseToken Address of virtual base token + */ + function _updatePositionList(ISetToken _setToken, address _baseToken) internal { + address[] memory positionList = positions[_setToken]; + bool hasBaseToken = positionList.contains(_baseToken); + + if (hasBaseToken) { + if(!_hasBaseBalance(_setToken, _baseToken)) { + positions[_setToken].removeStorage(_baseToken); + } + } else { + require(positions[_setToken].length < maxPerpPositionsPerSet, "Exceeds max perpetual positions per set"); + positions[_setToken].push(_baseToken); + } + } + + /** + * @dev Removes any zero balance positions from the positions array. This + * sync is done before issuance and redemption to account for positions that may have + * been liquidated. + * + * @param _setToken Instance of the SetToken + */ + function _syncPositionList(ISetToken _setToken) internal { + address[] memory positionList = positions[_setToken]; + uint256 positionLength = positionList.length; + + for (uint256 i = 0; i < positionLength; i++) { + address currPosition = positionList[i]; + if (!_hasBaseBalance(_setToken, currPosition)) { + positions[_setToken].removeStorage(currPosition); + } + } + } + + /** + * @dev Checks to see if we can make 1 positionUnit worth of a baseToken position, if not we consider the Set to have + * no balance and return false + * + * @param _setToken Instance of SetToken + * @param _baseToken Address of virtual base token + * @return bool True if a non-dust base token balance exists, false otherwise + */ + function _hasBaseBalance(ISetToken _setToken, address _baseToken) internal view returns(bool) { + int256 baseBalanceUnit = perpAccountBalance + .getBase(address(_setToken), _baseToken) + .preciseDiv(_setToken.totalSupply().toInt256()); + + return (baseBalanceUnit > 1) || (baseBalanceUnit < -1); + } + + /** + * @dev Gets the mid-point price of a virtual asset from UniswapV3 markets maintained by Perp Protocol + * + * @param _baseToken Address of virtual token to price + * @return price Mid-point price of virtual token in UniswapV3 AMM market + */ + function _calculateAMMSpotPrice(address _baseToken) internal view returns (uint256 price) { + address pool = perpMarketRegistry.getPool(_baseToken); + (uint160 sqrtPriceX96, , , , , , ) = IUniswapV3Pool(pool).slot0(); + uint256 priceX96 = sqrtPriceX96.formatSqrtPriceX96ToPriceX96(); + return priceX96.formatX96ToX10_18(); + } + + /** + * @dev Calculates the sum of collateralToken denominated market-prices of assets and debt for the Perp account per + * SetToken + * + * @param _setToken Instance of SetToken + * @return int256 External position unit + */ + function _calculateExternalPositionUnit(ISetToken _setToken) internal view returns (int256) { + PerpV2Positions.PositionNotionalInfo[] memory positionInfo = getPositionNotionalInfo(_setToken); + uint256 positionLength = positionInfo.length; + int256 totalPositionValue = 0; + + for (uint i = 0; i < positionLength; i++ ) { + int256 spotPrice = _calculateAMMSpotPrice(positionInfo[i].baseToken).toInt256(); + totalPositionValue = totalPositionValue.add( + positionInfo[i].baseBalance.preciseMul(spotPrice) + ); + } + + int256 externalPositionUnitInPreciseUnits = _calculatePartialAccountValuePositionUnit(_setToken) + .add(totalPositionValue.preciseDiv(_setToken.totalSupply().toInt256())); + + return externalPositionUnitInPreciseUnits.fromPreciseUnitToDecimals(collateralDecimals); + } + + + /** + * @dev Returns issuance or redemption adjustments in the format expected by `SlippageIssuanceModule`. + * The last recorded externalPositionUnit (current) is subtracted from a dynamically generated + * externalPositionUnit (new) and set in an `equityAdjustments` array which is the same length as + * the SetToken's components array, at the same index the collateral token occupies in the components + * array. All other values are left unset (0). An empty-value components length debtAdjustments + * array is also returned. + * + * @param _setToken Instance of the SetToken + * @param _newExternalPositionUnit Dynamically calculated externalPositionUnit + * @return int256[] Components-length array with equity adjustment value at appropriate index + * @return int256[] Components-length array of zeroes (debt adjustements) + */ + function _formatAdjustments( + ISetToken _setToken, + int256 _newExternalPositionUnit + ) + internal + view + returns (int256[] memory, int256[] memory) + { + int256 currentExternalPositionUnit = _setToken.getExternalPositionRealUnit( + address(collateralToken), + address(this) + ); + + return PerpV2Positions.formatAdjustments( + _setToken, + address(collateralToken), + currentExternalPositionUnit, + _newExternalPositionUnit + ); + } +} diff --git a/hardhat.config.ts b/hardhat.config.ts index 3869e1a56..f6ffdd4cd 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -1,4 +1,5 @@ require("dotenv").config(); +require('hardhat-contract-sizer'); import chalk from "chalk"; import { HardhatUserConfig } from "hardhat/config"; @@ -31,7 +32,7 @@ const config: HardhatUserConfig = { }, networks: { hardhat: { - allowUnlimitedContractSize: true, + allowUnlimitedContractSize: false, forking: (process.env.FORK) ? forkingConfig : undefined, accounts: getHardhatPrivateKeys(), }, @@ -68,6 +69,11 @@ const config: HardhatUserConfig = { target: "ethers-v5", externalArtifacts: ["external/**/*.json"], }, + // @ts-ignore + contractSizer: { + runOnCompile: false, + }, + mocha: mochaConfig, // These are external artifacts we don't compile but would like to improve @@ -100,4 +106,4 @@ function checkForkedProviderEnvironment() { } } -export default config; +export default config; \ No newline at end of file diff --git a/package.json b/package.json index 0384ef617..42d03492c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@setprotocol/set-protocol-v2", - "version": "0.1.15", + "version": "0.1.16", "description": "", "main": "dist", "types": "dist/types", @@ -73,6 +73,7 @@ "eslint-plugin-no-null": "^1.0.2", "ethereum-waffle": "^3.4.0", "hardhat": "^2.6.4", + "hardhat-contract-sizer": "^2.5.0", "husky": "^4.2.5", "istanbul-combine-updated": "^0.3.0", "lint-staged": "^10.2.11", diff --git a/test/integration/perpV2LeverageSlippageIssuance.spec.ts b/test/integration/perpV2LeverageSlippageIssuance.spec.ts index 691568939..4a63f384b 100644 --- a/test/integration/perpV2LeverageSlippageIssuance.spec.ts +++ b/test/integration/perpV2LeverageSlippageIssuance.spec.ts @@ -68,6 +68,10 @@ describe("PerpV2LeverageSlippageIssuance", () => { perpSetup = getPerpV2Fixture(owner.address); await perpSetup.initialize(maker, otherTrader); + // set funding rate to zero; allows us to avoid calculating small amounts of funding + // accrued in our test cases + await perpSetup.clearingHouseConfig.setMaxFundingRate(ZERO); + vETH = perpSetup.vETH; vBTC = perpSetup.vBTC; usdc = perpSetup.usdc; @@ -95,7 +99,7 @@ describe("PerpV2LeverageSlippageIssuance", () => { perpSetup.marketRegistry.address, BigNumber.from(3), "contracts/protocol/integration/lib/PerpV2.sol:PerpV2", - perpLib.address, + perpLib.address ); await setup.controller.addModule(perpLeverageModule.address); @@ -1092,7 +1096,7 @@ describe("PerpV2LeverageSlippageIssuance", () => { .sub(feeAdjustedTransferOutUSDC) .add(realizedPnlUSDC); - expect(finalCollateralBalanceUSDC).to.be.closeTo(expectedCollateralBalanceUSDC, 1); + expect(finalCollateralBalanceUSDC).to.be.closeTo(expectedCollateralBalanceUSDC, 10); }); it("should not update the setToken USDC token balance", async () => { diff --git a/test/integration/perpV2LeverageV2SlippageIssuance.spec.ts b/test/integration/perpV2LeverageV2SlippageIssuance.spec.ts new file mode 100644 index 000000000..04aeba746 --- /dev/null +++ b/test/integration/perpV2LeverageV2SlippageIssuance.spec.ts @@ -0,0 +1,1337 @@ +import "module-alias/register"; +import { ContractTransaction } from "ethers"; +import { Address } from "@utils/types"; +import { Account } from "@utils/test/types"; +import { + SlippageIssuanceModule, + PerpV2LibraryV2, + PositionV2, + PerpV2Positions, + PerpV2LeverageModuleV2, + SetToken, + StandardTokenMock, +} from "@utils/contracts"; +import { PerpV2BaseToken } from "@utils/contracts/perpV2"; +import { + toUSDCDecimals, + calculateUSDCTransferIn, + calculateUSDCTransferOut, + leverUp +} from "@utils/common"; +import DeployHelper from "@utils/deploys"; +import { + ether, + usdc as usdcUnits, + preciseDiv, + preciseMul +} from "@utils/index"; +import { + cacheBeforeEach, + getAccounts, + getWaffleExpect, + getSystemFixture, + getPerpV2Fixture, +} from "@utils/test/index"; +import { PerpV2Fixture, SystemFixture } from "@utils/fixtures"; +import { BigNumber } from "ethers"; +import { ADDRESS_ZERO, ZERO, MAX_UINT_256, ZERO_BYTES } from "@utils/constants"; + +const expect = getWaffleExpect(); + +describe("PerpV2LeverageSlippageIssuance", () => { + let owner: Account; + let maker: Account; + let otherTrader: Account; + let feeRecipient: Account; + let deployer: DeployHelper; + + let perpLib: PerpV2LibraryV2; + let positionLib: PositionV2; + let perpPositionsLib: PerpV2Positions; + let perpLeverageModule: PerpV2LeverageModuleV2; + let slippageIssuanceModule: SlippageIssuanceModule; + let setup: SystemFixture; + let perpSetup: PerpV2Fixture; + + let vETH: PerpV2BaseToken; + let vBTC: PerpV2BaseToken; + let usdc: StandardTokenMock; + + cacheBeforeEach(async () => { + [ + owner, + maker, + otherTrader, + feeRecipient + ] = await getAccounts(); + + deployer = new DeployHelper(owner.wallet); + setup = getSystemFixture(owner.address); + await setup.initialize(); + + perpSetup = getPerpV2Fixture(owner.address); + await perpSetup.initialize(maker, otherTrader); + + // set funding rate to zero; allows us to avoid calculating small amounts of funding + // accrued in our test cases + await perpSetup.clearingHouseConfig.setMaxFundingRate(ZERO); + + vETH = perpSetup.vETH; + vBTC = perpSetup.vBTC; + usdc = perpSetup.usdc; + + // Create liquidity + await perpSetup.setBaseTokenOraclePrice(vETH, usdcUnits(10)); + await perpSetup.initializePoolWithLiquidityWide( + vETH, + ether(10_000), + ether(100_000) + ); + + await perpSetup.setBaseTokenOraclePrice(vBTC, usdcUnits(20)); + await perpSetup.initializePoolWithLiquidityWide( + vBTC, + ether(10_000), + ether(200_000) + ); + + // Deploy libraries + positionLib = await deployer.libraries.deployPositionV2(); + perpLib = await deployer.libraries.deployPerpV2LibraryV2(); + perpPositionsLib = await deployer.libraries.deployPerpV2Positions(); + + perpLeverageModule = await deployer.modules.deployPerpV2LeverageModuleV2( + setup.controller.address, + perpSetup.vault.address, + perpSetup.quoter.address, + perpSetup.marketRegistry.address, + BigNumber.from(3), + "contracts/protocol/lib/PositionV2.sol:PositionV2", + positionLib.address, + "contracts/protocol/integration/lib/PerpV2LibraryV2.sol:PerpV2LibraryV2", + perpLib.address, + "contracts/protocol/integration/lib/PerpV2Positions.sol:PerpV2Positions", + perpPositionsLib.address, + ); + await setup.controller.addModule(perpLeverageModule.address); + + slippageIssuanceModule = await deployer.modules.deploySlippageIssuanceModule( + setup.controller.address + ); + await setup.controller.addModule(slippageIssuanceModule.address); + + await setup.integrationRegistry.addIntegration( + perpLeverageModule.address, + "DefaultIssuanceModule", + slippageIssuanceModule.address + ); + }); + + // Helper to calculate how leveraged the Perp account gets as it mints tokens on margin + async function calculateFlashLeverage(setToken: Address, setQuantity: BigNumber): Promise { + const spotPrice = await perpSetup.getSpotPrice(vETH.address); + const { collateralBalance } = await perpLeverageModule.getAccountInfo(setToken); + const positionNotionalInfo = (await perpLeverageModule.getPositionNotionalInfo(setToken))[0]; + const positionUnitInfo = (await perpLeverageModule.getPositionUnitInfo(setToken))[0]; + + const currentAssetValue = preciseMul(positionNotionalInfo.baseBalance, spotPrice); + const currentDebtValue = positionNotionalInfo.quoteBalance; + + const flashAssetQuantityNotional = preciseMul(positionUnitInfo.baseUnit, setQuantity); + const flashAssetValue = preciseMul(flashAssetQuantityNotional, spotPrice); + const flashDebtValue = + (await perpSetup.getSwapQuote(vETH.address, flashAssetQuantityNotional, true)).deltaQuote; + + const totalAssetValueBeforeRepayment = currentAssetValue.add(flashAssetValue); + const totalDebtValueBeforeRepayment = currentDebtValue.sub(flashDebtValue); + + return preciseDiv( + totalAssetValueBeforeRepayment, + + totalAssetValueBeforeRepayment + .add(totalDebtValueBeforeRepayment) + .add(collateralBalance) + ); + } + + async function calculateTotalSlippage(setToken: SetToken, setQuantity: BigNumber): Promise { + let totalExpectedSlippage = BigNumber.from(0); + const allPositionInfo = await perpLeverageModule.getPositionNotionalInfo(setToken.address); + + for (const positionInfo of allPositionInfo) { + const basePositionUnit = preciseDiv(positionInfo.baseBalance, await setToken.totalSupply()); + const baseTradeQuantityNotional = preciseMul(basePositionUnit, setQuantity); + const isLong = (basePositionUnit.gte(ZERO)); + + const { deltaBase, deltaQuote } = await perpSetup.getSwapQuote( + positionInfo.baseToken, + baseTradeQuantityNotional.abs(), + isLong + ); + + const idealQuote = preciseMul(deltaBase, await perpSetup.getSpotPrice(positionInfo.baseToken)); + + const expectedSlippage = (isLong) + ? idealQuote.sub(deltaQuote).mul(-1) + : idealQuote.sub(deltaQuote); + + totalExpectedSlippage = totalExpectedSlippage.add(expectedSlippage); + } + + return totalExpectedSlippage; + } + + async function calculateRedemptionData( + setToken: Address, + redeemQuantity: BigNumber, + usdcTransferOutQuantity: BigNumber + ) { + // Calculate fee adjusted usdcTransferOut + const redeemQuantityWithFees = (await slippageIssuanceModule.calculateTotalFees( + setToken, + redeemQuantity, + false + ))[0]; + + const feeAdjustedTransferOutUSDC = preciseMul(redeemQuantityWithFees, usdcTransferOutQuantity); + + // Calculate realizedPnl. The amount is debited from collateral returned to redeemer *and* + // debited from the Perp account collateral balance because withdraw performs a settlement. + let realizedPnlUSDC = BigNumber.from(0); + const positionUnitInfo = await await perpLeverageModule.getPositionUnitInfo(setToken); + + for (const info of positionUnitInfo) { + const baseTradeQuantityNotional = preciseMul(info.baseUnit, redeemQuantity); + + const { deltaQuote } = await perpSetup.getSwapQuote( + info.baseToken, + baseTradeQuantityNotional, + false + ); + + const { + baseBalance, + quoteBalance + } = (await perpLeverageModule.getPositionNotionalInfo(setToken))[0]; + + const closeRatio = preciseDiv(baseTradeQuantityNotional, baseBalance); + const reducedOpenNotional = preciseMul(quoteBalance, closeRatio); + + realizedPnlUSDC = realizedPnlUSDC.add(toUSDCDecimals(reducedOpenNotional.add(deltaQuote))); + } + + return { + feeAdjustedTransferOutUSDC, + realizedPnlUSDC, + redeemQuantityWithFees + }; + } + + describe("#issuance", async () => { + let setToken: SetToken; + let issueFee: BigNumber; + let usdcDefaultPositionUnit: BigNumber; + + let subjectSetToken: Address; + let subjectQuantity: BigNumber; + let subjectCheckedComponents: Address[]; + let subjectMaxTokenAmountsIn: BigNumber[]; + let subjectTo: Address; + let subjectCaller: Account; + let issueQuantity: BigNumber; + + const initializeContracts = async function() { + usdcDefaultPositionUnit = usdcUnits(10); + setToken = await setup.createSetToken( + [usdc.address], + [usdcDefaultPositionUnit], + [perpLeverageModule.address, slippageIssuanceModule.address] + ); + issueFee = ether(0.005); + await slippageIssuanceModule.initialize( + setToken.address, + ether(0.02), + issueFee, + ether(0.005), + feeRecipient.address, + ADDRESS_ZERO + ); + // Add SetToken to allow list + await perpLeverageModule.updateAllowedSetToken(setToken.address, true); + await perpLeverageModule.initialize(setToken.address); + + // Approve tokens to issuance module and call issue + await usdc.approve(slippageIssuanceModule.address, usdcUnits(1000)); + }; + + async function subject(): Promise { + return slippageIssuanceModule.connect(subjectCaller.wallet).issueWithSlippage( + subjectSetToken, + subjectQuantity, + subjectCheckedComponents, + subjectMaxTokenAmountsIn, + subjectTo, + ); + } + + context("when there is a default usdc position with 0 supply", async () => { + cacheBeforeEach(initializeContracts); + beforeEach(() => { + subjectSetToken = setToken.address; + subjectQuantity = ether(1); + subjectCheckedComponents = []; + subjectMaxTokenAmountsIn = []; + subjectTo = owner.address; + subjectCaller = owner; + }); + + it("should not update the collateral position on the SetToken", async () => { + const initialPositions = await setToken.getPositions(); + + await subject(); + + 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(usdc.address); + expect(newFirstPosition.positionState).to.eq(0); // Default + expect(newFirstPosition.unit).to.eq(usdcDefaultPositionUnit); + expect(newFirstPosition.module).to.eq(ADDRESS_ZERO); + }); + + it("should not have an external usdc position", async () => { + const initialPositions = await setToken.getPositions(); + + await subject(); + + const currentPositions = await setToken.getPositions(); + + expect(initialPositions.length).to.eq(1); + expect(currentPositions.length).to.eq(1); + }); + + it("should have the correct token balances", async () => { + const preMinterUSDCBalance = await usdc.balanceOf(subjectCaller.address); + const preSetUSDCBalance = await usdc.balanceOf(subjectSetToken); + + await subject(); + + const mintQuantity = preciseMul(subjectQuantity, ether(1).add(issueFee)); + const usdcFlows = preciseMul(mintQuantity, usdcDefaultPositionUnit); + + const postMinterUSDCBalance = await usdc.balanceOf(subjectCaller.address); + const postSetUSDCBalance = await usdc.balanceOf(subjectSetToken); + + expect(postMinterUSDCBalance).to.eq(preMinterUSDCBalance.sub(usdcFlows)); + expect(postSetUSDCBalance).to.eq(preSetUSDCBalance.add(usdcFlows)); + }); + }); + + context("when there is only an external USDC position and totalSupply is 1", async () => { + let baseToken: Address; + let depositQuantityUnit: BigNumber; + let usdcTransferInQuantity: BigNumber; + + cacheBeforeEach(initializeContracts); + + beforeEach(async () => { + // Issue 1 SetToken + issueQuantity = ether(1); + await slippageIssuanceModule.issue(setToken.address, issueQuantity, owner.address); + + depositQuantityUnit = usdcDefaultPositionUnit; + await perpLeverageModule.deposit(setToken.address, depositQuantityUnit); + + // Lever up + baseToken = vETH.address; + await leverUp( + setToken, + perpLeverageModule, + perpSetup, + owner, + baseToken, + 2, + ether(.02), + true + ); + + subjectSetToken = setToken.address; + subjectCheckedComponents = []; + subjectMaxTokenAmountsIn = []; + subjectTo = owner.address; + subjectCaller = owner; + }); + + describe("starting test assumptions", async () => { + it("should be correct", async() => { + const defaultPositionUnit = await setToken.getDefaultPositionRealUnit(usdc.address); + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(defaultPositionUnit).eq(ZERO); + expect(externalPositionUnit).eq(depositQuantityUnit); + }); + }); + + describe("when minting a single set", () => { + beforeEach(async () => { + subjectQuantity = ether(1); + + usdcTransferInQuantity = await calculateUSDCTransferIn( + setToken, + subjectQuantity, + perpLeverageModule, + perpSetup + ); + }); + + it("should not update the USDC defaultPositionUnit", async () => { + const initialDefaultPositionUnit = await setToken.getDefaultPositionRealUnit(usdc.address); + await subject(); + const finalDefaultPositionUnit = await setToken.getDefaultPositionRealUnit(usdc.address);; + + expect(finalDefaultPositionUnit).to.eq(initialDefaultPositionUnit); + }); + + it("should have set the expected USDC externalPositionUnit", async () => { + const expectedExternalPositionUnit = preciseDiv(usdcTransferInQuantity, subjectQuantity); + + await subject(); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + // externalPositionUnit = 10_008_105; + expect(externalPositionUnit).to.be.closeTo(expectedExternalPositionUnit, 1); + }); + + it("have the expected virtual token balance", async () => { + const totalSupply = await setToken.totalSupply(); + + const initialBaseBalance = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0].baseBalance; + await subject(); + const finalBaseBalance = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0].baseBalance; + + const basePositionUnit = preciseDiv(initialBaseBalance, totalSupply); + const baseTokenBoughtNotional = preciseMul(basePositionUnit, subjectQuantity); + const expectedBaseBalance = initialBaseBalance.add(baseTokenBoughtNotional); + + expect(finalBaseBalance).eq(expectedBaseBalance); + }); + + it("should deposit the expected amount into the Perp vault", async () => { + const initialCollateralBalance = (await perpLeverageModule.getAccountInfo(subjectSetToken)).collateralBalance; + await subject(); + const finalCollateralBalance = (await perpLeverageModule.getAccountInfo(subjectSetToken)).collateralBalance; + + const issueQuantityWithFees = (await slippageIssuanceModule.calculateTotalFees( + subjectSetToken, + subjectQuantity, + true + ))[0]; + + const feeAdjustedTransferIn = preciseMul(issueQuantityWithFees, usdcTransferInQuantity); + + // usdcTransferIn = 10_008_105 + // feeAdjustedTransferIn = 10_058_145 + const expectedCollateralBalance = toUSDCDecimals(initialCollateralBalance).add(feeAdjustedTransferIn); + expect(toUSDCDecimals(finalCollateralBalance)).to.be.closeTo(expectedCollateralBalance, 2); + }); + + it("should get required component issuance units correctly", async () => { + const issueQuantityWithFees = (await slippageIssuanceModule.calculateTotalFees( + subjectSetToken, + subjectQuantity, + true + ))[0]; + + const externalPositionUnit = preciseDiv(usdcTransferInQuantity, subjectQuantity); + const feeAdjustedTransferIn = preciseMul(issueQuantityWithFees, externalPositionUnit); + + const [components, equityFlows, debtFlows] = await slippageIssuanceModule.callStatic.getRequiredComponentIssuanceUnitsOffChain( + subjectSetToken, + subjectQuantity + ); + + const expectedComponents = await setToken.getComponents(); + const expectedEquityFlows = [feeAdjustedTransferIn]; + const expectedDebtFlows = [ZERO]; + + expect(expectedComponents[0]).to.eq(components[0]); + expect(expectedEquityFlows[0]).to.be.closeTo(equityFlows[0], 50); + expect(expectedDebtFlows[0]).to.eq(debtFlows[0]); + }); + }); + + describe("when minting multiple sets", () => { + beforeEach(async () => { + subjectQuantity = ether(2); + + usdcTransferInQuantity = await calculateUSDCTransferIn( + setToken, + subjectQuantity, + perpLeverageModule, + perpSetup + ); + }); + + it("should have set the expected USDC externalPositionUnit", async () => { + const expectedExternalPositionUnit = preciseDiv(usdcTransferInQuantity, subjectQuantity); + + await subject(); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + // externalPositionUnit = 10_008_105; + expect(externalPositionUnit).to.be.closeTo(expectedExternalPositionUnit, 1); + }); + + it("have the expected virtual token balance", async () => { + const totalSupply = await setToken.totalSupply(); + + const initialBaseBalance = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0].baseBalance; + await subject(); + const finalBaseBalance = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0].baseBalance; + + const basePositionUnit = preciseDiv(initialBaseBalance, totalSupply); + const baseTokenBoughtNotional = preciseMul(basePositionUnit, subjectQuantity); + const expectedBaseBalance = initialBaseBalance.add(baseTokenBoughtNotional); + + expect(finalBaseBalance).eq(expectedBaseBalance); + }); + + it("should deposit the expected amount into the Perp vault", async () => { + const initialCollateralBalance = (await perpLeverageModule.getAccountInfo(subjectSetToken)).collateralBalance; + await subject(); + const finalCollateralBalance = (await perpLeverageModule.getAccountInfo(subjectSetToken)).collateralBalance; + + const issueQuantityWithFees = (await slippageIssuanceModule.calculateTotalFees( + subjectSetToken, + subjectQuantity, + true + ))[0]; + + const externalPositionUnit = preciseDiv(usdcTransferInQuantity, subjectQuantity); + const feeAdjustedTransferIn = preciseMul(issueQuantityWithFees, externalPositionUnit); + + // usdcTransferIn = 20_024_302 + // feeAdjustedTransferIn = 20_124_423 + const expectedCollateralBalance = toUSDCDecimals(initialCollateralBalance).add(feeAdjustedTransferIn); + expect(toUSDCDecimals(finalCollateralBalance)).to.be.closeTo(expectedCollateralBalance, 2); + }); + + it("should deposit the expected amount into the Perp vault", async () => { + const issueQuantityWithFees = (await slippageIssuanceModule.calculateTotalFees( + subjectSetToken, + subjectQuantity, + true + ))[0]; + + const externalPositionUnit = preciseDiv(usdcTransferInQuantity, subjectQuantity); + const feeAdjustedTransferIn = preciseMul(issueQuantityWithFees, externalPositionUnit); + + const initialCollateralBalance = (await perpLeverageModule.getAccountInfo(subjectSetToken)).collateralBalance; + await subject(); + const finalCollateralBalance = (await perpLeverageModule.getAccountInfo(subjectSetToken)).collateralBalance; + + // usdcTransferIn = 20_024_302 + // feeAdjustedTransferIn = 20_124_423 + const expectedCollateralBalance = toUSDCDecimals(initialCollateralBalance).add(feeAdjustedTransferIn); + expect(toUSDCDecimals(finalCollateralBalance)).to.be.closeTo(expectedCollateralBalance, 2); + }); + + // This is slightly off ... over a tenth of a penny. + it.skip("should not incur a premium", async () => { + const issueQuantityWithFees = (await slippageIssuanceModule.calculateTotalFees( + subjectSetToken, + subjectQuantity, + true + ))[0]; + + // Model says premium should be calculated as (usdcTransferIn / amountMinted) + const externalPositionUnit = preciseDiv(usdcTransferInQuantity, subjectQuantity); + const feeAdjustedTransferIn = preciseMul(issueQuantityWithFees, externalPositionUnit); + const feeAdjustedExternalPositionUnit = preciseDiv(feeAdjustedTransferIn, issueQuantityWithFees); + + // Slippage will be paid by the issuer, but get reflected as debt in the quote balance. + const totalSlippageAndFees = await calculateTotalSlippage(setToken, subjectQuantity); + const totalSlippagePositionUnit = preciseDiv(totalSlippageAndFees, subjectQuantity); + + const feeAndSlippageAdjustedExternalPositionUnit = feeAdjustedExternalPositionUnit + .sub(toUSDCDecimals(totalSlippagePositionUnit)); + + await subject(); + + // Calculate value of set + const accountInfo = await perpLeverageModule.getAccountInfo(subjectSetToken); + const spotPrice = await perpSetup.getSpotPrice(baseToken); + const baseBalance = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0].baseBalance; + const notionalBaseValue = preciseMul(baseBalance, spotPrice); + + const totalSetValue = notionalBaseValue + .add(accountInfo.netQuoteBalance) + .add(accountInfo.collateralBalance) + .add(accountInfo.owedRealizedPnl); + + const valuePerSet = preciseDiv(totalSetValue, await setToken.totalSupply()); + const valuePerSetUSDC = toUSDCDecimals(valuePerSet); + + // feeAdjustedExternalPositionUnit = 10012150 + // feeAndSlippageAdjustedExternalPositionUnit = 9801960 + // valuePerSet = 9818624 + expect(valuePerSetUSDC).eq(feeAndSlippageAdjustedExternalPositionUnit); + }); + }); + + describe("when flash-issuing at high margin (success)", async () => { + // Starting point is 2X leverage, with 10 USDC collateral, 20 USDC vETH, totalSupply = 1 + // Minting 3 sets raises interim leverage ratio to > 80 USDC Asset / 10 USDC collateral + // We know from spec scenario testing that the effective limit is ~ 9.1X + beforeEach(async () => { + subjectQuantity = ether(3); + }); + + // Calculated leverage = 8_702_210_816_139_153_672 + it("~8.7X succeeds", async() => { + const flashLeverage = await calculateFlashLeverage(subjectSetToken, subjectQuantity); + await subject(); + + expect(flashLeverage).to.be.gt(ether(8)); + expect(flashLeverage).to.be.lt(ether(9)); + }); + }); + + describe("when flash-issuing at high margin (failure)", async () => { + beforeEach(async () => { + subjectQuantity = ether(3.5); + }); + + // Calculated leverage = 9_911_554_370_685_102_958 + it("~9.9X fails", async () => { + const flashLeverage = await calculateFlashLeverage(subjectSetToken, subjectQuantity); + + await expect(subject()).to.be.revertedWith("CH_NEFCI"); + + expect(flashLeverage).to.be.gt(ether(9)); + expect(flashLeverage).to.be.lt(ether(10)); + }); + }); + + describe("when issuing after a liquidation", async () => { + beforeEach(async () => { + subjectQuantity = ether(1); + + // Calculated leverage = ~8.5X = 8_654_438_822_995_683_587 + await leverUp( + setToken, + perpLeverageModule, + perpSetup, + owner, + baseToken, + 6, + ether(.02), + true + ); + + await perpSetup.setBaseTokenOraclePrice(vETH, usdcUnits(8.0)); + + await perpSetup + .clearingHouse + .connect(otherTrader.wallet) + .liquidate(subjectSetToken, baseToken); + }); + + it("should issue and transfer in the expected amount", async () => { + const initialTotalSupply = await setToken.totalSupply(); + const initialCollateralBalance = (await perpLeverageModule.getAccountInfo(subjectSetToken)).collateralBalance; + + await subject(); + + // We need to calculate this after the subject() fires because it will revert if the positionList + // isn't updated correctly... + const usdcTransferInQuantity = await calculateUSDCTransferIn( + setToken, + subjectQuantity, + perpLeverageModule, + perpSetup + ); + + const finalTotalSupply = await setToken.totalSupply(); + const finalPositionInfo = await perpLeverageModule.getPositionNotionalInfo(subjectSetToken); + const finalCollateralBalance = (await perpLeverageModule.getAccountInfo(subjectSetToken)).collateralBalance; + + const issueQuantityWithFees = (await slippageIssuanceModule.calculateTotalFees( + subjectSetToken, + subjectQuantity, + true + ))[0]; + + const externalPositionUnit = preciseDiv(usdcTransferInQuantity, subjectQuantity); + const feeAdjustedTransferIn = preciseMul(issueQuantityWithFees, externalPositionUnit); + + const expectedTotalSupply = initialTotalSupply.add(issueQuantityWithFees); + const expectedCollateralBalance = toUSDCDecimals(initialCollateralBalance).add(feeAdjustedTransferIn); + + expect(finalTotalSupply).eq(expectedTotalSupply); + expect(finalPositionInfo.length).eq(0); + expect(toUSDCDecimals(finalCollateralBalance)).to.be.closeTo(expectedCollateralBalance, 2); + }); + }); + + describe("when liquidation results in negative account value", () => { + beforeEach(async () => { + subjectQuantity = ether(1); + + // Calculated leverage = ~8.5X = 8_654_438_822_995_683_587 + await leverUp( + setToken, + perpLeverageModule, + perpSetup, + owner, + baseToken, + 6, + ether(.02), + true + ); + + // Move oracle price down to 5 USDC to enable liquidation + await perpSetup.setBaseTokenOraclePrice(vETH, usdcUnits(5)); + + // Move price down by maker selling 20_000 USDC of vETH + // Post trade spot price drops from ~10 USDC to 6_380_562_015_950_425_028 + await perpSetup.clearingHouse.connect(maker.wallet).openPosition({ + baseToken: vETH.address, + isBaseToQuote: true, // short + isExactInput: false, // `amount` is USDC + amount: ether(20_000), + oppositeAmountBound: ZERO, + deadline: MAX_UINT_256, + sqrtPriceLimitX96: ZERO, + referralCode: ZERO_BYTES + }); + + await perpSetup + .clearingHouse + .connect(otherTrader.wallet) + .liquidate(subjectSetToken, baseToken); + }); + + // In this test case, the account is bankrupt: + // collateralBalance = 10050000000000000000 + // owedRealizedPnl = -31795534271984084912 + it("should issue without transferring any usdc (because account worth 0)", async () => { + const issueQuantityWithFees = (await slippageIssuanceModule.calculateTotalFees( + subjectSetToken, + subjectQuantity, + true + ))[0]; + + const initialIssuerUSDCBalance = await usdc.balanceOf(subjectCaller.address); + const initialTotalSupply = await setToken.totalSupply(); + + await subject(); + + const finalIssuerUSDCBalance = await usdc.balanceOf(subjectCaller.address); + const finalTotalSupply = await setToken.totalSupply(); + + const expectedTotalSupply = initialTotalSupply.add(issueQuantityWithFees); + + expect(finalTotalSupply).eq(expectedTotalSupply); + expect(finalIssuerUSDCBalance).eq(initialIssuerUSDCBalance); + }); + }); + }); + }); + + describe("#redemption", async () => { + let setToken: SetToken; + let baseToken: Address; + let redeemFee: BigNumber; + let depositQuantityUnit: BigNumber; + let usdcDefaultPositionUnit: BigNumber; + let usdcTransferOutQuantity: BigNumber; + + let subjectSetToken: Address; + let subjectQuantity: BigNumber; + let subjectCheckedComponents: Address[]; + let subjectMaxTokenAmountsIn: BigNumber[]; + let subjectTo: Address; + let subjectCaller: Account; + let issueQuantity: BigNumber; + + const initializeContracts = async function() { + usdcDefaultPositionUnit = usdcUnits(10); + setToken = await setup.createSetToken( + [usdc.address], + [usdcDefaultPositionUnit], + [perpLeverageModule.address, slippageIssuanceModule.address] + ); + redeemFee = ether(0.005); + await slippageIssuanceModule.initialize( + setToken.address, + ether(0.02), + ether(0.005), + redeemFee, + feeRecipient.address, + ADDRESS_ZERO + ); + // Add SetToken to allow list + await perpLeverageModule.updateAllowedSetToken(setToken.address, true); + await perpLeverageModule.initialize(setToken.address); + + // Approve tokens to issuance module and call issue + await usdc.approve(slippageIssuanceModule.address, usdcUnits(1000)); + }; + + async function subject(): Promise { + return slippageIssuanceModule.connect(subjectCaller.wallet).redeemWithSlippage( + subjectSetToken, + subjectQuantity, + subjectCheckedComponents, + subjectMaxTokenAmountsIn, + subjectTo + ); + } + + context("when there is only an external USDC position and redeem will take supply to 0", async () => { + cacheBeforeEach(initializeContracts); + + beforeEach(async () => { + // Issue 1 SetToken + issueQuantity = ether(1); + await slippageIssuanceModule.issue(setToken.address, issueQuantity, owner.address); + + depositQuantityUnit = usdcUnits(10); + await perpLeverageModule.deposit(setToken.address, depositQuantityUnit); + + // Lever up 2X + baseToken = vETH.address; + await leverUp( + setToken, + perpLeverageModule, + perpSetup, + owner, + baseToken, + 2, + ether(.02), + true + ); + + subjectSetToken = setToken.address; + subjectQuantity = issueQuantity; + subjectCheckedComponents = []; + subjectMaxTokenAmountsIn = []; + subjectTo = owner.address; + subjectCaller = owner; + + usdcTransferOutQuantity = await calculateUSDCTransferOut( + setToken, + subjectQuantity, + perpLeverageModule, + perpSetup + ); + }); + + it("should not update the USDC defaultPositionUnit", async () => { + const initialDefaultPositionUnit = await setToken.getDefaultPositionRealUnit(usdc.address); + await subject(); + const finalDefaultPositionUnit = await setToken.getDefaultPositionRealUnit(usdc.address); + + expect(initialDefaultPositionUnit).eq(finalDefaultPositionUnit); + }); + + it("should have updated the USDC externalPositionUnit", async () => { + const initialExternalPositionUnit = await setToken.getExternalPositionRealUnit(usdc.address, perpLeverageModule.address); + await subject(); + const finalExternalPositionUnit = await setToken.getExternalPositionRealUnit(usdc.address, perpLeverageModule.address); + + const expectedExternalPositionUnit = usdcTransferOutQuantity; + + expect(initialExternalPositionUnit).not.eq(finalExternalPositionUnit); + expect(finalExternalPositionUnit).eq(expectedExternalPositionUnit); + }); + + it("should have the expected virtual token balance", async () => { + const totalSupply = await setToken.totalSupply(); + + const initialBaseBalance = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0].baseBalance; + await subject(); + const finalBaseBalance = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0].baseBalance; + + const basePositionUnit = preciseDiv(initialBaseBalance, totalSupply); + const baseTokenBoughtNotional = preciseMul(basePositionUnit, subjectQuantity); + const expectedBaseBalance = initialBaseBalance.sub(baseTokenBoughtNotional); + + expect(finalBaseBalance).eq(expectedBaseBalance); + }); + + it("should not have updated the setToken USDC token balance", async () => { + const initialUSDCBalance = await usdc.balanceOf(subjectSetToken); + await subject(); + const finalUSDCBalance = await usdc.balanceOf(subjectSetToken); + + expect(initialUSDCBalance).eq(finalUSDCBalance); + }); + + describe("withdrawal", () => { + let feeAdjustedTransferOutUSDC: BigNumber; + let realizedPnlUSDC: BigNumber; + + beforeEach(async() => { + ({ + feeAdjustedTransferOutUSDC, + realizedPnlUSDC + } = await calculateRedemptionData( + subjectSetToken, + subjectQuantity, + usdcTransferOutQuantity + )); + }); + + it("should withdraw the expected amount from the Perp vault", async () => { + const initialCollateralBalance = (await perpLeverageModule.getAccountInfo(subjectSetToken)).collateralBalance; + await subject(); + const finalCollateralBalance = (await perpLeverageModule.getAccountInfo(subjectSetToken)).collateralBalance; + + const initialCollateralBalanceUSDC = toUSDCDecimals(initialCollateralBalance); + const finalCollateralBalanceUSDC = toUSDCDecimals(finalCollateralBalance); + + const expectedCollateralBalanceUSDC = initialCollateralBalanceUSDC + .sub(feeAdjustedTransferOutUSDC) + .add(realizedPnlUSDC); + + expect(finalCollateralBalanceUSDC).to.be.closeTo(expectedCollateralBalanceUSDC, 1); + }); + + it("should not update the setToken USDC token balance", async () => { + const initialUSDCBalance = await usdc.balanceOf(subjectSetToken); + await subject(); + const finalUSDCBalance = await usdc.balanceOf(subjectSetToken); + + expect(initialUSDCBalance).eq(0); + expect(finalUSDCBalance).eq(initialUSDCBalance); + }); + + it("should have transferred expected USDC to set token holder", async () => { + const initialOwnerUSDCBalance = await usdc.balanceOf(subjectCaller.address); + await subject(); + const finalOwnerUSDCBalance = await usdc.balanceOf(subjectCaller.address); + + const expectedUSDCBalance = initialOwnerUSDCBalance.add(feeAdjustedTransferOutUSDC); + expect(finalOwnerUSDCBalance).eq(expectedUSDCBalance); + }); + }); + }); + + context("when there is only an external USDC position and redeem will take supply to 1", async () => { + let depositQuantityUnit: BigNumber; + let usdcTransferOutQuantity: BigNumber; + + cacheBeforeEach(initializeContracts); + + beforeEach(async () => { + // Issue 2 SetTokens + issueQuantity = ether(2); + await slippageIssuanceModule.issue(setToken.address, issueQuantity, owner.address); + + // Deposit entire default position + depositQuantityUnit = usdcDefaultPositionUnit; + await perpLeverageModule.deposit(setToken.address, depositQuantityUnit); + + // Lever up 2X + baseToken = vETH.address; + await leverUp( + setToken, + perpLeverageModule, + perpSetup, + owner, + baseToken, + 2, + ether(.02), + true + ); + + subjectSetToken = setToken.address; + subjectQuantity = ether(1); + subjectCheckedComponents = []; + subjectMaxTokenAmountsIn = []; + subjectTo = owner.address; + subjectCaller = owner; + + usdcTransferOutQuantity = await calculateUSDCTransferOut( + setToken, + subjectQuantity, + perpLeverageModule, + perpSetup + ); + }); + + it("should not update the USDC defaultPositionUnit", async () => { + const initialDefaultPositionUnit = await setToken.getDefaultPositionRealUnit(usdc.address); + await subject(); + const finalDefaultPositionUnit = await setToken.getDefaultPositionRealUnit(usdc.address); + + expect(initialDefaultPositionUnit).eq(finalDefaultPositionUnit); + }); + + it("should update the USDC externalPositionUnit", async () => { + const initialExternalPositionUnit = await setToken.getExternalPositionRealUnit(usdc.address, perpLeverageModule.address); + await subject(); + const finalExternalPositionUnit = await setToken.getExternalPositionRealUnit(usdc.address, perpLeverageModule.address); + + // initialExternalPositionUnit = 10_000_000 + // finalExternalPositionUnit = 9_597_857 + + const expectedExternalPositionUnit = preciseDiv(usdcTransferOutQuantity, subjectQuantity); + expect(initialExternalPositionUnit).eq(usdcDefaultPositionUnit); + expect(finalExternalPositionUnit).to.be.closeTo(expectedExternalPositionUnit, 1); + }); + + it("should have the expected virtual token balance", async () => { + const totalSupply = await setToken.totalSupply(); + + const initialBaseBalance = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0].baseBalance; + await subject(); + const finalBaseBalance = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0].baseBalance; + + const basePositionUnit = preciseDiv(initialBaseBalance, totalSupply); + const baseTokenSoldNotional = preciseMul(basePositionUnit, subjectQuantity); + const expectedBaseBalance = initialBaseBalance.sub(baseTokenSoldNotional); + + expect(finalBaseBalance).eq(expectedBaseBalance); + }); + + it("should get required component redemption units correctly", async () => { + const issueQuantityWithFees = (await slippageIssuanceModule.calculateTotalFees( + subjectSetToken, + subjectQuantity, + false + ))[0]; + + const externalPositionUnit = preciseDiv(usdcTransferOutQuantity, subjectQuantity); + const feeAdjustedTransferOut = preciseMul(issueQuantityWithFees, externalPositionUnit); + + const [components, equityFlows, debtFlows] = await slippageIssuanceModule + .callStatic + .getRequiredComponentRedemptionUnitsOffChain( + subjectSetToken, + subjectQuantity + ); + + const expectedComponents = await setToken.getComponents(); + const expectedEquityFlows = [feeAdjustedTransferOut]; + const expectedDebtFlows = [ZERO]; + + expect(expectedComponents[0]).to.eq(components[0]); + expect(expectedEquityFlows[0]).to.be.closeTo(equityFlows[0], 50); + expect(expectedDebtFlows[0]).to.eq(debtFlows[0]); + }); + + // This is slightly off ... over a tenth of a penny. + it.skip("should not incur a premium", async () => { + const redeemQuantityWithFees = (await slippageIssuanceModule.calculateTotalFees( + subjectSetToken, + subjectQuantity, + false + ))[0]; + + // Model says premium should be calculated as (usdcTransferIn / amountMinted) + const externalPositionUnit = preciseDiv(usdcTransferOutQuantity, subjectQuantity); + const feeAdjustedTransferOut = preciseMul(redeemQuantityWithFees, externalPositionUnit); + const feeAdjustedExternalPositionUnit = preciseDiv(feeAdjustedTransferOut, redeemQuantityWithFees); + + // Slippage will be paid by the redeemer + const totalSlippageAndFees = await calculateTotalSlippage(setToken, subjectQuantity); + const totalSlippagePositionUnit = preciseDiv(totalSlippageAndFees, subjectQuantity); + + const feeAndSlippageAdjustedExternalPositionUnit = feeAdjustedExternalPositionUnit + .add(toUSDCDecimals(totalSlippagePositionUnit)); + + await subject(); + + // Calculate value of set + const accountInfo = await perpLeverageModule.getAccountInfo(subjectSetToken); + const spotPrice = await perpSetup.getSpotPrice(baseToken); + const baseBalance = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0].baseBalance; + const notionalBaseValue = preciseMul(baseBalance, spotPrice); + + const totalSetValue = notionalBaseValue + .add(accountInfo.netQuoteBalance) + .add(accountInfo.collateralBalance) + .add(accountInfo.owedRealizedPnl); + + const valuePerSet = preciseDiv(totalSetValue, await setToken.totalSupply()); + const valuePerSetUSDC = toUSDCDecimals(valuePerSet); + + // feeAdjustedTransferOut = 9_553_810 + // valuePerSetUSDC = 9_796_973 + // feeAndSlippageAdjustedExternalPositionUnit = 9_808_047 + + expect(valuePerSetUSDC).eq(feeAndSlippageAdjustedExternalPositionUnit); + }); + + describe("withdrawal", () => { + let feeAdjustedTransferOutUSDC: BigNumber; + let realizedPnlUSDC: BigNumber; + + beforeEach(async() => { + ({ + feeAdjustedTransferOutUSDC, + realizedPnlUSDC + } = await calculateRedemptionData( + subjectSetToken, + subjectQuantity, + usdcTransferOutQuantity) + ); + }); + + it("should withdraw the expected amount from the Perp vault", async () => { + const initialCollateralBalance = (await perpLeverageModule.getAccountInfo(subjectSetToken)).collateralBalance; + await subject(); + const finalCollateralBalance = (await perpLeverageModule.getAccountInfo(subjectSetToken)).collateralBalance; + + const initialCollateralBalanceUSDC = toUSDCDecimals(initialCollateralBalance); + const finalCollateralBalanceUSDC = toUSDCDecimals(finalCollateralBalance); + + // realizedPnl = -398179 + // feeAdjustedTransferOut = 9553810 + const expectedCollateralBalanceUSDC = initialCollateralBalanceUSDC + .sub(feeAdjustedTransferOutUSDC) + .add(realizedPnlUSDC); + + expect(finalCollateralBalanceUSDC).to.be.closeTo(expectedCollateralBalanceUSDC, 1); + }); + + it("should not update the setToken USDC token balance", async () => { + const initialUSDCBalance = await usdc.balanceOf(subjectSetToken); + await subject(); + const finalUSDCBalance = await usdc.balanceOf(subjectSetToken); + + expect(initialUSDCBalance).eq(0); + expect(finalUSDCBalance).eq(initialUSDCBalance); + }); + + it("should have transferred expected USDC to set token holder", async () => { + const initialOwnerUSDCBalance = await usdc.balanceOf(subjectCaller.address); + await subject(); + const finalOwnerUSDCBalance = await usdc.balanceOf(subjectCaller.address); + + const expectedUSDCBalance = initialOwnerUSDCBalance.add(feeAdjustedTransferOutUSDC); + expect(finalOwnerUSDCBalance).to.be.closeTo(expectedUSDCBalance, 1); + }); + + it("should remove the module when dust is in the account and be able to add module back", async () => { + // Redeem to `1` + await subject(); + + // Check precondition + const initialModules = await setToken.getModules(); + expect(initialModules.includes(perpLeverageModule.address)).eq(true); + + // Trade to `0` + const { + baseUnit: initialBaseUnit + } = (await perpLeverageModule.getPositionUnitInfo(subjectSetToken))[0]; + + await perpLeverageModule.connect(owner.wallet).trade( + subjectSetToken, + baseToken, + initialBaseUnit.mul(-1), + ZERO + ); + + const positionInfo = await perpLeverageModule.getPositionUnitInfo(subjectSetToken); + + // Withdraw remaining free collateral + const freeCollateral = await perpSetup.vault.getFreeCollateral(subjectSetToken); + const freeCollateralPositionUnit = preciseDiv(freeCollateral, await setToken.totalSupply()); + + // freeCollateral = 9737806 + // withdrawing this amount as a positionUnit results in a freeCollateral balance of `1` + // that can't be withdrawn due to positionUnit math rounding errors. + await perpLeverageModule + .connect(owner.wallet) + .withdraw(subjectSetToken, freeCollateralPositionUnit); + + const { + collateralBalance: finalCollateralBalance + } = await perpLeverageModule.getAccountInfo(subjectSetToken); + + + /// Remove module + await setToken.removeModule(perpLeverageModule.address); + const finalModules = await setToken.getModules(); + + expect(finalModules.includes(perpLeverageModule.address)).eq(false); + expect(positionInfo.length).eq(0); + expect(toUSDCDecimals(finalCollateralBalance)).eq(1); // <-- DUST + + // Restore module + await setToken.connect(owner.wallet).addModule(perpLeverageModule.address); + await perpLeverageModule.updateAllowedSetToken(setToken.address, true); + await perpLeverageModule.initialize(setToken.address); + + const restoredModules = await setToken.getModules(); + expect(restoredModules.includes(perpLeverageModule.address)).eq(true); + + // Verify that we can deposit again + await perpLeverageModule.deposit(setToken.address, usdcUnits(5)); + }); + }); + + describe("when redeeming after a liquidation", async () => { + beforeEach(async () => { + subjectQuantity = ether(1); + + // Calculated leverage = ~8.5X = 8_654_438_822_995_683_587 + await leverUp( + setToken, + perpLeverageModule, + perpSetup, + owner, + baseToken, + 6, + ether(.02), + true + ); + + await perpSetup.setBaseTokenOraclePrice(vETH, usdcUnits(8.0)); + + await perpSetup + .clearingHouse + .connect(otherTrader.wallet) + .liquidate(subjectSetToken, baseToken); + }); + + it("should redeem and transfer out the expected amount", async () => { + const initialTotalSupply = await setToken.totalSupply(); + const initialCollateralBalance = (await perpLeverageModule.getAccountInfo(subjectSetToken)).collateralBalance; + + // Total amount of owedRealizedPnl will be debited from collateral balance + const { owedRealizedPnl } = await perpLeverageModule.getAccountInfo(subjectSetToken); + const owedRealizedPnlUSDC = toUSDCDecimals(owedRealizedPnl); + + await subject(); + + const finalTotalSupply = await setToken.totalSupply(); + const finalPositionInfo = await perpLeverageModule.getPositionNotionalInfo(subjectSetToken); + const finalCollateralBalance = (await perpLeverageModule.getAccountInfo(subjectSetToken)).collateralBalance; + + const usdcTransferOutQuantity = await calculateUSDCTransferOut( + setToken, + subjectQuantity, + perpLeverageModule, + perpSetup + ); + + const { + feeAdjustedTransferOutUSDC, + redeemQuantityWithFees + } = await calculateRedemptionData( + subjectSetToken, + subjectQuantity, + usdcTransferOutQuantity + ); + + const expectedTotalSupply = initialTotalSupply.sub(redeemQuantityWithFees); + const expectedCollateralBalance = toUSDCDecimals(initialCollateralBalance) + .sub(feeAdjustedTransferOutUSDC) + .add(owedRealizedPnlUSDC); + + expect(finalTotalSupply).eq(expectedTotalSupply); + expect(finalPositionInfo.length).eq(0); + expect(toUSDCDecimals(finalCollateralBalance)).to.be.closeTo(expectedCollateralBalance, 2); + }); + }); + + describe("when liquidation results in negative account value", () => { + beforeEach(async () => { + // Calculated leverage = ~8.5X = 8_654_438_822_995_683_587 + await leverUp( + setToken, + perpLeverageModule, + perpSetup, + owner, + baseToken, + 6, + ether(.02), + true + ); + + // Move oracle price down to 5 USDC to enable liquidation + await perpSetup.setBaseTokenOraclePrice(vETH, usdcUnits(5.0)); + + // Move price down by maker selling 20k USDC of vETH + // Post trade spot price rises from ~10 USDC to 6_370_910_537_702_299_856 + await perpSetup.clearingHouse.connect(maker.wallet).openPosition({ + baseToken: vETH.address, + isBaseToQuote: true, // short + isExactInput: false, // `amount` is USDC + amount: ether(20000), + oppositeAmountBound: ZERO, + deadline: MAX_UINT_256, + sqrtPriceLimitX96: ZERO, + referralCode: ZERO_BYTES + }); + + await perpSetup + .clearingHouse + .connect(otherTrader.wallet) + .liquidate(subjectSetToken, baseToken); + }); + + // In this test case, the account is bankrupt: + // collateralBalance = 10050000000000000000 + // owedRealizedPnl = -31795534271984084912 + it("should redeem without transferring any usdc (because account worth 0)", async () => { + const redeemQuantityWithFees = (await slippageIssuanceModule.calculateTotalFees( + subjectSetToken, + subjectQuantity, + false + ))[0]; + + const initialRedeemerUSDCBalance = await usdc.balanceOf(subjectCaller.address); + const initialTotalSupply = await setToken.totalSupply(); + + await subject(); + + const finalRedeemerUSDCBalance = await usdc.balanceOf(subjectCaller.address); + const finalTotalSupply = await setToken.totalSupply(); + + const expectedTotalSupply = initialTotalSupply.sub(redeemQuantityWithFees); + + expect(finalTotalSupply).eq(expectedTotalSupply); + expect(finalRedeemerUSDCBalance).eq(initialRedeemerUSDCBalance); + }); + + it("should be possible to remove the module", async () => { + await subject(); + + const collateralBalance = await perpSetup.vault.getBalance(subjectSetToken); + const freeCollateral = await perpSetup.vault.getFreeCollateral(subjectSetToken); + const accountValue = await perpSetup.clearingHouse.getAccountValue(subjectSetToken); + + // collateralBalance: 20_100_000 (10^6) + // accountValue: -43_466_857_276_051_287_954 (10^18) + expect(collateralBalance).gt(1); + expect(freeCollateral).eq(0); + expect(accountValue).lt(-1); + + /// Remove module + await setToken.removeModule(perpLeverageModule.address); + const finalModules = await setToken.getModules(); + expect(finalModules.includes(perpLeverageModule.address)).eq(false); + }); + }); + }); + }); +}); diff --git a/test/protocol-viewers/perpV2LeverageModuleViewer.spec.ts b/test/protocol-viewers/perpV2LeverageModuleViewer.spec.ts index 0fb6fb9b3..9ce272003 100644 --- a/test/protocol-viewers/perpV2LeverageModuleViewer.spec.ts +++ b/test/protocol-viewers/perpV2LeverageModuleViewer.spec.ts @@ -108,7 +108,7 @@ describe("PerpV2LeverageModuleViewer", () => { perpSetup.marketRegistry.address, BigNumber.from(3), "contracts/protocol/integration/lib/PerpV2.sol:PerpV2", - perpLib.address, + perpLib.address ); await setup.controller.addModule(perpLeverageModule.address); diff --git a/test/protocol/integration/lib/perpV2LibraryV2.spec.ts b/test/protocol/integration/lib/perpV2LibraryV2.spec.ts new file mode 100644 index 000000000..87cafc025 --- /dev/null +++ b/test/protocol/integration/lib/perpV2LibraryV2.spec.ts @@ -0,0 +1,608 @@ +import "module-alias/register"; +import { BigNumber } from "ethers"; + +import { Address } from "@utils/types"; +import { Account } from "@utils/test/types"; +import { PerpV2LibraryV2, PerpV2LibraryV2Mock, InvokeMock, SetToken } from "@utils/contracts"; +import { PerpV2BaseToken } from "@utils/contracts/perpV2"; +import DeployHelper from "@utils/deploys"; +import { + ether, +} from "@utils/index"; +import { + getAccounts, + getSystemFixture, + getWaffleExpect, + addSnapshotBeforeRestoreAfterEach, + getPerpV2Fixture +} from "@utils/test/index"; +import { PerpV2Fixture, SystemFixture } from "@utils/fixtures"; +import { MAX_UINT_256, ZERO_BYTES, ADDRESS_ZERO, ZERO } from "@utils/constants"; + +const expect = getWaffleExpect(); + +describe("PerpV2LibraryV2", () => { + let owner: Account; + let maker: Account; + let otherTrader: Account; + let deployer: DeployHelper; + + let perpLib: PerpV2LibraryV2; + let perpLibMock: PerpV2LibraryV2Mock; + let invokeLibMock: InvokeMock; + let setup: SystemFixture; + let perpSetup: PerpV2Fixture; + + let setToken: SetToken; + let vETH: PerpV2BaseToken; + + before(async () => { + [ + owner, + maker, + otherTrader + ] = await getAccounts(); + + deployer = new DeployHelper(owner.wallet); + setup = getSystemFixture(owner.address); + await setup.initialize(); + + perpLib = await deployer.libraries.deployPerpV2LibraryV2(); + perpLibMock = await deployer.mocks.deployPerpV2LibraryV2Mock( + "contracts/protocol/integration/lib/PerpV2LibraryV2.sol:PerpV2LibraryV2", + perpLib.address + ); + invokeLibMock = await deployer.mocks.deployInvokeMock(); + await setup.controller.addModule(perpLibMock.address); + await setup.controller.addModule(invokeLibMock.address); + + perpSetup = getPerpV2Fixture(owner.address); + await perpSetup.initialize(maker, otherTrader); + + vETH = perpSetup.vETH; + + // Create liquidity + await perpSetup.initializePoolWithLiquidityWide(vETH, ether(1000), ether(10_000)); + + setToken = await setup.createSetToken( + [perpSetup.usdc.address], + [ether(1000)], + [setup.issuanceModule.address, perpLibMock.address, invokeLibMock.address] + ); + + await setup.issuanceModule.initialize(setToken.address, ADDRESS_ZERO); + await invokeLibMock.initializeModuleOnSet(setToken.address); + await perpLibMock.initializeModuleOnSet(setToken.address); + + await perpSetup.usdc.approve(setup.issuanceModule.address, MAX_UINT_256); + await setup.issuanceModule.issue(setToken.address, ether(1), owner.address); + }); + + addSnapshotBeforeRestoreAfterEach(); + + describe("#getDepositCalldata", async () => { + let subjectAsset: Address; + let subjectAmountNotional: BigNumber; + let subjectVault: Address; + + beforeEach(async () => { + subjectAsset = perpSetup.usdc.address; + subjectAmountNotional = ether(1); + subjectVault = perpSetup.vault.address; + }); + + async function subject(): Promise { + return await perpLibMock.testGetDepositCalldata( + subjectVault, + subjectAsset, + subjectAmountNotional + ); + } + + it("should get correct data", async () => { + const [target, value, calldata] = await subject(); + const expectedCalldata = perpSetup.vault.interface.encodeFunctionData("deposit", [ + subjectAsset, + subjectAmountNotional + ]); + + expect(target).to.eq(subjectVault); + expect(value).to.eq(ZERO); + expect(calldata).to.eq(expectedCalldata); + }); + }); + + describe("#invokeDeposit", async () => { + let subjectSetToken: Address; + let subjectAsset: Address; + let subjectAmountNotional: BigNumber; + let subjectVault: Address; + + beforeEach(async () => { + await invokeLibMock.testInvokeApprove(setToken.address, perpSetup.usdc.address, perpSetup.vault.address, MAX_UINT_256); + + subjectSetToken = setToken.address; + subjectAsset = perpSetup.usdc.address; + subjectAmountNotional = ether(1); + subjectVault = perpSetup.vault.address; + }); + + async function subject(): Promise { + return await perpLibMock.testInvokeDeposit( + subjectSetToken, + subjectVault, + subjectAsset, + subjectAmountNotional + ); + } + + it("should create a USDC collateral balance", async () => { + const previousCollateralBalance = await perpSetup.vault.getBalance(setToken.address); + await subject(); + const currentCollateralBalance = await perpSetup.vault.getBalance(setToken.address); + const expectedCollateralBalance = previousCollateralBalance.add(subjectAmountNotional); + expect(currentCollateralBalance).to.eq(expectedCollateralBalance); + }); + }); + + describe("#getWithdrawCalldata", async () => { + let subjectAsset: Address; + let subjectAmountNotional: BigNumber; + let subjectVault: Address; + + beforeEach(async () => { + subjectAsset = perpSetup.usdc.address; + subjectAmountNotional = ether(1); + subjectVault = perpSetup.vault.address; + }); + + async function subject(): Promise { + return await perpLibMock.testGetWithdrawCalldata( + subjectVault, + subjectAsset, + subjectAmountNotional + ); + } + + it("should get correct data", async () => { + const [target, value, calldata] = await subject(); + const expectedCalldata = perpSetup.vault.interface.encodeFunctionData("withdraw", [ + subjectAsset, + subjectAmountNotional + ]); + + expect(target).to.eq(subjectVault); + expect(value).to.eq(ZERO); + expect(calldata).to.eq(expectedCalldata); + }); + }); + + describe("#invokeWithdraw", async () => { + let subjectSetToken: Address; + let subjectAsset: Address; + let subjectAmountNotional: BigNumber; + let subjectVault: Address; + + beforeEach(async () => { + await invokeLibMock.testInvokeApprove(setToken.address, perpSetup.usdc.address, perpSetup.vault.address, MAX_UINT_256); + await perpLibMock.testInvokeDeposit(setToken.address, perpSetup.vault.address, perpSetup.usdc.address, ether(1)); + + subjectSetToken = setToken.address; + subjectAsset = perpSetup.usdc.address; + subjectAmountNotional = ether(1); + subjectVault = perpSetup.vault.address; + }); + + async function subject(): Promise { + return await perpLibMock.testInvokeWithdraw( + subjectSetToken, + subjectVault, + subjectAsset, + subjectAmountNotional + ); + } + + it("should withdraw USDC collateral and return USDC", async () => { + const previousCollateralBalance = await perpSetup.vault.getBalance(setToken.address); + const previousUSDCBalance = await perpSetup.usdc.balanceOf(setToken.address); + await subject(); + const currentCollateralBalance = await perpSetup.vault.getBalance(setToken.address); + const currentUSDCBalance = await perpSetup.usdc.balanceOf(setToken.address); + + const expectedCollateralBalance = previousCollateralBalance.sub(subjectAmountNotional); + const expectedUSDCBalance = previousUSDCBalance.add(subjectAmountNotional); // 1:1 ratio + + expect(currentCollateralBalance).to.eq(expectedCollateralBalance); + expect(currentUSDCBalance).to.eq(expectedUSDCBalance); + }); + }); + + describe("#getOpenPositionCalldata", async () => { + let subjectClearingHouse: Address; + let subjectVETH: PerpV2BaseToken; + let subjectIsBaseToQuote: boolean; + let subjectIsExactInput: boolean; + let subjectTradeQuoteAmount: BigNumber; + let subjectOppositeAmountBound: BigNumber; + let subjectDeadline: BigNumber; + let subjectSqrtPriceLimitX96: BigNumber; + let subjectReferralCode: string; + + beforeEach(async () => { + subjectClearingHouse = perpSetup.clearingHouse.address; + subjectVETH = vETH; + subjectIsBaseToQuote = false; + subjectIsExactInput = true; + subjectTradeQuoteAmount = ether(1); + subjectOppositeAmountBound = ZERO; + subjectDeadline = MAX_UINT_256; + subjectSqrtPriceLimitX96 = ZERO; + subjectReferralCode = ZERO_BYTES; + }); + + async function subject(): Promise { + return await perpLibMock.testGetOpenPositionCalldata( + subjectClearingHouse, + { + baseToken: subjectVETH.address, + isBaseToQuote: subjectIsBaseToQuote, + isExactInput: subjectIsExactInput, + amount: subjectTradeQuoteAmount, + oppositeAmountBound: subjectOppositeAmountBound, + deadline: subjectDeadline, + sqrtPriceLimitX96: subjectSqrtPriceLimitX96, + referralCode: subjectReferralCode + } + ); + } + + it("should get correct data", async () => { + const [target, value, calldata] = await subject(); + const expectedCalldata = perpSetup.clearingHouse.interface.encodeFunctionData("openPosition", [ + { + baseToken: subjectVETH.address, + isBaseToQuote: subjectIsBaseToQuote, + isExactInput: subjectIsExactInput, + amount: subjectTradeQuoteAmount, + oppositeAmountBound: subjectOppositeAmountBound, + deadline: subjectDeadline, + sqrtPriceLimitX96: subjectSqrtPriceLimitX96, + referralCode: subjectReferralCode + } + ]); + + expect(target).to.eq(subjectClearingHouse); + expect(value).to.eq(ZERO); + expect(calldata).to.eq(expectedCalldata); + }); + }); + + describe("#invokeOpenPosition", async () => { + let subjectSetToken: Address; + let subjectClearingHouse: Address; + let subjectVETH: PerpV2BaseToken; + let subjectIsBaseToQuote: boolean; + let subjectIsExactInput: boolean; + let subjectTradeQuoteAmount: BigNumber; + let subjectOppositeAmountBound: BigNumber; + let subjectDeadline: BigNumber; + let subjectSqrtPriceLimitX96: BigNumber; + let subjectReferralCode: string; + + beforeEach(async () => { + await invokeLibMock.testInvokeApprove(setToken.address, perpSetup.usdc.address, perpSetup.vault.address, MAX_UINT_256); + await perpLibMock.testInvokeDeposit(setToken.address, perpSetup.vault.address, perpSetup.usdc.address, ether(1)); + + subjectSetToken = setToken.address; + subjectClearingHouse = perpSetup.clearingHouse.address; + subjectVETH = vETH; + subjectIsBaseToQuote = false; + subjectIsExactInput = true; + subjectTradeQuoteAmount = ether(1); + subjectOppositeAmountBound = ZERO; + subjectDeadline = MAX_UINT_256; + subjectSqrtPriceLimitX96 = ZERO; + subjectReferralCode = ZERO_BYTES; + }); + + async function subject(): Promise { + return await perpLibMock.testInvokeOpenPosition( + subjectSetToken, + subjectClearingHouse, + { + baseToken: subjectVETH.address, + isBaseToQuote: subjectIsBaseToQuote, + isExactInput: subjectIsExactInput, + amount: subjectTradeQuoteAmount, + oppositeAmountBound: subjectOppositeAmountBound, + deadline: subjectDeadline, + sqrtPriceLimitX96: subjectSqrtPriceLimitX96, + referralCode: subjectReferralCode + } + ); + } + + it("should open a position", async () => { + const previousBaseBalance = await perpSetup.accountBalance.getBase(setToken.address, subjectVETH.address); + const previousQuoteBalance = await perpSetup.accountBalance.getQuote(setToken.address, subjectVETH.address); + + await subject(); + + const currentBaseBalance = await perpSetup.accountBalance.getBase(setToken.address, subjectVETH.address); + const currentQuoteBalance = await perpSetup.accountBalance.getQuote(setToken.address, subjectVETH.address); + + const expectedQuoteBalance = previousQuoteBalance.sub(subjectTradeQuoteAmount); + + expect(previousBaseBalance).to.eq(0); + expect(currentBaseBalance).to.be.gt(previousBaseBalance); + expect(currentQuoteBalance).to.eq(expectedQuoteBalance); + }); + }); + + describe("#getSwapCalldata", async () => { + let subjectQuoter: Address; + let subjectVETH: PerpV2BaseToken; + let subjectIsBaseToQuote: boolean; + let subjectIsExactInput: boolean; + let subjectTradeQuoteAmount: BigNumber; + let subjectSqrtPriceLimitX96: BigNumber; + + beforeEach(async () => { + subjectQuoter = perpSetup.clearingHouse.address; + subjectVETH = vETH; + subjectIsBaseToQuote = false; + subjectIsExactInput = true; + subjectTradeQuoteAmount = ether(1); + subjectSqrtPriceLimitX96 = ZERO; + }); + + async function subject(): Promise { + return await perpLibMock.testGetSwapCalldata( + subjectQuoter, + { + baseToken: subjectVETH.address, + isBaseToQuote: subjectIsBaseToQuote, + isExactInput: subjectIsExactInput, + amount: subjectTradeQuoteAmount, + sqrtPriceLimitX96: subjectSqrtPriceLimitX96, + } + ); + } + + it("should get correct data", async () => { + const [target, value, calldata] = await subject(); + const expectedCalldata = perpSetup.quoter.interface.encodeFunctionData("swap", [ + { + baseToken: subjectVETH.address, + isBaseToQuote: subjectIsBaseToQuote, + isExactInput: subjectIsExactInput, + amount: subjectTradeQuoteAmount, + sqrtPriceLimitX96: subjectSqrtPriceLimitX96, + } + ]); + + expect(target).to.eq(subjectQuoter); + expect(value).to.eq(ZERO); + expect(calldata).to.eq(expectedCalldata); + }); + }); + + describe("#invokeSwap", async () => { + let subjectSetToken: Address; + let subjectQuoter: Address; + let subjectVETH: PerpV2BaseToken; + let subjectIsBaseToQuote: boolean; + let subjectIsExactInput: boolean; + let subjectTradeQuoteAmount: BigNumber; + let subjectSqrtPriceLimitX96: BigNumber; + beforeEach(async () => { + await invokeLibMock.testInvokeApprove(setToken.address, perpSetup.usdc.address, perpSetup.vault.address, MAX_UINT_256); + await perpLibMock.testInvokeDeposit(setToken.address, perpSetup.vault.address, perpSetup.usdc.address, ether(1)); + + subjectSetToken = setToken.address; + subjectQuoter = perpSetup.quoter.address; + subjectVETH = vETH; + subjectIsBaseToQuote = false; + subjectIsExactInput = true; + subjectTradeQuoteAmount = ether(1); + subjectSqrtPriceLimitX96 = ZERO; + }); + + // Need to callStatic this swap to get the return values + async function subject(callStatic: boolean): Promise { + const params = { + baseToken: subjectVETH.address, + isBaseToQuote: subjectIsBaseToQuote, + isExactInput: subjectIsExactInput, + amount: subjectTradeQuoteAmount, + sqrtPriceLimitX96: subjectSqrtPriceLimitX96, + }; + + return (callStatic) + ? await perpLibMock.callStatic.testInvokeSwap(subjectSetToken, subjectQuoter, params) + : await perpLibMock.testInvokeSwap(subjectSetToken, subjectQuoter, params); + } + + it("should return the same deltaBase & deltaQuote values as `openPosition`", async () => { + const { + deltaBase: expectedDeltaAvailableBase, + deltaQuote: expectedDeltaAvailableQuote + } = await perpLibMock.callStatic.testInvokeOpenPosition( + subjectSetToken, + perpSetup.clearingHouse.address, + { + baseToken: subjectVETH.address, + isBaseToQuote: subjectIsBaseToQuote, + isExactInput: subjectIsExactInput, + amount: subjectTradeQuoteAmount, + oppositeAmountBound: ZERO, + deadline: MAX_UINT_256, + sqrtPriceLimitX96: ZERO, + referralCode: ZERO_BYTES + } + ); + + const { + deltaAvailableBase: quotedDeltaAvailableBase, + deltaAvailableQuote: quotedDeltaAvailableQuote + } = await subject(true); + + expect(expectedDeltaAvailableBase).to.eq(quotedDeltaAvailableBase); + expect(expectedDeltaAvailableQuote).to.eq(quotedDeltaAvailableQuote); + }); + + it("should only simulate the trade", async () => { + const previousQuoteBalance = await await perpSetup.accountBalance.getQuote(setToken.address, subjectVETH.address); + await subject(false); + const currentQuoteBalance = await perpSetup.accountBalance.getQuote(setToken.address, subjectVETH.address); + + expect(currentQuoteBalance).to.eq(previousQuoteBalance); + }); + }); + + describe("#simulateTrade", async () => { + let subjectSetToken: Address; + let subjectBaseToken: Address; + let subjectIsBuy: boolean; + let subjectBaseTokenAmount: BigNumber; + let subjectOppositeAmountBound: BigNumber; + + beforeEach(async () => { + await invokeLibMock.testInvokeApprove(setToken.address, perpSetup.usdc.address, perpSetup.vault.address, MAX_UINT_256); + await perpLibMock.testInvokeDeposit(setToken.address, perpSetup.vault.address, perpSetup.usdc.address, ether(1)); + + subjectSetToken = setToken.address; + subjectBaseToken = vETH.address; + subjectIsBuy = true; + subjectBaseTokenAmount = ether(1); + subjectOppositeAmountBound = ZERO; + }); + + // Need to callStatic this swap to get the return values + async function subject(callStatic: boolean): Promise { + const actionInfo = { + setToken: subjectSetToken, + baseToken: subjectBaseToken, + isBuy: subjectIsBuy, + baseTokenAmount: subjectBaseTokenAmount, + oppositeAmountBound: subjectOppositeAmountBound + }; + + return (callStatic) + ? await perpLibMock.callStatic.testSimulateTrade(actionInfo, perpSetup.quoter.address) + : await perpLibMock.testSimulateTrade(actionInfo, perpSetup.quoter.address); + } + + it("should return the same deltaBase & deltaQuote values as `invokeSwap`", async () => { + const { + deltaAvailableBase: expectedDeltaAvailableBase, + deltaAvailableQuote: expectedDeltaAvailableQuote + } = await perpLibMock.callStatic.testInvokeSwap( + subjectSetToken, + perpSetup.quoter.address, + { + baseToken: subjectBaseToken, + isBaseToQuote: !subjectIsBuy, + isExactInput: !subjectIsBuy, + amount: subjectBaseTokenAmount, + sqrtPriceLimitX96: ZERO + } + ); + + const [quotedDeltaAvailableBase, quotedDeltaAvailableQuote] = await subject(true); + + expect(expectedDeltaAvailableBase).to.eq(quotedDeltaAvailableBase); + expect(expectedDeltaAvailableQuote).to.eq(quotedDeltaAvailableQuote); + }); + + it("should only simulate the trade", async () => { + const previousQuoteBalance = await await perpSetup.accountBalance.getQuote(setToken.address, subjectBaseToken); + await subject(false); + const currentQuoteBalance = await perpSetup.accountBalance.getQuote(setToken.address, subjectBaseToken); + + expect(currentQuoteBalance).to.eq(previousQuoteBalance); + }); + }); + + describe("#executeTrade", async () => { + let subjectSetToken: Address; + let subjectBaseToken: Address; + let subjectIsBuy: boolean; + let subjectBaseTokenAmount: BigNumber; + let subjectOppositeAmountBound: BigNumber; + + beforeEach(async () => { + await invokeLibMock.testInvokeApprove(setToken.address, perpSetup.usdc.address, perpSetup.vault.address, MAX_UINT_256); + await perpLibMock.testInvokeDeposit(setToken.address, perpSetup.vault.address, perpSetup.usdc.address, ether(1)); + + subjectSetToken = setToken.address; + subjectBaseToken = vETH.address; + subjectIsBuy = true; + subjectBaseTokenAmount = ether(1); + subjectOppositeAmountBound = MAX_UINT_256; + }); + + async function subject(): Promise { + return await perpLibMock.testExecuteTrade( + { + setToken: subjectSetToken, + baseToken: subjectBaseToken, + isBuy: subjectIsBuy, + baseTokenAmount: subjectBaseTokenAmount, + oppositeAmountBound: subjectOppositeAmountBound + }, + perpSetup.clearingHouse.address + ); + } + + it("should execute a trade", async () => { + const previousBaseBalance = await perpSetup.accountBalance.getBase(setToken.address, subjectBaseToken); + + await subject(); + + const currentBaseBalance = await perpSetup.accountBalance.getBase(setToken.address, subjectBaseToken); + const currentQuoteBalance = await perpSetup.accountBalance.getQuote(setToken.address, subjectBaseToken); + + const expectedBaseBalance = previousBaseBalance.add(subjectBaseTokenAmount); + + expect(previousBaseBalance).to.eq(0); + expect(currentBaseBalance).to.be.eq(expectedBaseBalance); + expect(currentQuoteBalance).to.be.lt(0); + }); + + describe("when isBuy is false", async () => { + beforeEach(async () => { + subjectIsBuy = false; + subjectOppositeAmountBound = ZERO; + }); + + async function subject(): Promise { + return await perpLibMock.testExecuteTrade( + { + setToken: subjectSetToken, + baseToken: subjectBaseToken, + isBuy: subjectIsBuy, + baseTokenAmount: subjectBaseTokenAmount, + oppositeAmountBound: subjectOppositeAmountBound + }, + perpSetup.clearingHouse.address + ); + } + + it("should execute a trade", async () => { + const previousBaseBalance = await perpSetup.accountBalance.getBase(setToken.address, subjectBaseToken); + + await subject(); + + const currentBaseBalance = await perpSetup.accountBalance.getBase(setToken.address, subjectBaseToken); + const currentQuoteBalance = await perpSetup.accountBalance.getQuote(setToken.address, subjectBaseToken); + + const expectedBaseBalance = previousBaseBalance.sub(subjectBaseTokenAmount); + + expect(previousBaseBalance).to.eq(0); + expect(currentBaseBalance).to.be.eq(expectedBaseBalance); + expect(currentQuoteBalance).to.be.gt(0); + }); + }); + }); +}); diff --git a/test/protocol/integration/lib/perpV2Positions.spec.ts b/test/protocol/integration/lib/perpV2Positions.spec.ts new file mode 100644 index 000000000..f2d0098ab --- /dev/null +++ b/test/protocol/integration/lib/perpV2Positions.spec.ts @@ -0,0 +1,427 @@ +import "module-alias/register"; + +import { Address } from "@utils/types"; +import { Account } from "@utils/test/types"; +import { + PerpV2, + PerpV2Positions, + PerpV2LeverageModule, + DebtIssuanceMock, + PerpV2PositionsMock, + StandardTokenMock, + SetToken, +} from "@utils/contracts"; + +import { PerpV2BaseToken } from "@utils/contracts/perpV2"; + +import DeployHelper from "@utils/deploys"; +import { + ether, + bitcoin, + usdc as usdcUnits, + preciseDiv, + preciseMul +} from "@utils/index"; + +import { + cacheBeforeEach, + getAccounts, + getWaffleExpect, + getSystemFixture, + getPerpV2Fixture, +} from "@utils/test/index"; + +import { PerpV2Fixture, SystemFixture } from "@utils/fixtures"; +import { ADDRESS_ZERO, ZERO } from "@utils/constants"; +import { BigNumber } from "ethers"; + +const expect = getWaffleExpect(); + +describe("PerpV2Positions", () => { + let owner: Account; + let maker: Account; + let otherTrader: Account; + let mockModule: Account; + let deployer: DeployHelper; + let maxPerpPositionsPerSet: BigNumber; + + let perpLib: PerpV2; + let perpLeverageModule: PerpV2LeverageModule; + let perpPositionsLib: PerpV2Positions; + let perpPositionsMock: PerpV2PositionsMock; + let debtIssuanceMock: DebtIssuanceMock; + let setup: SystemFixture; + let perpSetup: PerpV2Fixture; + + let vETH: PerpV2BaseToken; + let vBTC: PerpV2BaseToken; + let usdc: StandardTokenMock; + + cacheBeforeEach(async () => { + [ + owner, + maker, + otherTrader, + mockModule, + ] = await getAccounts(); + + deployer = new DeployHelper(owner.wallet); + setup = getSystemFixture(owner.address); + await setup.initialize(); + + perpSetup = getPerpV2Fixture(owner.address); + await perpSetup.initialize(maker, otherTrader); + + // set funding rate to zero; allows us to avoid calculating small amounts of funding + // accrued in our test cases + await perpSetup.clearingHouseConfig.setMaxFundingRate(ZERO); + + vETH = perpSetup.vETH; + vBTC = perpSetup.vBTC; + usdc = perpSetup.usdc; + + // Create liquidity + await perpSetup.setBaseTokenOraclePrice(vETH, usdcUnits(10)); + await perpSetup.initializePoolWithLiquidityWide( + vETH, + ether(10_000), + ether(100_000) + ); + + await perpSetup.setBaseTokenOraclePrice(vBTC, usdcUnits(20)); + await perpSetup.initializePoolWithLiquidityWide( + vBTC, + ether(10_000), + ether(200_000) + ); + + debtIssuanceMock = await deployer.mocks.deployDebtIssuanceMock(); + await setup.controller.addModule(debtIssuanceMock.address); + + maxPerpPositionsPerSet = BigNumber.from(2); + perpLib = await deployer.libraries.deployPerpV2(); + perpLeverageModule = await deployer.modules.deployPerpV2LeverageModule( + setup.controller.address, + perpSetup.vault.address, + perpSetup.quoter.address, + perpSetup.marketRegistry.address, + maxPerpPositionsPerSet, + "contracts/protocol/integration/lib/PerpV2.sol:PerpV2", + perpLib.address + ); + await setup.controller.addModule(perpLeverageModule.address); + + await setup.integrationRegistry.addIntegration( + perpLeverageModule.address, + "DefaultIssuanceModule", + debtIssuanceMock.address + ); + + perpPositionsLib = await deployer.libraries.deployPerpV2Positions(); + perpPositionsMock = await deployer.mocks.deployPerpV2PositionsMock( + "contracts/protocol/integration/lib/PerpV2Positions.sol:PerpV2Positions", + perpPositionsLib.address + ); + }); + + /** + * HELPERS + */ + + // Creates SetToken, issues sets (default: 1), initializes PerpV2LeverageModule and deposits to Perp + async function issueSetsAndDepositToPerp( + depositQuantityUnit: BigNumber, + isInitialized: boolean = true, + issueQuantity: BigNumber = ether(1), + skipMockModuleInitialization = false + ): Promise { + const setToken = await setup.createSetToken( + [setup.wbtc.address, usdc.address, setup.weth.address], + [bitcoin(10), usdcUnits(100), ether(10)], + [perpLeverageModule.address, debtIssuanceMock.address, setup.issuanceModule.address] + ); + + if (isInitialized) { + await debtIssuanceMock.initialize(setToken.address); + await perpLeverageModule.updateAllowedSetToken(setToken.address, true); + + await perpLeverageModule.connect(owner.wallet).initialize(setToken.address); + + // Initialize mock module + if (!skipMockModuleInitialization) { + await setup.controller.addModule(mockModule.address); + await setToken.addModule(mockModule.address); + await setToken.connect(mockModule.wallet).initializeModule(); + } + + await usdc.approve(setup.issuanceModule.address, preciseMul(usdcUnits(100), issueQuantity)); + await setup.issuanceModule.initialize(setToken.address, ADDRESS_ZERO); + await setup.issuanceModule.issue(setToken.address, issueQuantity, owner.address); + + await perpLeverageModule.deposit(setToken.address, depositQuantityUnit); + } + + return setToken; + } + + describe("#getNetQuoteBalance", () => { + let setToken: SetToken; + + let subjectSetToken: Address; + let subjectBaseTokens: Address[]; + let subjectPerpAccountBalance: Address; + + beforeEach(async () => { + // Issue 2 sets + const issueQuantity = ether(2); + setToken = await issueSetsAndDepositToPerp(usdcUnits(100), true, issueQuantity); + + await perpLeverageModule.connect(owner.wallet).trade( + setToken.address, + vETH.address, + preciseDiv(ether(1), issueQuantity), + preciseDiv(ether(10.15), issueQuantity) + ); + + await perpLeverageModule.connect(owner.wallet).trade( + setToken.address, + vBTC.address, + preciseDiv(ether(1), issueQuantity), + preciseDiv(ether(50.575), issueQuantity) + ); + + subjectSetToken = setToken.address; + subjectBaseTokens = [vETH.address, vBTC.address]; + subjectPerpAccountBalance = perpSetup.accountBalance.address; + }); + + async function subject(): Promise { + return perpPositionsMock.testGetNetQuoteBalance( + subjectSetToken, + subjectBaseTokens, + subjectPerpAccountBalance + ); + } + + it("should return correct net quote balance", async () => { + const vETHQuoteBalance = await perpSetup.accountBalance.getQuote(subjectSetToken, vETH.address); + const vBTCQuoteBalance = await perpSetup.accountBalance.getQuote(subjectSetToken, vBTC.address); + const expectedNetQuoteBalance = vETHQuoteBalance.add(vBTCQuoteBalance); + + const netQuoteBalance = await subject(); + + expect(netQuoteBalance).to.be.eq(expectedNetQuoteBalance); + }); + }); + + describe("#getPositionNotionalInfo", () => { + let setToken: SetToken; + let issueQuantity: BigNumber; + let expectedVETHToken: Address; + let expectedVBTCToken: Address; + let vethTradeQuantityUnits: BigNumber; + let vbtcTradeQuantityUnits: BigNumber; + let expectedDepositQuantity: BigNumber; + let expectedVETHDeltaQuote: BigNumber; + let expectedVBTCDeltaQuote: BigNumber; + + let subjectSetToken: Address; + let subjectBaseTokens: Address[]; + let subjectPerpAccountBalance: Address; + + beforeEach(async () => { + expectedDepositQuantity = usdcUnits(100); + issueQuantity = ether(2); + + setToken = await issueSetsAndDepositToPerp(expectedDepositQuantity, true, issueQuantity); + + expectedVETHToken = vETH.address; + expectedVBTCToken = vBTC.address; + vethTradeQuantityUnits = ether(1); + vbtcTradeQuantityUnits = ether(2); + + ({ deltaQuote: expectedVETHDeltaQuote } = await perpSetup.getSwapQuote( + expectedVETHToken, + preciseMul(vethTradeQuantityUnits, issueQuantity), + true + )); + + ({ deltaQuote: expectedVBTCDeltaQuote } = await perpSetup.getSwapQuote( + expectedVBTCToken, + preciseMul(vbtcTradeQuantityUnits, issueQuantity), + true + )); + + const vETHQuoteBoundQuantityUnits = ether(10.15); + const vBTCQuoteBoundQuantityUnits = ether(101); + + await perpLeverageModule.connect(owner.wallet).trade( + setToken.address, + expectedVETHToken, + vethTradeQuantityUnits, + vETHQuoteBoundQuantityUnits + ); + + await perpLeverageModule.connect(owner.wallet).trade( + setToken.address, + expectedVBTCToken, + vbtcTradeQuantityUnits, + vBTCQuoteBoundQuantityUnits + ); + + subjectSetToken = setToken.address; + subjectBaseTokens = [vETH.address, vBTC.address]; + subjectPerpAccountBalance = perpSetup.accountBalance.address; + }); + + async function subject(): Promise { + return perpPositionsMock.testGetPositionNotionalInfo( + subjectSetToken, + subjectBaseTokens, + subjectPerpAccountBalance + ); + } + + it("should return correct notional info for multiple positions", async () => { + const positionInfo = await subject(); + + const expectedVETHNotionalPosition = preciseMul(vethTradeQuantityUnits, issueQuantity); + const expectedVBTCNotionalPosition = preciseMul(vbtcTradeQuantityUnits, issueQuantity); + + expect(positionInfo.length).eq(2); + expect(positionInfo[0].baseToken).eq(expectedVETHToken); + expect(positionInfo[1].baseToken).eq(expectedVBTCToken); + expect(positionInfo[0].baseBalance).eq(expectedVETHNotionalPosition); + expect(positionInfo[1].baseBalance).eq(expectedVBTCNotionalPosition); + expect(positionInfo[0].quoteBalance).eq(expectedVETHDeltaQuote.mul(-1)); + expect(positionInfo[1].quoteBalance).eq(expectedVBTCDeltaQuote.mul(-1)); + }); + }); + + describe("#getPositionUnitInfo", () => { + let setToken: SetToken; + let issueQuantity: BigNumber; + let expectedVETHToken: Address; + let expectedVBTCToken: Address; + let vethTradeQuantityUnits: BigNumber; + let vbtcTradeQuantityUnits: BigNumber; + let expectedDepositQuantity: BigNumber; + let expectedVETHQuoteUnits: BigNumber; + let expectedVBTCQuoteUnits: BigNumber; + + let subjectSetToken: Address; + let subjectBaseTokens: Address[]; + let subjectPerpAccountBalance: Address; + + beforeEach(async () => { + issueQuantity = ether(2); + expectedDepositQuantity = usdcUnits(100); + + // Issue 2 sets + setToken = await issueSetsAndDepositToPerp(expectedDepositQuantity, true, issueQuantity); + + expectedVETHToken = vETH.address; + expectedVBTCToken = vBTC.address; + vethTradeQuantityUnits = preciseDiv(ether(1), issueQuantity); + vbtcTradeQuantityUnits = preciseDiv(ether(1), issueQuantity); + + const vETHQuoteBoundQuantityUnits = preciseDiv(ether(10.15), issueQuantity); + const vBTCQuoteBoundQuantityUnits = preciseDiv(ether(50.575), issueQuantity); + + await perpLeverageModule.connect(owner.wallet).trade( + setToken.address, + expectedVETHToken, + vethTradeQuantityUnits, + vETHQuoteBoundQuantityUnits + ); + + await perpLeverageModule.connect(owner.wallet).trade( + setToken.address, + expectedVBTCToken, + vbtcTradeQuantityUnits, + vBTCQuoteBoundQuantityUnits + ); + + subjectSetToken = setToken.address; + subjectBaseTokens = [vETH.address, vBTC.address]; + subjectPerpAccountBalance = perpSetup.accountBalance.address; + }); + + async function subject(): Promise { + return perpPositionsMock.testGetPositionUnitInfo( + subjectSetToken, + subjectBaseTokens, + subjectPerpAccountBalance + ); + } + + it("should return correct unit info for multiple positions", async () => { + const vETHQuoteBalance = await perpSetup.accountBalance.getQuote(subjectSetToken, expectedVETHToken); + const vBTCQuoteBalance = await perpSetup.accountBalance.getQuote(subjectSetToken, expectedVBTCToken); + + expectedVETHQuoteUnits = preciseDiv(vETHQuoteBalance, issueQuantity); + expectedVBTCQuoteUnits = preciseDiv(vBTCQuoteBalance, issueQuantity); + + const positionInfo = await subject(); + + expect(positionInfo.length).eq(2); + expect(positionInfo[0].baseToken).eq(expectedVETHToken); + expect(positionInfo[1].baseToken).eq(expectedVBTCToken); + expect(positionInfo[0].baseUnit).eq(vethTradeQuantityUnits); + expect(positionInfo[1].baseUnit).eq(vbtcTradeQuantityUnits); + expect(positionInfo[0].quoteUnit).eq(expectedVETHQuoteUnits); + expect(positionInfo[1].quoteUnit).eq(expectedVBTCQuoteUnits); + }); + }); + + describe("#formatAdjustments", () => { + let setToken: SetToken; + + let subjectSetToken: Address; + let subjectAdjustmentComponent: Address; + let subjectCurrentExternalPositionUnit: BigNumber; + let subjectNewExternalPositionUnit: BigNumber; + + beforeEach(async () => { + // Issue 2 sets + const issueQuantity = ether(2); + setToken = await issueSetsAndDepositToPerp(usdcUnits(100), true, issueQuantity); + + subjectSetToken = setToken.address; + subjectAdjustmentComponent = perpSetup.usdc.address; + subjectCurrentExternalPositionUnit = usdcUnits(50); + subjectNewExternalPositionUnit = usdcUnits(100); + }); + + async function subject(): Promise { + return perpPositionsMock.testFormatAdjustments( + subjectSetToken, + subjectAdjustmentComponent, + subjectCurrentExternalPositionUnit, + subjectNewExternalPositionUnit + ); + } + + it("should return correct equity and debt adjustments", async () => { + const components = await setToken.getComponents(); + const expectedEquityAdjustments = await Promise.all( + components.map(async (value): Promise => { + if (value === subjectAdjustmentComponent) { + return subjectNewExternalPositionUnit.sub(subjectCurrentExternalPositionUnit); + } + return ZERO; + }) + ); + const expectedDebtAdjustments = components.map(() => ZERO); + + const [equityAdjustments, debtAdjustments] = await subject(); + + equityAdjustments.map((value: BigNumber, index: number) => + expect(value).to.be.eq(expectedEquityAdjustments[index]) + ); + debtAdjustments.map((value: BigNumber, index: number) => + expect(value).to.be.eq(expectedDebtAdjustments[index]) + ); + }); + }); +}); \ No newline at end of file diff --git a/test/protocol/lib/moduleBaseV2.spec.ts b/test/protocol/lib/moduleBaseV2.spec.ts new file mode 100644 index 000000000..a342043d7 --- /dev/null +++ b/test/protocol/lib/moduleBaseV2.spec.ts @@ -0,0 +1,675 @@ +import "module-alias/register"; + +import { BigNumber } from "ethers"; +import { Address } from "@utils/types"; +import { Account } from "@utils/test/types"; +import { ModuleBaseV2Mock, SetToken, StandardTokenMock, StandardTokenWithFeeMock } from "@utils/contracts"; +import { MAX_UINT_256, ZERO } from "@utils/constants"; +import DeployHelper from "@utils/deploys"; +import { + ether, + hashAdapterName, + preciseMul, +} from "@utils/index"; +import { + getAccounts, + getRandomAddress, + getRandomAccount, + getSystemFixture, + getWaffleExpect, + addSnapshotBeforeRestoreAfterEach, +} from "@utils/test/index"; +import { SystemFixture } from "@utils/fixtures"; + +const expect = getWaffleExpect(); + +describe("ModuleBaseV2", () => { + let owner: Account; + let otherAccount: Account; + let deployer: DeployHelper; + + let setup: SystemFixture; + let moduleBase: ModuleBaseV2Mock; + let dummyModule: Account; + + before(async () => { + [ + owner, + otherAccount, + dummyModule, + ] = await getAccounts(); + + setup = getSystemFixture(owner.address); + await setup.initialize(); + deployer = new DeployHelper(owner.wallet); + }); + + addSnapshotBeforeRestoreAfterEach(); + + describe("#constructor", async () => { + let subjectModuleBase: ModuleBaseV2Mock; + + async function subject(): Promise { + return deployer.mocks.deployModuleBaseV2Mock(setup.controller.address); + } + + it("should have the correct controller", async () => { + subjectModuleBase = await subject(); + const expectedController = await subjectModuleBase.controller(); + expect(expectedController).to.eq(setup.controller.address); + }); + }); + + context("when there is a SetToken with an enabled module", async () => { + let setToken: SetToken; + let setTokenManager: Address; + + beforeEach(async () => { + moduleBase = await deployer.mocks.deployModuleBaseV2Mock(setup.controller.address); + + await setup.controller.addModule(moduleBase.address); + await setup.controller.addModule(dummyModule.address); + + setTokenManager = owner.address; + + setToken = await setup.createSetToken( + [setup.weth.address, setup.usdc.address], + [ether(1), ether(200)], + [moduleBase.address, dummyModule.address], + setTokenManager, + ); + }); + + describe("#testGetAndValidateAdapter", async () => { + let subjectIntegrationName: Address; + let adapterAddress: Address; + + beforeEach(async () => { + subjectIntegrationName = "CURVE"; + adapterAddress = otherAccount.address; + await setup.integrationRegistry.addIntegration( + moduleBase.address, + subjectIntegrationName, + adapterAddress + ); + }); + + async function subject(): Promise { + return moduleBase.testGetAndValidateAdapter(subjectIntegrationName); + } + + it("should return the correct adapter", async () => { + const adapter = await subject(); + expect(adapter).to.eq(adapterAddress); + }); + + describe("when the adapter name has an invalid adapter", async () => { + beforeEach(async () => { + subjectIntegrationName = "NA"; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be valid adapter"); + }); + }); + }); + + describe("#testGetAndValidateAdapterWithHash", async () => { + let subjectIntegrationHash: Address; + let adapterAddress: Address; + + beforeEach(async () => { + const integrationName = "CURVE"; + adapterAddress = otherAccount.address; + await setup.integrationRegistry.addIntegration( + moduleBase.address, + integrationName, + adapterAddress + ); + + subjectIntegrationHash = hashAdapterName(integrationName); + }); + + async function subject(): Promise { + return moduleBase.testGetAndValidateAdapterWithHash(subjectIntegrationHash); + } + + it("should return the correct adapter", async () => { + const adapter = await subject(); + expect(adapter).to.eq(adapterAddress); + }); + + describe("when the adapter name has an invalid adapter", async () => { + beforeEach(async () => { + subjectIntegrationHash = hashAdapterName("NA"); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be valid adapter"); + }); + }); + }); + + describe("#testGetModuleFee", async () => { + let subjectFeeIndex: BigNumber; + let subjectQuantity: BigNumber; + + let feePercentage: BigNumber; + + beforeEach(async () => { + subjectFeeIndex = ZERO; + subjectQuantity = ether(1); + feePercentage = ether(0.05); // 5% + await setup.controller.addFee( + moduleBase.address, + subjectFeeIndex, + feePercentage + ); + }); + + async function subject(): Promise { + return moduleBase.testGetModuleFee(subjectFeeIndex, subjectQuantity); + } + + it("should return the correct fee", async () => { + const returnedFee = await subject(); + const expectedFee = preciseMul(subjectQuantity, feePercentage); + expect(returnedFee).to.eq(expectedFee); + }); + }); + + describe("#testPayProtocolFeeFromSetToken", async () => { + let subjectSetToken: Address; + let subjectComponent: Address; + let subjectFeeQuantity: BigNumber; + + let feeRecipient: Address; + + beforeEach(async () => { + subjectSetToken = setToken.address; + subjectComponent = setup.dai.address; + subjectFeeQuantity = ether(1); + + feeRecipient = otherAccount.address; + + await setup.dai.transfer(subjectSetToken, subjectFeeQuantity); + await setup.controller.editFeeRecipient(feeRecipient); + + await moduleBase.initializeModuleOnSet(subjectSetToken); + }); + + async function subject(): Promise { + return moduleBase.testPayProtocolFeeFromSetToken( + subjectSetToken, + subjectComponent, + subjectFeeQuantity + ); + } + + it("should send the fee the the fee recipient", async () => { + await subject(); + const retrievedFeeRecipientBalance = await setup.dai.balanceOf(feeRecipient); + expect(retrievedFeeRecipientBalance).to.eq(subjectFeeQuantity); + }); + }); + + describe("#testTransferFrom", async () => { + let token: StandardTokenMock; + let quantity: BigNumber; + + let subjectTokenAddress: Address; + let subjectFromAddress: Address; + let subjectToAddress: Address; + let subjectQuantity: BigNumber; + + beforeEach(async () => { + token = await deployer.mocks.deployTokenMock(owner.address); + + token = token.connect(owner.wallet); + await token.connect(owner.wallet).approve(moduleBase.address, MAX_UINT_256); + + quantity = ether(1); + + subjectTokenAddress = token.address; + subjectFromAddress = owner.address; + subjectToAddress = otherAccount.address; + subjectQuantity = ether(1); + }); + + async function subject(): Promise { + return moduleBase.testTransferFrom( + subjectTokenAddress, + subjectFromAddress, + subjectToAddress, + subjectQuantity + ); + } + + it("should decrement the balance of the from address", async () => { + const previousBalance = await token.balanceOf(owner.address); + + await subject(); + + const newBalance = await token.balanceOf(owner.address); + const expectedBalance = previousBalance.sub(quantity); + + await expect(newBalance).to.eq(expectedBalance); + }); + + it("should increment the balance of the to address", async () => { + const previousBalance = await token.balanceOf(subjectToAddress); + + await subject(); + + const newBalance = await token.balanceOf(subjectToAddress); + const expectedBalance = previousBalance.add(quantity); + + await expect(newBalance).to.eq(expectedBalance); + }); + + describe("when the transfer quantity is 0", async () => { + beforeEach(async () => { + subjectQuantity = ZERO; + }); + + it("should not change the balance of the user", async () => { + const previousBalance = await token.balanceOf(subjectToAddress); + + await subject(); + + const newBalance = await token.balanceOf(subjectToAddress); + + await expect(newBalance).to.eq(previousBalance); + }); + }); + + describe("when the token has a transfer fee", async () => { + let mockTokenWithFee: StandardTokenWithFeeMock; + + beforeEach(async () => { + mockTokenWithFee = await deployer.mocks.deployTokenWithFeeMock(owner.address); + + mockTokenWithFee = mockTokenWithFee.connect(owner.wallet); + await mockTokenWithFee.approve(moduleBase.address, MAX_UINT_256); + + subjectTokenAddress = mockTokenWithFee.address; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Invalid post transfer balance"); + }); + }); + + describe("when the token is not approved for transfer", async () => { + beforeEach(async () => { + await token.approve(moduleBase.address, ZERO); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("ERC20: transfer amount exceeds allowance"); + }); + }); + }); + + describe("#testIsSetPendingInitialization", async () => { + let subjectSetTokenAddress: Address; + + beforeEach(async () => { + subjectSetTokenAddress = setToken.address; + }); + + async function subject(): Promise { + return moduleBase.testIsSetPendingInitialization(subjectSetTokenAddress); + } + + it("should return true", async () => { + const isModulePending = await subject(); + expect(isModulePending).to.eq(true); + }); + + describe("when the SetToken has not put the module into a pending state", async () => { + beforeEach(async () => { + const nonPendingModule = await getRandomAddress(); + await setup.controller.addModule(nonPendingModule); + + const setTokenWithNonPendingModule = await setup.createSetToken( + [setup.weth.address, setup.usdc.address], + [ether(1), ether(200)], + [nonPendingModule] + ); + subjectSetTokenAddress = setTokenWithNonPendingModule.address; + }); + + it("should return false", async () => { + const isModulePending = await subject(); + expect(isModulePending).to.eq(false); + }); + }); + }); + + describe("#testIsSetManager", async () => { + let subjectSetTokenAddress: Address; + let subjectAddressToCheck: Address; + + beforeEach(async () => { + subjectSetTokenAddress = setToken.address; + subjectAddressToCheck = owner.address; + }); + + async function subject(): Promise { + return moduleBase.testIsSetManager(subjectSetTokenAddress, subjectAddressToCheck); + } + + it("should return true when the testOnlySetManager is calling", async () => { + const isManager = await subject(); + expect(isManager).to.eq(true); + }); + + describe("when the caller is not the manager", async () => { + beforeEach(async () => { + subjectAddressToCheck = await getRandomAddress(); + }); + + it("should return false", async () => { + const isManager = await subject(); + expect(isManager).to.eq(false); + }); + }); + }); + + describe("#testIsSetValidAndInitialized", async () => { + let subjectSetTokenAddress: Address; + + beforeEach(async () => { + await moduleBase.initializeModuleOnSet(setToken.address); + subjectSetTokenAddress = setToken.address; + }); + + async function subject(): Promise { + return moduleBase.testIsSetValidAndInitialized(subjectSetTokenAddress); + } + + it("should return true when the module is enabled on the SetToken", async () => { + const isValidSet = await subject(); + expect(isValidSet).to.eq(true); + }); + + describe("when the module is not enabled on the Set", async () => { + beforeEach(async () => { + const nonPendingModule = await getRandomAddress(); + await setup.controller.addModule(nonPendingModule); + const setTokenWithNonPendingModule = await setup.createSetToken( + [setup.weth.address, setup.usdc.address], + [ether(1), ether(200)], + [nonPendingModule] + ); + subjectSetTokenAddress = setTokenWithNonPendingModule.address; + }); + + it("should return false", async () => { + const isManager = await subject(); + expect(isManager).to.eq(false); + }); + }); + + describe("when the module is enabled on the Set, but not enabled on the Controller", async () => { + beforeEach(async () => { + const nonEnabledSetToken = await deployer.core.deploySetToken( + [setup.weth.address, setup.usdc.address], + [ether(1), ether(200)], + [moduleBase.address], + setup.controller.address, + owner.address, + "SET", + "SET" + ); + subjectSetTokenAddress = nonEnabledSetToken.address; + }); + + it("should return false", async () => { + const isManager = await subject(); + expect(isManager).to.eq(false); + }); + }); + }); + + describe("#testOnlyManagerAndValidSet", async () => { + let subjectCaller: Account; + let subjectSetTokenAddress: Address; + + beforeEach(async () => { + subjectCaller = owner; + + await moduleBase.initializeModuleOnSet(setToken.address); + subjectSetTokenAddress = setToken.address; + }); + + async function subject(): Promise { + moduleBase = moduleBase.connect(subjectCaller.wallet); + return moduleBase.testOnlyManagerAndValidSet(subjectSetTokenAddress); + } + + it("should not revert if the manager is calling", async () => { + await expect(subject()).to.not.be.reverted; + }); + + describe("when called by a different address", 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 enabled on the Set", async () => { + beforeEach(async () => { + const nonPendingModule = await getRandomAddress(); + await setup.controller.addModule(nonPendingModule); + const setTokenWithNonPendingModule = await setup.createSetToken( + [setup.weth.address, setup.usdc.address], + [ether(1), ether(200)], + [nonPendingModule] + ); + subjectSetTokenAddress = setTokenWithNonPendingModule.address; + }); + + it("should revert", async () => { + await expect(subject()).to.be.reverted; + }); + }); + + describe("when the module is enabled on the Set, but not enabled on the Controller", async () => { + beforeEach(async () => { + const nonEnabledSetToken = await deployer.core.deploySetToken( + [setup.weth.address, setup.usdc.address], + [ether(1), ether(200)], + [moduleBase.address], + setup.controller.address, + owner.address, + "SET", + "SET" + ); + subjectSetTokenAddress = nonEnabledSetToken.address; + }); + + it("should revert", async () => { + await expect(subject()).to.be.reverted; + }); + }); + }); + + describe("#testOnlySetManager", async () => { + let subjectCaller: Account; + + beforeEach(async () => { + subjectCaller = owner; + }); + + async function subject(): Promise { + moduleBase = moduleBase.connect(subjectCaller.wallet); + return moduleBase.testOnlySetManager(setToken.address); + } + + it("should not revert if the manager is calling", async () => { + await expect(subject()).to.not.be.reverted; + }); + + describe("when called by a different address", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be the SetToken manager"); + }); + }); + }); + + describe("#testOnlyModule", async () => { + let subjectSetTokenAddress: Address; + let subjectCaller: Account; + + beforeEach(async () => { + await setToken.connect(dummyModule.wallet).initializeModule(); + + subjectSetTokenAddress = setToken.address; + subjectCaller = dummyModule; + }); + + async function subject(): Promise { + return moduleBase.connect(subjectCaller.wallet).testOnlyModule(subjectSetTokenAddress); + } + + it("should not revert if an approved module is calling", async () => { + await expect(subject()).to.not.be.reverted; + }); + + describe("when the caller is not an approved module", async () => { + beforeEach(async () => { + subjectCaller = owner; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Only the module can call"); + }); + }); + }); + + describe("#testOnlyValidInitialization", async () => { + let subjectSetTokenAddress: Address; + + beforeEach(async () => { + subjectSetTokenAddress = setToken.address; + }); + + async function subject(): Promise { + return moduleBase.testOnlyValidInitialization(subjectSetTokenAddress); + } + + it("should not revert", async () => { + await expect(subject()).to.not.be.reverted; + }); + + describe("when the module is in NONE state on the Set", async () => { + beforeEach(async () => { + const nonPendingModule = await getRandomAddress(); + await setup.controller.addModule(nonPendingModule); + const setTokenWithNonPendingModule = await setup.createSetToken( + [setup.weth.address, setup.usdc.address], + [ether(1), ether(200)], + [nonPendingModule] + ); + subjectSetTokenAddress = setTokenWithNonPendingModule.address; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be pending initialization"); + }); + }); + + describe("when the module is in initialized state on the Set", async () => { + beforeEach(async () => { + await moduleBase.initializeModuleOnSet(setToken.address); + }); + + 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 deployer.core.deploySetToken( + [setup.weth.address, setup.usdc.address], + [ether(1), ether(200)], + [moduleBase.address], + setup.controller.address, + owner.address, + "SET", + "SET" + ); + subjectSetTokenAddress = nonEnabledSetToken.address; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be controller-enabled SetToken"); + }); + }); + }); + + describe("#testOnlyValidAndInitializedSet", async () => { + let subjectSetTokenAddress: Address; + + beforeEach(async () => { + await moduleBase.initializeModuleOnSet(setToken.address); + subjectSetTokenAddress = setToken.address; + }); + + async function subject(): Promise { + return moduleBase.testOnlyValidAndInitializedSet(subjectSetTokenAddress); + } + + it("should not revert", async () => { + await expect(subject()).to.not.be.reverted; + }); + + describe("when the module is not enabled on the Set", async () => { + beforeEach(async () => { + const nonPendingModule = await getRandomAddress(); + await setup.controller.addModule(nonPendingModule); + const setTokenWithNonPendingModule = await setup.createSetToken( + [setup.weth.address, setup.usdc.address], + [ether(1), ether(200)], + [nonPendingModule] + ); + subjectSetTokenAddress = setTokenWithNonPendingModule.address; + }); + + it("should revert", async () => { + await expect(subject()).to.be.reverted; + }); + }); + + describe("when the module is enabled on the Set, but not enabled on the Controller", async () => { + beforeEach(async () => { + const nonEnabledSetToken = await deployer.core.deploySetToken( + [setup.weth.address, setup.usdc.address], + [ether(1), ether(200)], + [moduleBase.address], + setup.controller.address, + owner.address, + "SET", + "SET" + ); + subjectSetTokenAddress = nonEnabledSetToken.address; + }); + + it("should revert", async () => { + await expect(subject()).to.be.reverted; + }); + }); + }); + }); +}); diff --git a/test/protocol/lib/positionV2.spec.ts b/test/protocol/lib/positionV2.spec.ts new file mode 100644 index 000000000..5203de7ed --- /dev/null +++ b/test/protocol/lib/positionV2.spec.ts @@ -0,0 +1,797 @@ +import "module-alias/register"; +import { BigNumber } from "ethers"; +import { PositionV2, PositionV2Mock, SetToken, StandardTokenMock } from "@utils/contracts"; +import DeployHelper from "@utils/deploys"; +import { + ether, + preciseMul, + preciseDiv, + preciseDivCeil, +} from "@utils/index"; +import { + getRandomAddress, + getSystemFixture, + getWaffleExpect, + getAccounts, + addSnapshotBeforeRestoreAfterEach, +} from "@utils/test/index"; + +import { Address } from "@utils/types"; +import { Account } from "@utils/test/types"; +import { ZERO, PRECISE_UNIT, ADDRESS_ZERO, ONE } from "@utils/constants"; +import { SystemFixture } from "@utils/fixtures"; + +const expect = getWaffleExpect(); + +describe("PositionV2", () => { + let owner: Account, moduleOne: Account, moduleTwo: Account; + let setToken: SetToken; + let deployer: DeployHelper; + let setup: SystemFixture; + + let positionLib: PositionV2; + let componentOne: StandardTokenMock; + let componentTwo: StandardTokenMock; + let componentThree: StandardTokenMock; + + let components: Address[]; + let units: BigNumber[]; + let modules: Address[]; + + let positionLibMock: PositionV2Mock; + + before(async () => { + [ + owner, + moduleOne, + moduleTwo, + ] = await getAccounts(); + + deployer = new DeployHelper(owner.wallet); + setup = getSystemFixture(owner.address); + await setup.initialize(); + + positionLib = await deployer.libraries.deployPositionV2(); + positionLibMock = await deployer.mocks.deployPositionV2Mock( + "contracts/protocol/lib/PositionV2.sol:PositionV2", + positionLib.address + ); + await setup.controller.addModule(positionLibMock.address); + await setup.controller.addModule(moduleOne.address); + + componentOne = await deployer.mocks.deployTokenMock(owner.address); + componentTwo = await deployer.mocks.deployTokenMock(owner.address); + componentThree = await deployer.mocks.deployTokenMock(owner.address); + + components = [componentOne.address, componentTwo.address]; + units = [ether(1), ether(2)]; + modules = [moduleOne.address, positionLibMock.address, setup.issuanceModule.address]; + + setToken = await setup.createSetToken(components, units, modules); + + setToken = setToken.connect(moduleOne.wallet); + await setToken.initializeModule(); + + await positionLibMock.initialize(setToken.address); + await setup.issuanceModule.initialize(setToken.address, ADDRESS_ZERO); + }); + + addSnapshotBeforeRestoreAfterEach(); + + describe("#hasDefaultPosition", async () => { + let subjectSetToken: Address; + let subjectComponent: Address; + + beforeEach(async () => { + subjectSetToken = setToken.address; + subjectComponent = componentTwo.address; + }); + + async function subject(): Promise { + return positionLibMock.testHasDefaultPosition(subjectSetToken, subjectComponent); + } + + it("should find the asked for position", async () => { + const isDefaultPositionFound = await subject(); + expect(isDefaultPositionFound).to.eq(true); + }); + + describe("when the component does not have a positive value on the SetToken", async () => { + beforeEach(async () => { + await setToken.connect(moduleOne.wallet).editDefaultPositionUnit(subjectComponent, ZERO); + }); + + it("should return false", async () => { + const isDefaultPositionFound = await subject(); + expect(isDefaultPositionFound).to.eq(false); + }); + }); + }); + + describe("#hasExternalPosition", async () => { + let subjectSetToken: Address; + let subjectComponent: Address; + + beforeEach(async () => { + subjectSetToken = setToken.address; + subjectComponent = componentTwo.address; + + await setToken.connect(moduleOne.wallet).addExternalPositionModule( + subjectComponent, + moduleOne.address, + ); + }); + + async function subject(): Promise { + return positionLibMock.testHasExternalPosition(subjectSetToken, subjectComponent); + } + + it("should find the asked for position", async () => { + const isDefaultPositionFound = await subject(); + expect(isDefaultPositionFound).to.eq(true); + }); + + describe("when the component does not have an external module", async () => { + beforeEach(async () => { + await setToken.connect(moduleOne.wallet).removeExternalPositionModule( + subjectComponent, + moduleOne.address, + ); + }); + + it("should return false", async () => { + const isDefaultPositionFound = await subject(); + expect(isDefaultPositionFound).to.eq(false); + }); + }); + }); + + describe("#hasSufficientDefaultUnits", async () => { + let subjectSetToken: Address; + let subjectComponent: Address; + let subjectUnit: BigNumber; + + beforeEach(async () => { + subjectSetToken = setToken.address; + subjectComponent = componentTwo.address; + subjectUnit = ether(2); + }); + + async function subject(): Promise { + return positionLibMock.testHasSufficientDefaultUnits( + subjectSetToken, + subjectComponent, + subjectUnit, + ); + } + + it("should return true", async () => { + const hasSufficientUnits = await subject(); + expect(hasSufficientUnits).to.eq(true); + }); + + describe("when the is less than the Position Unit", async () => { + beforeEach(async () => { + subjectUnit = ether(1); + }); + + it("should return false", async () => { + const hasSufficientUnits = await subject(); + expect(hasSufficientUnits).to.eq(true); + }); + }); + + describe("when the is more than the Position Unit", async () => { + beforeEach(async () => { + subjectUnit = ether(3); + }); + + it("should return false", async () => { + const hasSufficientUnits = await subject(); + expect(hasSufficientUnits).to.eq(false); + }); + }); + }); + + describe("#hasSufficientExternalUnits", async () => { + let subjectSetToken: Address; + let subjectComponent: Address; + let subjectModule: Address; + let subjectUnit: BigNumber; + + beforeEach(async () => { + subjectSetToken = setToken.address; + subjectComponent = componentOne.address; + subjectModule = moduleOne.address; + subjectUnit = ether(2); + + await setToken.connect(moduleOne.wallet).editExternalPositionUnit( + subjectComponent, + subjectModule, + ether(2) + ); + }); + + async function subject(): Promise { + return positionLibMock.testHasSufficientExternalUnits( + subjectSetToken, + subjectComponent, + subjectModule, + subjectUnit, + ); + } + + it("should return true", async () => { + const hasSufficientUnits = await subject(); + expect(hasSufficientUnits).to.eq(true); + }); + + describe("when the is less than the Position Unit", async () => { + beforeEach(async () => { + subjectUnit = ether(1); + }); + + it("should return false", async () => { + const hasSufficientUnits = await subject(); + expect(hasSufficientUnits).to.eq(true); + }); + }); + + describe("when the is more than the Position Unit", async () => { + beforeEach(async () => { + subjectUnit = ether(3); + }); + + it("should return false", async () => { + const hasSufficientUnits = await subject(); + expect(hasSufficientUnits).to.eq(false); + }); + }); + }); + + describe("#editDefaultPosition", async () => { + let subjectSetTokenAddress: Address; + let subjectComponent: Address; + let subjectUnit: BigNumber; + + beforeEach(async () => { + subjectSetTokenAddress = setToken.address; + subjectComponent = componentTwo.address; + subjectUnit = ether(3); + }); + + async function subject(): Promise { + return positionLibMock.testEditDefaultPosition( + subjectSetTokenAddress, + subjectComponent, + subjectUnit + ); + } + + context("when the position exists", async () => { + it("should set the units", async () => { + await subject(); + + const afterEditPosition = await setToken.getDefaultPositionRealUnit(subjectComponent); + const expectedUnit = subjectUnit; + expect(afterEditPosition).to.eq(expectedUnit); + }); + }); + + describe("when the default position is originally 0", async () => { + beforeEach(async () => { + subjectComponent = await getRandomAddress(); + }); + + it("should set the units", async () => { + await subject(); + + const afterEditPosition = await setToken.getDefaultPositionRealUnit(subjectComponent); + const expectedUnit = subjectUnit; + expect(afterEditPosition).to.eq(expectedUnit); + }); + + it("should add the component to the components array", async () => { + const previousComponents = await setToken.getComponents(); + + await subject(); + + const currentComponents = await setToken.getComponents(); + expect(currentComponents.length).to.eq(previousComponents.length + 1); + }); + + describe("when the component has an external position", async () => { + beforeEach(async () => { + await setToken.connect(moduleOne.wallet).addExternalPositionModule( + subjectComponent, + moduleOne.address + ); + }); + + it("should not add the component from the components array", async () => { + const previousComponents = await setToken.getComponents(); + + await subject(); + + const currentComponents = await setToken.getComponents(); + expect(currentComponents.length).to.eq(previousComponents.length); + }); + }); + + describe("when the component unit is 0", async () => { + beforeEach(async () => { + subjectUnit = ZERO; + }); + + it("should not add any components", async () => { + const previousComponents = await setToken.getComponents(); + + await subject(); + + const currentComponents = await setToken.getComponents(); + expect(currentComponents.length).to.eq(previousComponents.length); + }); + }); + }); + + describe("when the position is set to 0", async () => { + beforeEach(async () => { + subjectUnit = ZERO; + }); + + it("should set the default units 0", async () => { + await subject(); + + const retrievedRealUnit = await setToken.getDefaultPositionRealUnit(subjectComponent); + expect(retrievedRealUnit).to.eq(ZERO); + }); + + it("should remove the component from the components array", async () => { + const previousComponents = await setToken.getComponents(); + + await subject(); + + const currentComponents = await setToken.getComponents(); + expect(currentComponents.length).to.eq(previousComponents.length - 1); + }); + + describe("when the component has an external position", async () => { + beforeEach(async () => { + await setToken.connect(moduleOne.wallet).addExternalPositionModule( + subjectComponent, + moduleOne.address + ); + }); + + it("should not remove the component from the components array", async () => { + const previousComponents = await setToken.getComponents(); + + await subject(); + + const currentComponents = await setToken.getComponents(); + expect(currentComponents.length).to.eq(previousComponents.length); + }); + }); + }); + }); + + describe("#calculateAndEditDefaultPosition", async () => { + let subjectSetTokenAddress: Address; + let subjectComponent: Address; + let subjectSetTokenSupply: BigNumber; + let subjectPreviousComponentBalance: BigNumber; + + beforeEach(async () => { + subjectSetTokenAddress = setToken.address; + subjectComponent = componentTwo.address; + subjectSetTokenSupply = ether(3); + subjectPreviousComponentBalance = preciseMul(subjectSetTokenSupply, ether(2)); + + // Mint some set tokens + await setup.approveAndIssueSetToken(setToken, subjectSetTokenSupply); + }); + + async function subject(): Promise { + return positionLibMock.testCalculateAndEditDefaultPosition( + subjectSetTokenAddress, + subjectComponent, + subjectSetTokenSupply, + subjectPreviousComponentBalance + ); + } + + it("should set the correct units", async () => { + const componentTwoUnit = ether(2); + + await subject(); + + const componentTwoBalance = await componentTwo.balanceOf(setToken.address); + const newPositionUnit = await setToken.getDefaultPositionRealUnit(subjectComponent); + const expectedPosition = await positionLibMock.testCalculateDefaultEditPositionUnit( + subjectSetTokenSupply, + subjectPreviousComponentBalance, + componentTwoBalance, + componentTwoUnit + ); + + expect(newPositionUnit).to.eq(expectedPosition); + }); + + describe("when the amount of the subjectComponent is 0", async () => { + beforeEach(async () => { + subjectComponent = componentThree.address; + }); + + it("should not add the component to the components array", async () => { + await subject(); + + const postComponents = await setToken.getDefaultPositionRealUnit(subjectComponent); + expect(postComponents).to.eq(ZERO); + }); + }); + }); + + describe("#editExternalPosition", async () => { + let subjectSetToken: Address; + let subjectComponent: Address; + let subjectModule: Address; + let subjectNewUnit: BigNumber; + let subjectData: string; + + beforeEach(async () => { + subjectSetToken = setToken.address; + subjectComponent = componentThree.address; + subjectModule = moduleOne.address; + subjectNewUnit = ether(3); + subjectData = "0x1234"; + }); + + async function subject(): Promise { + return positionLibMock.testEditExternalPosition( + subjectSetToken, + subjectComponent, + subjectModule, + subjectNewUnit, + subjectData + ); + } + + context("no position exists for the component", async () => { + it("should add the component to the components array", async () => { + const preComponents = await setToken.getComponents(); + expect(preComponents).to.not.contain(subjectComponent); + + await subject(); + + const postComponents = await setToken.getComponents(); + expect(postComponents).to.contain(subjectComponent); + }); + + it("should add the new external position", async () => { + const preModules = await setToken.getExternalPositionModules(subjectComponent); + expect(preModules.length).to.eq(0); + + await subject(); + + const postModules = await setToken.getExternalPositionModules(subjectComponent); + const unit = await setToken.getExternalPositionRealUnit(subjectComponent, subjectModule); + const data = await setToken.getExternalPositionData(subjectComponent, subjectModule); + expect(postModules).to.contain(subjectModule); + expect(unit).to.eq(subjectNewUnit); + expect(data).to.eq("0x1234"); + }); + + describe("and calling module is calling with 0 unit", async () => { + beforeEach(async () => { + subjectNewUnit = ZERO; + subjectData = "0x"; + }); + + it("should not add the component to the components array", async () => { + const preComponents = await setToken.getComponents(); + expect(preComponents).to.not.contain(subjectComponent); + + await subject(); + + const postComponents = await setToken.getComponents(); + expect(postComponents).to.not.contain(subjectComponent); + }); + + it("should not add the new external position", async () => { + const preModules = await setToken.getExternalPositionModules(subjectComponent); + expect(preModules.length).to.eq(0); + + await subject(); + + const postModules = await setToken.getExternalPositionModules(subjectComponent); + const unit = await setToken.getExternalPositionRealUnit(subjectComponent, subjectModule); + const data = await setToken.getExternalPositionData(subjectComponent, subjectModule); + expect(postModules).to.not.contain(subjectModule); + expect(unit).to.eq(ZERO); + expect(data).to.eq("0x"); + }); + }); + }); + + context("only a default position exists for the component", async () => { + beforeEach(async () => { + subjectComponent = componentTwo.address; + }); + + it("should not add anything to the components array", async () => { + const preComponents = await setToken.getComponents(); + expect(preComponents).to.contain(subjectComponent); + expect(preComponents.length).to.eq(2); + + await subject(); + + const postComponents = await setToken.getComponents(); + expect(postComponents).to.contain(subjectComponent); + expect(preComponents.length).to.eq(2); + }); + + it("should add the new external position", async () => { + const preModules = await setToken.getExternalPositionModules(subjectComponent); + expect(preModules.length).to.eq(0); + + await subject(); + + const modules = await setToken.getExternalPositionModules(subjectComponent); + const unit = await setToken.getExternalPositionRealUnit(subjectComponent, subjectModule); + const data = await setToken.getExternalPositionData(subjectComponent, subjectModule); + expect(modules).to.contain(subjectModule); + expect(unit).to.eq(subjectNewUnit); + expect(data).to.eq("0x1234"); + }); + }); + + context("when adding to an existing external position", async () => { + beforeEach(async () => { + await subject(); + + subjectNewUnit = ether(2); + subjectData = "0x4567"; + }); + + it("should add the new external position", async () => { + const preModules = await setToken.getExternalPositionModules(subjectComponent); + expect(preModules.length).to.eq(1); + + await subject(); + + const modules = await setToken.getExternalPositionModules(subjectComponent); + const unit = await setToken.getExternalPositionRealUnit(subjectComponent, subjectModule); + const data = await setToken.getExternalPositionData(subjectComponent, subjectModule); + expect(modules).to.contain(subjectModule); + expect(unit).to.eq(subjectNewUnit); + expect(data).to.eq("0x4567"); + }); + }); + + context("when removing an external position but default position still exists", async () => { + beforeEach(async () => { + subjectComponent = componentTwo.address; + await subject(); + subjectNewUnit = ZERO; + subjectData = "0x"; + }); + + it("should remove the module from the modules array, components stay the same", async () => { + const preComponents = await setToken.getComponents(); + const preModules = await setToken.getExternalPositionModules(subjectComponent); + expect(preComponents).to.contain(subjectComponent); + expect(preComponents.length).to.eq(2); + expect(preModules.length).to.eq(1); + + await subject(); + + const postComponents = await setToken.getComponents(); + const postModules = await setToken.getExternalPositionModules(subjectComponent); + expect(postComponents).to.contain(subjectComponent); + expect(preComponents.length).to.eq(2); + expect(postModules.length).to.eq(0); + }); + + it("should update the unit and delete the data", async () => { + await subject(); + + const postUnit = await setToken.getExternalPositionRealUnit(subjectComponent, subjectModule); + const data = await setToken.getExternalPositionData(subjectComponent, subjectModule); + expect(postUnit).to.eq(ZERO); + expect(data).to.eq("0x"); + }); + + describe("but passed data is not 0", async () => { + beforeEach(async () => { + subjectData = "0x4567"; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Passed data must be null"); + }); + }); + }); + + context("when removing an external position and no default position", async () => { + beforeEach(async () => { + await subject(); + subjectNewUnit = ZERO; + subjectData = "0x"; + }); + + it("should remove entry from the modules and components array", async () => { + const preComponents = await setToken.getComponents(); + const preModules = await setToken.getExternalPositionModules(subjectComponent); + expect(preComponents).to.contain(subjectComponent); + expect(preComponents.length).to.eq(3); + expect(preModules.length).to.eq(1); + + await subject(); + + const postComponents = await setToken.getComponents(); + const postModules = await setToken.getExternalPositionModules(subjectComponent); + expect(postComponents).to.not.contain(subjectComponent); + expect(postComponents.length).to.eq(2); + expect(postModules.length).to.eq(0); + }); + + it("should update the unit and delete the data", async () => { + await subject(); + + const postUnit = await setToken.getExternalPositionRealUnit(subjectComponent, subjectModule); + const data = await setToken.getExternalPositionData(subjectComponent, subjectModule); + expect(postUnit).to.eq(ZERO); + expect(data).to.eq("0x"); + }); + + describe("but passed module is not the one being tracked", async () => { + beforeEach(async () => { + await setToken.connect(moduleOne.wallet).editExternalPositionUnit(subjectComponent, moduleTwo.address, ONE); + subjectModule = moduleTwo.address; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("External positions must be 0 to remove component"); + }); + }); + }); + }); + + describe("#getDefaultTrackedBalance", async () => { + let subjectSetToken: Address; + let subjectComponent: Address; + + let totalSupply: BigNumber; + + beforeEach(async () => { + subjectSetToken = setToken.address; + subjectComponent = componentTwo.address; + totalSupply = ether(5); + + await componentOne.approve(setup.issuanceModule.address, ether(100)); + await componentTwo.approve(setup.issuanceModule.address, ether(100)); + await setup.issuanceModule.issue(subjectSetToken, totalSupply, owner.address); + }); + + async function subject(): Promise { + return positionLibMock.testGetDefaultTrackedBalance( + subjectSetToken, + subjectComponent + ); + } + + it("should return correct position unit quantity", async () => { + const retrievedQuantity = await subject(); + const expectedResult = preciseMul(totalSupply, ether(2)); + expect(retrievedQuantity).to.eq(expectedResult); + }); + }); + + describe("#getDefaultTotalNotional", async () => { + let subjectSetTokenSupply: BigNumber; + let subjectPositionUnit: BigNumber; + + beforeEach(async () => { + subjectSetTokenSupply = ether(2); + subjectPositionUnit = ether(10); + }); + + async function subject(): Promise { + return positionLibMock.testGetDefaultTotalNotional( + subjectSetTokenSupply, + subjectPositionUnit + ); + } + + it("should return correct total notional", async () => { + const totalNotional = await subject(); + const expectedTotalNotional = subjectPositionUnit.mul(subjectSetTokenSupply).div(PRECISE_UNIT); + expect(totalNotional).to.eq(expectedTotalNotional); + }); + }); + + describe("#getDefaultPositionUnit", async () => { + let subjectSetTokenSupply: BigNumber; + let subjectTotalNotional: BigNumber; + + beforeEach(async () => { + subjectSetTokenSupply = ether(2); + subjectTotalNotional = ether(10); + }); + + async function subject(): Promise { + return positionLibMock.testGetDefaultPositionUnit( + subjectSetTokenSupply, + subjectTotalNotional + ); + } + + it("should return correct position unit", async () => { + const positionUnit = await subject(); + const expectedPositionUnit = subjectTotalNotional.mul(PRECISE_UNIT).div(subjectSetTokenSupply); + expect(positionUnit).to.eq(expectedPositionUnit); + }); + }); + + describe("#calculateDefaultEditPositionUnit", async () => { + let subjectSetTokenSupply: BigNumber; + let subjectPreTotalNotional: BigNumber; + let subjectPostTotalNotional: BigNumber; + let subjectPrePositionUnit: BigNumber; + + beforeEach(async () => { + subjectSetTokenSupply = ether(2); + subjectPreTotalNotional = ether(2); + subjectPostTotalNotional = ether(1); + subjectPrePositionUnit = ether(1); + }); + + async function subject(): Promise { + return positionLibMock.testCalculateDefaultEditPositionUnit( + subjectSetTokenSupply, + subjectPreTotalNotional, + subjectPostTotalNotional, + subjectPrePositionUnit + ); + } + + it("should calculate correct new position unit", async () => { + const newPositionUnit = await subject(); + + const unitToSub = preciseDivCeil(subjectPreTotalNotional.sub(subjectPostTotalNotional), subjectSetTokenSupply); + const expectedPositionUnit = subjectPrePositionUnit.sub(unitToSub); + expect(newPositionUnit).to.eq(expectedPositionUnit); + }); + + describe("when post action notional is greater than pre action notional", async () => { + beforeEach(async () => { + subjectPreTotalNotional = ether(1); + subjectPrePositionUnit = ether(.5); + subjectPostTotalNotional = ether(2); + }); + + it("should calculate correct new position unit", async () => { + const newPositionUnit = await subject(); + + const unitToAdd = preciseDiv(subjectPostTotalNotional.sub(subjectPreTotalNotional), subjectSetTokenSupply); + const expectedPositionUnit = subjectPrePositionUnit.add(unitToAdd); + expect(newPositionUnit).to.eq(expectedPositionUnit); + }); + }); + + describe("when resulting position unit requires rounding, it rounds down", async () => { + beforeEach(async () => { + subjectPrePositionUnit = ether(.99999999999999999); + }); + + it("should calculate correct new position unit", async () => { + const newPositionUnit = await subject(); + + const unitToAdd = preciseDiv(subjectPostTotalNotional.sub(subjectPreTotalNotional), subjectSetTokenSupply); + const expectedPositionUnit = subjectPrePositionUnit.add(unitToAdd); + + expect(newPositionUnit).to.eq(expectedPositionUnit); + }); + }); + }); +}); diff --git a/test/protocol/modules/perpV2BasisTradingModule.spec.ts b/test/protocol/modules/perpV2BasisTradingModule.spec.ts index 4d10a1cd7..9926e90c7 100644 --- a/test/protocol/modules/perpV2BasisTradingModule.spec.ts +++ b/test/protocol/modules/perpV2BasisTradingModule.spec.ts @@ -4,7 +4,9 @@ import { Address } from "@utils/types"; import { Account } from "@utils/test/types"; import { PerpV2, + PerpV2Positions, PerpV2BasisTradingModule, + PositionV2, DebtIssuanceMock, StandardTokenMock, SetToken @@ -61,7 +63,9 @@ describe("PerpV2BasisTradingModule", () => { let mockModule: Account; let deployer: DeployHelper; + let positionLib: PositionV2; let perpLib: PerpV2; + let perpPositionsLib: PerpV2Positions; let perpBasisTradingModule: PerpV2BasisTradingModule; let debtIssuanceMock: DebtIssuanceMock; let setup: SystemFixture; @@ -113,15 +117,24 @@ describe("PerpV2BasisTradingModule", () => { await setup.controller.addModule(debtIssuanceMock.address); maxPerpPositionsPerSet = TWO; - perpLib = await deployer.libraries.deployPerpV2(); + + // Deploy libraries + positionLib = await deployer.libraries.deployPositionV2(); + perpLib = await deployer.libraries.deployPerpV2LibraryV2(); + perpPositionsLib = await deployer.libraries.deployPerpV2Positions(); + perpBasisTradingModule = await deployer.modules.deployPerpV2BasisTradingModule( setup.controller.address, perpSetup.vault.address, perpSetup.quoter.address, perpSetup.marketRegistry.address, maxPerpPositionsPerSet, - "contracts/protocol/integration/lib/PerpV2.sol:PerpV2", + "contracts/protocol/lib/PositionV2.sol:PositionV2", + positionLib.address, + "contracts/protocol/integration/lib/PerpV2LibraryV2.sol:PerpV2LibraryV2", perpLib.address, + "contracts/protocol/integration/lib/PerpV2Positions.sol:PerpV2Positions", + perpPositionsLib.address ); await setup.controller.addModule(perpBasisTradingModule.address); @@ -208,8 +221,12 @@ describe("PerpV2BasisTradingModule", () => { subjectQuoter, subjectMarketRegistry, subjectMaxPerpPositionsPerSet, - "contracts/protocol/integration/lib/PerpV2.sol:PerpV2", + "contracts/protocol/lib/PositionV2.sol:PositionV2", + positionLib.address, + "contracts/protocol/integration/lib/PerpV2LibraryV2.sol:PerpV2LibraryV2", perpLib.address, + "contracts/protocol/integration/lib/PerpV2Positions.sol:PerpV2Positions", + perpPositionsLib.address ); } @@ -249,6 +266,26 @@ describe("PerpV2BasisTradingModule", () => { }); }); + describe("#initialize (old)", async () => { + let subjectSetToken: Address; + let subjectCaller: Account; + + beforeEach(async () => { + subjectSetToken = await getRandomAddress(); + subjectCaller = owner; + }); + + async function subject(): Promise { + return perpBasisTradingModule.connect(subjectCaller.wallet)["initialize(address)"]( + subjectSetToken + ); + } + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Use intialize(_setToken, _settings) instead"); + }); + }); + describe("#initialize", async () => { let setToken: SetToken; let subjectSetToken: Address; diff --git a/test/protocol/modules/perpV2LeverageModule.spec.ts b/test/protocol/modules/perpV2LeverageModule.spec.ts index f2f71ee8c..966c80c5b 100644 --- a/test/protocol/modules/perpV2LeverageModule.spec.ts +++ b/test/protocol/modules/perpV2LeverageModule.spec.ts @@ -117,7 +117,7 @@ describe("PerpV2LeverageModule", () => { perpSetup.marketRegistry.address, maxPerpPositionsPerSet, "contracts/protocol/integration/lib/PerpV2.sol:PerpV2", - perpLib.address, + perpLib.address ); await setup.controller.addModule(perpLeverageModule.address); @@ -196,7 +196,7 @@ describe("PerpV2LeverageModule", () => { subjectMarketRegistry, subjectMaxPerpPositionsPerSet, "contracts/protocol/integration/lib/PerpV2.sol:PerpV2", - perpLib.address, + perpLib.address ); } diff --git a/test/protocol/modules/perpV2LeverageModuleV2.spec.ts b/test/protocol/modules/perpV2LeverageModuleV2.spec.ts new file mode 100644 index 000000000..daf535e29 --- /dev/null +++ b/test/protocol/modules/perpV2LeverageModuleV2.spec.ts @@ -0,0 +1,6062 @@ +import "module-alias/register"; + +import { Address } from "@utils/types"; +import { Account } from "@utils/test/types"; +import { + PositionV2, + PerpV2LibraryV2, + PerpV2Positions, + PerpV2LeverageModuleV2, + DebtIssuanceMock, + StandardTokenMock, + SetToken +} from "@utils/contracts"; + +import { PerpV2BaseToken } from "@utils/contracts/perpV2"; + +import DeployHelper from "@utils/deploys"; +import { + ether, + bitcoin, + usdc as usdcUnits, + preciseDiv, + preciseMul +} from "@utils/index"; + +import { + calculateExternalPositionUnit, + calculateUSDCTransferIn, + calculateUSDCTransferOut, + calculateUSDCTransferInPreciseUnits, + calculateUSDCTransferOutPreciseUnits, + getUSDCDeltaDueToFundingGrowth, + leverUp, + toUSDCDecimals, +} from "@utils/common"; + +import { + cacheBeforeEach, + getAccounts, + getWaffleExpect, + getSystemFixture, + getPerpV2Fixture, + getRandomAccount, + getRandomAddress, + increaseTimeAsync +} from "@utils/test/index"; + +import { PerpV2Fixture, SystemFixture } from "@utils/fixtures"; +import { ADDRESS_ZERO, ZERO, ZERO_BYTES, MAX_UINT_256, ONE_DAY_IN_SECONDS, ONE, TWO, THREE } from "@utils/constants"; +import { BigNumber } from "ethers"; + +const expect = getWaffleExpect(); + +describe("PerpV2LeverageModuleV2", () => { + let owner: Account; + let maker: Account; + let otherTrader: Account; + let mockModule: Account; + let deployer: DeployHelper; + + let positionLib: PositionV2; + let perpLib: PerpV2LibraryV2; + let perpPositionsLib: PerpV2Positions; + let perpLeverageModule: PerpV2LeverageModuleV2; + let debtIssuanceMock: DebtIssuanceMock; + let setup: SystemFixture; + let perpSetup: PerpV2Fixture; + let maxPerpPositionsPerSet: BigNumber; + + let vETH: PerpV2BaseToken; + let vBTC: PerpV2BaseToken; + let usdc: StandardTokenMock; + + cacheBeforeEach(async () => { + [ + owner, + maker, + otherTrader, + mockModule, + ] = await getAccounts(); + + deployer = new DeployHelper(owner.wallet); + setup = getSystemFixture(owner.address); + await setup.initialize(); + + perpSetup = getPerpV2Fixture(owner.address); + await perpSetup.initialize(maker, otherTrader); + + // set funding rate to zero; allows us to avoid calculating small amounts of funding + // accrued in our test cases + await perpSetup.clearingHouseConfig.setMaxFundingRate(ZERO); + + vETH = perpSetup.vETH; + vBTC = perpSetup.vBTC; + usdc = perpSetup.usdc; + + // Create liquidity + await perpSetup.setBaseTokenOraclePrice(vETH, usdcUnits(10)); + await perpSetup.initializePoolWithLiquidityWide( + vETH, + ether(10_000), + ether(100_000) + ); + + await perpSetup.setBaseTokenOraclePrice(vBTC, usdcUnits(20)); + await perpSetup.initializePoolWithLiquidityWide( + vBTC, + ether(10_000), + ether(200_000) + ); + + debtIssuanceMock = await deployer.mocks.deployDebtIssuanceMock(); + await setup.controller.addModule(debtIssuanceMock.address); + + maxPerpPositionsPerSet = TWO; + + // Deploy libraries + positionLib = await deployer.libraries.deployPositionV2(); + perpLib = await deployer.libraries.deployPerpV2LibraryV2(); + perpPositionsLib = await deployer.libraries.deployPerpV2Positions(); + + perpLeverageModule = await deployer.modules.deployPerpV2LeverageModuleV2( + setup.controller.address, + perpSetup.vault.address, + perpSetup.quoter.address, + perpSetup.marketRegistry.address, + maxPerpPositionsPerSet, + "contracts/protocol/lib/PositionV2.sol:PositionV2", + positionLib.address, + "contracts/protocol/integration/lib/PerpV2LibraryV2.sol:PerpV2LibraryV2", + perpLib.address, + "contracts/protocol/integration/lib/PerpV2Positions.sol:PerpV2Positions", + perpPositionsLib.address, + ); + await setup.controller.addModule(perpLeverageModule.address); + + await setup.integrationRegistry.addIntegration( + perpLeverageModule.address, + "DefaultIssuanceModule", + debtIssuanceMock.address + ); + }); + + /** + * HELPERS + */ + + // Creates SetToken, issues sets (default: 1), initializes PerpV2LeverageModuleV2 and deposits to Perp + async function issueSetsAndDepositToPerp( + depositQuantityUnit: BigNumber, + isInitialized: boolean = true, + issueQuantity: BigNumber = ether(1), + skipMockModuleInitialization = false + ): Promise { + const setToken = await setup.createSetToken( + [setup.wbtc.address, usdc.address, setup.weth.address], + [bitcoin(10), usdcUnits(100), ether(10)], + [perpLeverageModule.address, debtIssuanceMock.address, setup.issuanceModule.address] + ); + + if (isInitialized) { + await debtIssuanceMock.initialize(setToken.address); + await perpLeverageModule.updateAllowedSetToken(setToken.address, true); + + await perpLeverageModule.connect(owner.wallet).initialize(setToken.address); + + // Initialize mock module + if (!skipMockModuleInitialization) { + await setup.controller.addModule(mockModule.address); + await setToken.addModule(mockModule.address); + await setToken.connect(mockModule.wallet).initializeModule(); + } + + await usdc.approve(setup.issuanceModule.address, preciseMul(usdcUnits(100), issueQuantity)); + await setup.issuanceModule.initialize(setToken.address, ADDRESS_ZERO); + await setup.issuanceModule.issue(setToken.address, issueQuantity, owner.address); + + await perpLeverageModule.deposit(setToken.address, depositQuantityUnit); + } + + return setToken; + } + + async function syncOracleToSpot(baseToken: PerpV2BaseToken): Promise { + const baseTokenSpotPrice = await perpSetup.getSpotPrice(baseToken.address); + await perpSetup.setBaseTokenOraclePrice(baseToken, baseTokenSpotPrice.div(10 ** 12)); + } + + describe("#constructor", async () => { + let subjectController: Address; + let subjectVault: Address; + let subjectQuoter: Address; + let subjectMarketRegistry: Address; + let subjectMaxPerpPositionsPerSet: BigNumber; + + beforeEach(async () => { + subjectController = setup.controller.address; + subjectVault = perpSetup.vault.address; + subjectQuoter = perpSetup.quoter.address; + subjectMarketRegistry = perpSetup.marketRegistry.address; + subjectMaxPerpPositionsPerSet = ONE; + }); + + async function subject(): Promise { + return deployer.modules.deployPerpV2LeverageModuleV2( + subjectController, + subjectVault, + subjectQuoter, + subjectMarketRegistry, + subjectMaxPerpPositionsPerSet, + "contracts/protocol/lib/PositionV2.sol:PositionV2", + positionLib.address, + "contracts/protocol/integration/lib/PerpV2LibraryV2.sol:PerpV2LibraryV2", + perpLib.address, + "contracts/protocol/integration/lib/PerpV2Positions.sol:PerpV2Positions", + perpPositionsLib.address + ); + } + + it("should set the correct controller", async () => { + const perpLeverageModule = await subject(); + + const controller = await perpLeverageModule.controller(); + expect(controller).to.eq(subjectController); + }); + + it("should set the correct PerpV2 contracts and collateralToken", async () => { + const perpLeverageModule = await subject(); + + const perpAccountBalance = await perpLeverageModule.perpAccountBalance(); + const perpClearingHouse = await perpLeverageModule.perpClearingHouse(); + const perpExchange = await perpLeverageModule.perpExchange(); + const perpVault = await perpLeverageModule.perpVault(); + const perpQuoter = await perpLeverageModule.perpQuoter(); + const perpMarketRegistry = await perpLeverageModule.perpMarketRegistry(); + const collateralToken = await perpLeverageModule.collateralToken(); + + expect(perpAccountBalance).to.eq(perpSetup.accountBalance.address); + expect(perpClearingHouse).to.eq(perpSetup.clearingHouse.address); + expect(perpExchange).to.eq(perpSetup.exchange.address); + expect(perpVault).to.eq(perpSetup.vault.address); + expect(perpQuoter).to.eq(perpSetup.quoter.address); + expect(perpMarketRegistry).to.eq(perpSetup.marketRegistry.address); + expect(collateralToken).to.eq(perpSetup.usdc.address); + }); + + it("should set the correct max perp positions per Set", async () => { + const perpLeverageModule = await subject(); + + const maxPerpPositionsPerSet = await perpLeverageModule.maxPerpPositionsPerSet(); + + expect(maxPerpPositionsPerSet).to.eq(ONE); + }); + }); + + describe("#initialize", async () => { + let setToken: SetToken; + let isAllowListed: boolean; + let subjectSetToken: Address; + let subjectCaller: Account; + + const initializeContracts = async () => { + setToken = await setup.createSetToken( + [usdc.address], + [ether(100)], + [perpLeverageModule.address, debtIssuanceMock.address] + ); + await debtIssuanceMock.initialize(setToken.address); + + if (isAllowListed) { + // Add SetToken to allow list + await perpLeverageModule.updateAllowedSetToken(setToken.address, true); + } + }; + + const initializeSubjectVariables = () => { + subjectSetToken = setToken.address; + subjectCaller = owner; + }; + + async function subject(): Promise { + return perpLeverageModule.connect(subjectCaller.wallet).initialize( + subjectSetToken, + ); + } + + describe("when isAllowListed is true", () => { + before(async () => { + isAllowListed = true; + }); + + cacheBeforeEach(initializeContracts); + beforeEach(initializeSubjectVariables); + + it("should enable the Module on the SetToken", async () => { + await subject(); + const isModuleEnabled = await setToken.isInitializedModule(perpLeverageModule.address); + expect(isModuleEnabled).to.eq(true); + }); + + it("should register on the debt issuance module", async () => { + await subject(); + const isRegistered = await debtIssuanceMock.isRegistered(setToken.address); + expect(isRegistered).to.be.true; + }); + + describe("when debt issuance module is not added to integration registry", async () => { + beforeEach(async () => { + await setup.integrationRegistry.removeIntegration(perpLeverageModule.address, "DefaultIssuanceModule"); + }); + + afterEach(async () => { + // Add debt issuance address to integration + await setup.integrationRegistry.addIntegration( + perpLeverageModule.address, + "DefaultIssuanceModule", + debtIssuanceMock.address + ); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be valid adapter"); + }); + }); + + describe("when debt issuance module is not initialized on SetToken", async () => { + beforeEach(async () => { + await setToken.removeModule(debtIssuanceMock.address); + }); + + afterEach(async () => { + await setToken.addModule(debtIssuanceMock.address); + await debtIssuanceMock.initialize(setToken.address); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Issuance not initialized"); + }); + }); + + 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 in pending state", async () => { + beforeEach(async () => { + const newModule = await getRandomAddress(); + await setup.controller.addModule(newModule); + + const perpLeverageModuleNotPendingSetToken = await setup.createSetToken( + [usdc.address], + [usdcUnits(100)], + [newModule] + ); + + subjectSetToken = perpLeverageModuleNotPendingSetToken.address; + }); + + 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.weth.address], + [usdcUnits(100)], + [perpLeverageModule.address] + ); + + subjectSetToken = nonEnabledSetToken.address; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be controller-enabled SetToken"); + }); + }); + }); + + describe("when isAllowListed is false", async () => { + before(async () => { + isAllowListed = false; + }); + + cacheBeforeEach(initializeContracts); + beforeEach(initializeSubjectVariables); + + describe("when SetToken is not allowlisted", async () => { + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Not allowed SetToken"); + }); + }); + + describe("when any Set can initialize this module", async () => { + beforeEach(async () => { + await perpLeverageModule.updateAnySetAllowed(true); + }); + + it("should enable the Module on the SetToken", async () => { + await subject(); + const isModuleEnabled = await setToken.isInitializedModule(perpLeverageModule.address); + expect(isModuleEnabled).to.eq(true); + }); + }); + }); + }); + + describe("#trade", () => { + let setToken: SetToken; + let isInitialized: boolean = true; + let depositQuantity: BigNumber; + + let subjectSetToken: Address; + let subjectCaller: Account; + let subjectBaseToken: Address; + let subjectBaseTradeQuantityUnits: BigNumber; + let subjectQuoteBoundQuantityUnits: BigNumber; + + const initializeContracts = async () => { + depositQuantity = usdcUnits(10); + setToken = await issueSetsAndDepositToPerp(depositQuantity, isInitialized); + }; + + const initializeSubjectVariables = async () => { + subjectSetToken = setToken.address; + subjectCaller = owner; + subjectBaseToken = vETH.address; + }; + + cacheBeforeEach(initializeContracts); + beforeEach(initializeSubjectVariables); + + async function subject(): Promise { + return await perpLeverageModule.connect(subjectCaller.wallet).trade( + subjectSetToken, + subjectBaseToken, + subjectBaseTradeQuantityUnits, + subjectQuoteBoundQuantityUnits + ); + } + + describe("when module is initialized", async () => { + describe("when going long", () => { + describe("when no positions are open (total supply is 1)", async () => { + beforeEach(async () => { + // Long ~10 USDC of vETH + subjectBaseTradeQuantityUnits = ether(1); + subjectQuoteBoundQuantityUnits = ether(10.15); + }); + + it("should open the expected position", async () => { + const totalSupply = await setToken.totalSupply(); + + const expectedBaseBalance = preciseMul(subjectBaseTradeQuantityUnits, totalSupply); + const expectedQuoteBalance = + (await perpSetup.getSwapQuote(subjectBaseToken, subjectBaseTradeQuantityUnits, true)).deltaQuote; + + const initialPositionInfo = await perpLeverageModule.getPositionNotionalInfo(subjectSetToken); + + await subject(); + + const finalPositionInfo = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0]; + + expect(initialPositionInfo.length).to.eq(0); + expect(finalPositionInfo.baseBalance).gt(0); + expect(finalPositionInfo.quoteBalance).lt(0); + expect(finalPositionInfo.baseBalance).eq(expectedBaseBalance); + expect(finalPositionInfo.quoteBalance).eq(expectedQuoteBalance.mul(-1)); + expect(finalPositionInfo.quoteBalance.mul(-1)).lt(subjectQuoteBoundQuantityUnits); + }); + + it("should emit the correct PerpTraded event", async () => { + const { + deltaBase: expectedDeltaBase, + deltaQuote: expectedDeltaQuote + } = await perpSetup.getSwapQuote(subjectBaseToken, subjectBaseTradeQuantityUnits, true); + + const expectedProtocolFee = ether(0); + const expectedIsBuy = true; + + await expect(subject()).to.emit(perpLeverageModule, "PerpTraded").withArgs( + subjectSetToken, + subjectBaseToken, + expectedDeltaBase, + expectedDeltaQuote, + expectedProtocolFee, + expectedIsBuy + ); + }); + + it("should not update the USDC defaultPositionUnit", async () => { + const initialDefaultPositionUnit = await setToken.getDefaultPositionRealUnit(usdc.address); + await subject(); + const finalDefaultPositionUnit = await setToken.getDefaultPositionRealUnit(usdc.address); + + expect(initialDefaultPositionUnit).eq(finalDefaultPositionUnit); + }); + + it("should not update the USDC externalPositionUnit", async () => { + const initialExternalPositionUnit = await setToken.getExternalPositionRealUnit(usdc.address, perpLeverageModule.address); + await subject(); + const finalExternalPositionUnit = await setToken.getExternalPositionRealUnit(usdc.address, perpLeverageModule.address); + + expect(initialExternalPositionUnit).eq(finalExternalPositionUnit); + }); + }); + + describe("when trading on margin", async () => { + beforeEach(async () => { + // Long ~20 USDC of vETH with 10 USDC collateral + subjectBaseTradeQuantityUnits = ether(2); + subjectQuoteBoundQuantityUnits = ether(20.3); + }); + + it("should open expected position", async () => { + const totalSupply = await setToken.totalSupply(); + const collateralBalance = (await perpLeverageModule.getAccountInfo(subjectSetToken)).collateralBalance; + const quoteBalanceMin = preciseMul(subjectQuoteBoundQuantityUnits, totalSupply); + + const expectedQuoteBalance = + (await perpSetup.getSwapQuote(subjectBaseToken, subjectBaseTradeQuantityUnits, true)).deltaQuote; + + await subject(); + + const positionInfo = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0]; + const expectedBaseBalance = preciseMul(subjectBaseTradeQuantityUnits, totalSupply); + + // Check that a levered trade happened + expect(toUSDCDecimals(collateralBalance)).to.eq(depositQuantity); + expect(toUSDCDecimals(quoteBalanceMin)).to.be.gt(depositQuantity); + + // Check balances + expect(positionInfo.baseBalance).to.eq(expectedBaseBalance); + expect(positionInfo.quoteBalance.mul(-1)).eq(expectedQuoteBalance); + expect(positionInfo.quoteBalance.mul(-1)).to.be.lt(quoteBalanceMin); + }); + }); + + describe("when total supply is 2", async () => { + let issueQuantity: BigNumber; + let otherSetToken: SetToken; + + beforeEach(async () => { + depositQuantity = usdcUnits(10); + issueQuantity = ether(2); + + otherSetToken = await issueSetsAndDepositToPerp( + depositQuantity, + isInitialized, + issueQuantity, + true + ); + + subjectSetToken = otherSetToken.address; + subjectBaseTradeQuantityUnits = ether(1); + subjectQuoteBoundQuantityUnits = ether(10.15); + }); + + it("should open position for the expected amount", async () => { + const totalSupply = await otherSetToken.totalSupply(); + const expectedBaseBalance = preciseMul(subjectBaseTradeQuantityUnits, totalSupply); + + await subject(); + + const positionInfo = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0]; + + expect(totalSupply).eq(issueQuantity); + expect(positionInfo.baseBalance).to.eq(expectedBaseBalance); + }); + }); + + describe("when slippage is greater than allowed", async () => { + beforeEach(async () => { + // Long ~10 USDC of vETH: slippage incurred as larger negative quote delta + subjectBaseTradeQuantityUnits = ether(1); + subjectQuoteBoundQuantityUnits = ether(10); + }); + + it("should revert", async () => { + // ClearingHouse: too much quote received when long + await expect(subject()).to.be.revertedWith("CH_TMRL"); + }); + }); + + describe("when an existing position is long", async () => { + beforeEach(async () => { + subjectBaseTradeQuantityUnits = ether(1); + subjectQuoteBoundQuantityUnits = ether(10.15); + + await perpLeverageModule.connect(subjectCaller.wallet).trade( + subjectSetToken, + subjectBaseToken, + subjectBaseTradeQuantityUnits, + subjectQuoteBoundQuantityUnits + ); + }); + + it("long trade should increase the position size", async () => { + const totalSupply = await setToken.totalSupply(); + const initialPositionInfo = await perpLeverageModule.getPositionNotionalInfo(subjectSetToken); + const expectedDeltaBase = preciseMul(subjectBaseTradeQuantityUnits, totalSupply); + const expectedBaseBalance = initialPositionInfo[0].baseBalance.add(expectedDeltaBase); + + await subject(); + + const finalPositionInfo = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0]; + + expect(initialPositionInfo.length).to.eq(1); + expect(finalPositionInfo.baseBalance).eq(expectedBaseBalance); + }); + }); + + describe("when an existing position is short", async () => { + beforeEach(async () => { + // Short ~10 USDC vETH + await perpLeverageModule.connect(subjectCaller.wallet).trade( + subjectSetToken, + subjectBaseToken, + ether(-1), + ether(9.85) + ); + + subjectBaseTradeQuantityUnits = ether(.5); + subjectQuoteBoundQuantityUnits = ether(5.15); + }); + + it("long trade should reduce the position", async () => { + const totalSupply = await setToken.totalSupply(); + const baseTradeQuantityNotional = preciseMul(subjectBaseTradeQuantityUnits, totalSupply); + + const initialPositionInfo = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0]; + + await subject(); + + const finalPositionInfo = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0]; + const closeRatio = preciseDiv(baseTradeQuantityNotional, initialPositionInfo.baseBalance); + const reducedOpenNotional = preciseMul(initialPositionInfo.quoteBalance, closeRatio); + + const expectedBaseBalance = initialPositionInfo.baseBalance.add(baseTradeQuantityNotional); + const expectedQuoteBalance = initialPositionInfo.quoteBalance.add(reducedOpenNotional); + + expect(finalPositionInfo.baseBalance).gt(initialPositionInfo.baseBalance); + expect(finalPositionInfo.quoteBalance).lt(initialPositionInfo.quoteBalance); + + expect(finalPositionInfo.baseBalance).eq(expectedBaseBalance); + expect(finalPositionInfo.quoteBalance).eq(expectedQuoteBalance); + }); + + describe("when the position is zeroed out", async () => { + beforeEach(async () => { + subjectBaseTradeQuantityUnits = ether(1); + subjectQuoteBoundQuantityUnits = ether(10.15); + }); + + it("should remove the position from the positions array", async () => { + const initialPositionInfo = await perpLeverageModule.getPositionNotionalInfo(subjectSetToken); + await subject(); + const finalPositionInfo = await perpLeverageModule.getPositionNotionalInfo(subjectSetToken); + + expect(initialPositionInfo.length).eq(1); + expect(finalPositionInfo.length).eq(0); + }); + + it("should ensure no dust amount is left on PerpV2", async () => { + await subject(); + + const baseBalance = await perpSetup.accountBalance.getBase(setToken.address, vETH.address); + expect(baseBalance).to.be.eq(ZERO); + }); + }); + + describe("when reversing the position", async () => { + beforeEach(async () => { + subjectBaseTradeQuantityUnits = ether(2); + subjectQuoteBoundQuantityUnits = ether(20.45); + }); + + it("long trade should reverse the short position to a long position", async () => { + const totalSupply = await setToken.totalSupply(); + const baseTradeQuantityNotional = preciseMul(subjectBaseTradeQuantityUnits, totalSupply); + + const { deltaQuote } = await perpSetup.getSwapQuote( + subjectBaseToken, + baseTradeQuantityNotional.abs(), + true // long + ); + const quote = deltaQuote.mul(-1); + + const initialPositionInfo = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0]; + + await subject(); + + const finalPositionInfo = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0]; + const closeRatio = preciseDiv(baseTradeQuantityNotional.abs(), initialPositionInfo.baseBalance.abs()); + const closedPositionNotional = preciseDiv(quote, closeRatio); + + const expectedBaseBalance = initialPositionInfo.baseBalance.add(baseTradeQuantityNotional); + const expectedQuoteBalance = quote.sub(closedPositionNotional); + + expect(finalPositionInfo.baseBalance).gt(ZERO); + expect(finalPositionInfo.quoteBalance).lt(ZERO); + + expect(finalPositionInfo.baseBalance).eq(expectedBaseBalance); + expect(finalPositionInfo.quoteBalance).eq(expectedQuoteBalance); + }); + }); + }); + + describe("when a protocol fee is charged", async () => { + let feePercentage: BigNumber; + + cacheBeforeEach(async () => { + feePercentage = ether(0.05); + setup.controller = setup.controller.connect(owner.wallet); + + await setup.controller.addFee( + perpLeverageModule.address, + ZERO, // Fee type on trade function denoted as 0 + feePercentage + ); + + // Long ~10 USDC of vETH + subjectBaseTradeQuantityUnits = ether(1); + subjectQuoteBoundQuantityUnits = ether(10.15); + }); + + it("should withdraw the expected collateral amount from the Perp vault", async () => { + const { + collateralBalance: initialCollateralBalance + } = await perpLeverageModule.getAccountInfo(subjectSetToken); + + await subject(); + + const { + collateralBalance: finalCollateralBalance + } = await perpLeverageModule.getAccountInfo(subjectSetToken); + + // Levering up from 0, the absolute value of position quote balance is size of our trade + const { quoteBalance } = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0]; + const feeAmountInQuoteDecimals = preciseMul(quoteBalance.abs(), feePercentage); + + const expectedCollateralBalance = toUSDCDecimals(initialCollateralBalance).sub(toUSDCDecimals(feeAmountInQuoteDecimals)); + expect(toUSDCDecimals(finalCollateralBalance)).to.eq(expectedCollateralBalance); + }); + + it("should transfer the correct protocol fee to the protocol", async () => { + const feeRecipient = await setup.controller.feeRecipient(); + const initialFeeRecipientBalance = await usdc.balanceOf(feeRecipient); + + await subject(); + + const { quoteBalance } = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0]; + const feeAmountInQuoteDecimals = preciseMul(quoteBalance.mul(-1), feePercentage); + const feeAmountInUSDCDecimals = toUSDCDecimals(feeAmountInQuoteDecimals); + const expectedFeeRecipientBalance = initialFeeRecipientBalance.add(feeAmountInUSDCDecimals); + + const finalFeeRecipientBalance = await usdc.balanceOf(feeRecipient); + expect(finalFeeRecipientBalance).to.eq(expectedFeeRecipientBalance); + }); + + it("should not change the value of the SetToken USDC defaultPositionUnit", async () => { + const initialUSDCDefaultPositionUnit = await setToken.getDefaultPositionRealUnit(usdc.address); + await subject(); + const finalUSDCDefaultPositionUnit = await setToken.getDefaultPositionRealUnit(usdc.address); + + expect(initialUSDCDefaultPositionUnit).to.eq(finalUSDCDefaultPositionUnit); + }); + + it("should not update the USDC externalPositionUnit", async () => { + const initialExternalPositionUnit = await setToken.getExternalPositionRealUnit(usdc.address, perpLeverageModule.address); + await subject(); + const finalExternalPositionUnit = await setToken.getExternalPositionRealUnit(usdc.address, perpLeverageModule.address); + + expect(initialExternalPositionUnit).eq(finalExternalPositionUnit); + }); + + it("should emit the correct PerpTraded event", async () => { + const { + deltaBase: expectedDeltaBase, + deltaQuote: expectedDeltaQuote + } = await perpSetup.getSwapQuote(subjectBaseToken, subjectBaseTradeQuantityUnits, true); + + const expectedProtocolFee = toUSDCDecimals(preciseMul(expectedDeltaQuote, feePercentage)); + const expectedIsBuy = true; + + await expect(subject()).to.emit(perpLeverageModule, "PerpTraded").withArgs( + subjectSetToken, + subjectBaseToken, + expectedDeltaBase, + expectedDeltaQuote, + expectedProtocolFee, + expectedIsBuy + ); + }); + }); + + describe("when amount of token to trade is 0", async () => { + beforeEach(async () => { + subjectBaseTradeQuantityUnits = ZERO; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Amount is 0"); + }); + }); + + describe("when baseToken does not exist in Perp system", async () => { + beforeEach(async () => { + subjectBaseTradeQuantityUnits = ether(1); + subjectQuoteBoundQuantityUnits = ether(10.15); + subjectBaseToken = await getRandomAddress(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Base token does not exist"); + }); + }); + }); + + describe("when going short", () => { + beforeEach(async () => { + // Short ~10 USDC of vETH + subjectBaseTradeQuantityUnits = ether(-1); + subjectQuoteBoundQuantityUnits = ether(9.85); + }); + + it("should open the expected position", async () => { + const totalSupply = await setToken.totalSupply(); + const expectedBaseBalance = preciseMul(subjectBaseTradeQuantityUnits, totalSupply); + + const expectedQuoteBalance = (await perpSetup.getSwapQuote( + subjectBaseToken, + subjectBaseTradeQuantityUnits.mul(-1), + false + )).deltaQuote; + + const initialPositionInfo = await perpLeverageModule.getPositionNotionalInfo(subjectSetToken); + await subject(); + const finalPositionInfo = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0]; + + expect(initialPositionInfo.length).to.eq(0); + expect(finalPositionInfo.baseBalance).lt(0); + expect(finalPositionInfo.quoteBalance).gt(0); + expect(finalPositionInfo.baseBalance).eq(expectedBaseBalance); + expect(finalPositionInfo.quoteBalance).eq(expectedQuoteBalance); + expect(finalPositionInfo.quoteBalance).gt(subjectQuoteBoundQuantityUnits); + }); + + it("should emit the correct PerpTraded event", async () => { + const { + deltaBase: expectedDeltaBase, + deltaQuote: expectedDeltaQuote + } = await perpSetup.getSwapQuote(subjectBaseToken, subjectBaseTradeQuantityUnits.mul(-1), false); + + const expectedProtocolFee = ether(0); + const expectedIsBuy = false; + + await expect(subject()).to.emit(perpLeverageModule, "PerpTraded").withArgs( + subjectSetToken, + subjectBaseToken, + expectedDeltaBase, + expectedDeltaQuote, + expectedProtocolFee, + expectedIsBuy + ); + }); + + describe("when an existing position is long", async () => { + beforeEach(async () => { + await perpLeverageModule.connect(subjectCaller.wallet).trade( + subjectSetToken, + subjectBaseToken, + ether(1), + ether(10.15) + ); + + // Partial close + subjectBaseTradeQuantityUnits = ether(-.5); + subjectQuoteBoundQuantityUnits = ether(4.85); + }); + + it("short trade should reduce the position", async () => { + const totalSupply = await setToken.totalSupply(); + const baseTradeQuantityNotional = preciseMul(subjectBaseTradeQuantityUnits, totalSupply); + + const initialPositionInfo = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0]; + + await subject(); + + const finalPositionInfo = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0]; + const closeRatio = preciseDiv(baseTradeQuantityNotional, initialPositionInfo.baseBalance); + const reducedOpenNotional = preciseMul(initialPositionInfo.quoteBalance, closeRatio); + + const expectedBaseBalance = initialPositionInfo.baseBalance.sub(baseTradeQuantityNotional.abs()); + const expectedQuoteBalance = initialPositionInfo.quoteBalance.add(reducedOpenNotional); + + expect(finalPositionInfo.baseBalance).lt(initialPositionInfo.baseBalance); + expect(finalPositionInfo.quoteBalance).gt(initialPositionInfo.quoteBalance); + + expect(finalPositionInfo.baseBalance).eq(expectedBaseBalance); + expect(finalPositionInfo.quoteBalance).eq(expectedQuoteBalance); + }); + + describe("when the position is zeroed out", async () => { + beforeEach(async () => { + subjectBaseTradeQuantityUnits = ether(-1); + subjectQuoteBoundQuantityUnits = ether(9.85); + }); + + it("should remove the position from the positions array", async () => { + const initialPositionInfo = await perpLeverageModule.getPositionNotionalInfo(subjectSetToken); + await subject(); + const finalPositionInfo = await perpLeverageModule.getPositionNotionalInfo(subjectSetToken); + + expect(initialPositionInfo.length).eq(1); + expect(finalPositionInfo.length).eq(0); + }); + + it("should ensure no dust amount is left on PerpV2", async () => { + await subject(); + + const baseBalance = await perpSetup.accountBalance.getBase(setToken.address, vETH.address); + expect(baseBalance).to.be.eq(ZERO); + }); + }); + + describe("when the position reversed", async () => { + beforeEach(async () => { + subjectBaseTradeQuantityUnits = ether(-2); + subjectQuoteBoundQuantityUnits = ether(19.45); + }); + + it("short trade should reverse the long position to a short position", async () => { + const totalSupply = await setToken.totalSupply(); + const baseTradeQuantityNotional = preciseMul(subjectBaseTradeQuantityUnits, totalSupply); + + const { deltaQuote } = await perpSetup.getSwapQuote( + subjectBaseToken, + baseTradeQuantityNotional.abs(), + false // short + ); + + const initialPositionInfo = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0]; + + await subject(); + + const finalPositionInfo = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0]; + const closeRatio = preciseDiv(baseTradeQuantityNotional.abs(), initialPositionInfo.baseBalance.abs()); + const closedPositionNotional = preciseDiv(deltaQuote, closeRatio); + + const expectedBaseBalance = initialPositionInfo.baseBalance.sub(baseTradeQuantityNotional.abs()); + const expectedQuoteBalance = deltaQuote.sub(closedPositionNotional); + + expect(finalPositionInfo.baseBalance).lt(ZERO); + expect(finalPositionInfo.quoteBalance).gt(ZERO); + + expect(finalPositionInfo.baseBalance).eq(expectedBaseBalance); + expect(finalPositionInfo.quoteBalance).eq(expectedQuoteBalance); + }); + }); + }); + + describe("when an existing position is short", async () => { + beforeEach(async () => { + await perpLeverageModule.connect(subjectCaller.wallet).trade( + subjectSetToken, + subjectBaseToken, + ether(-1), + ether(9.85) + ); + }); + + it("short trade should increase the position size", async () => { + const totalSupply = await setToken.totalSupply(); + const initialPositionInfo = await perpLeverageModule.getPositionNotionalInfo(subjectSetToken); + const expectedDeltaBase = preciseMul(subjectBaseTradeQuantityUnits, totalSupply); + const expectedBaseBalance = initialPositionInfo[0].baseBalance.add(expectedDeltaBase); + + await subject(); + + const finalPositionInfo = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0]; + + expect(initialPositionInfo.length).to.eq(1); + expect(initialPositionInfo[0].baseBalance).gt(finalPositionInfo.baseBalance); + expect(finalPositionInfo.baseBalance).eq(expectedBaseBalance); + }); + }); + + describe("when a protocol fee is charged", async () => { + let feePercentage: BigNumber; + + cacheBeforeEach(async () => { + feePercentage = ether(0.05); + setup.controller = setup.controller.connect(owner.wallet); + + await setup.controller.addFee( + perpLeverageModule.address, + ZERO, // Fee type on trade function denoted as 0 + feePercentage + ); + + // Short ~10 USDC of vETH + subjectBaseTradeQuantityUnits = ether(-1); + subjectQuoteBoundQuantityUnits = ether(9.85); + }); + + it("should withdraw the expected collateral amount from the Perp vault", async () => { + const { + collateralBalance: initialCollateralBalance + } = await perpLeverageModule.getAccountInfo(subjectSetToken); + + await subject(); + + const { + collateralBalance: finalCollateralBalance + } = await perpLeverageModule.getAccountInfo(subjectSetToken); + + // Levering up from 0, the position quote balance is size of our trade + const { quoteBalance } = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0]; + const feeAmountInQuoteDecimals = preciseMul(quoteBalance, feePercentage); + + const expectedCollateralBalance = toUSDCDecimals(initialCollateralBalance).sub(toUSDCDecimals(feeAmountInQuoteDecimals)); + expect(toUSDCDecimals(finalCollateralBalance)).to.eq(expectedCollateralBalance); + }); + }); + + describe("when slippage is greater than allowed", async () => { + beforeEach(async () => { + // Short ~10 USDC of vETH, slippage incurred as smaller positive quote delta + subjectBaseTradeQuantityUnits = ether(-1); + subjectQuoteBoundQuantityUnits = ether(10); + }); + + it("should revert", async () => { + // ClearingHouse: too little quote received when short + await expect(subject()).to.be.revertedWith("CH_TLRS"); + }); + }); + }); + }); + + describe("when exceeds the max number of postions", async () => { + beforeEach(async () => { + // Open a WBTC position to max out the number of positions that can be opened per Set + await perpLeverageModule.connect(owner.wallet).updateMaxPerpPositionsPerSet(ONE); + + await perpLeverageModule.trade( + subjectSetToken, + vBTC.address, + ether(0.1), + ether(2.1) // 2.1 > 2 (20 * 0.1) + ); + + // Long ~10 USDC of vETH + subjectBaseTradeQuantityUnits = ether(1); + subjectQuoteBoundQuantityUnits = ether(10.15); + }); + + after(async () => { + await perpLeverageModule.connect(owner.wallet).updateMaxPerpPositionsPerSet(TWO); + }); + + it("should revert with exceeds max perpetual positions per set", async () => { + await expect(subject()).to.be.revertedWith("Exceeds max perpetual positions per set"); + }); + }); + + 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( + [perpSetup.usdc.address], + [usdcUnits(100)], + [perpLeverageModule.address], + owner.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(); + await initializeSubjectVariables(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be a valid and initialized SetToken"); + }); + }); + }); + + describe("#deposit", () => { + let subjectSetToken: SetToken; + let subjectDepositAmount: number; + let subjectDepositQuantity: BigNumber; + let subjectCaller: Account; + let isInitialized: boolean; + + const initializeContracts = async () => { + subjectSetToken = await setup.createSetToken( + [usdc.address], + [usdcUnits(100)], + [perpLeverageModule.address, debtIssuanceMock.address, setup.issuanceModule.address] + ); + + await debtIssuanceMock.initialize(subjectSetToken.address); + await perpLeverageModule.updateAllowedSetToken(subjectSetToken.address, true); + + if (isInitialized) { + await perpLeverageModule.initialize(subjectSetToken.address); + + const issueQuantity = ether(2); + await usdc.approve(setup.issuanceModule.address, usdcUnits(1000)); + await setup.issuanceModule.initialize(subjectSetToken.address, ADDRESS_ZERO); + await setup.issuanceModule.issue(subjectSetToken.address, issueQuantity, owner.address); + } + }; + + const initializeSubjectVariables = () => { + subjectCaller = owner; + subjectDepositAmount = 1; + subjectDepositQuantity = usdcUnits(subjectDepositAmount); + }; + + async function subject(): Promise { + return await perpLeverageModule + .connect(subjectCaller.wallet) + .deposit(subjectSetToken.address, subjectDepositQuantity); + } + + describe("when module is initialized", () => { + beforeEach(async () => { + isInitialized = true; + }); + + cacheBeforeEach(initializeContracts); + beforeEach(initializeSubjectVariables); + + it("should create a deposit", async () => { + const { + collateralBalance: initialCollateralBalance + } = await perpLeverageModule.getAccountInfo(subjectSetToken.address); + + await subject(); + + const { + collateralBalance: finalCollateralBalance + } = await perpLeverageModule.getAccountInfo(subjectSetToken.address); + + const totalSupply = await subjectSetToken.totalSupply(); + const expectedCollateralBalance = toUSDCDecimals(initialCollateralBalance) + .add(preciseMul(subjectDepositQuantity, totalSupply)); + expect(toUSDCDecimals(finalCollateralBalance)).to.eq(expectedCollateralBalance); + }); + + it("should add Perp as an external position module", async () => { + const initialExternalModules = await subjectSetToken.getExternalPositionModules(usdc.address); + + await subject(); + + const finalExternalPositionModules = await subjectSetToken.getExternalPositionModules(usdc.address); + + const expectedExternalPositionModule = perpLeverageModule.address; + + expect(initialExternalModules.length).eq(0); + expect(finalExternalPositionModules.length).eq(1); + expect(finalExternalPositionModules[0]).eq(expectedExternalPositionModule); + + }); + + it("should update the USDC defaultPositionUnit", async () => { + const initialDefaultPosition = await subjectSetToken.getDefaultPositionRealUnit(usdc.address); + await subject(); + const finalDefaultPosition = await subjectSetToken.getDefaultPositionRealUnit(usdc.address);; + + const expectedDefaultPosition = initialDefaultPosition.sub(usdcUnits(subjectDepositAmount)); + expect(finalDefaultPosition).to.eq(expectedDefaultPosition); + }); + + it("should update the USDC externalPositionUnit", async () => { + const initialExternalPositionUnit = await subjectSetToken.getExternalPositionRealUnit(usdc.address, perpLeverageModule.address); + await subject(); + const finalExternalPositionUnit = await subjectSetToken.getExternalPositionRealUnit(usdc.address, perpLeverageModule.address); + + const expectedDefaultPosition = initialExternalPositionUnit.add(subjectDepositQuantity); + expect(finalExternalPositionUnit).to.eq(expectedDefaultPosition); + }); + + it("should emit the correct CollateralDeposited event", async () => { + const totalSupply = await subjectSetToken.totalSupply(); + + await expect(subject()).to.emit(perpLeverageModule, "CollateralDeposited").withArgs( + subjectSetToken.address, + perpSetup.usdc.address, + preciseMul(subjectDepositQuantity, totalSupply) + ); + }); + + describe("when depositing and a position exists", () => { + let baseToken: Address; + + describe("when the position is long", async () => { + beforeEach(async () => { + await subject(); // should avoid calling subject here + baseToken = vETH.address; + await leverUp( + subjectSetToken, + perpLeverageModule, + perpSetup, + owner, + baseToken, + 2, + ether(.02), + true + ); + }); + + it("should create a deposit", async () => { + const { + collateralBalance: initialCollateralBalance + } = await perpLeverageModule.getAccountInfo(subjectSetToken.address); + + await subject(); + + const { + collateralBalance: finalCollateralBalance + } = await perpLeverageModule.getAccountInfo(subjectSetToken.address); + + const totalSupply = await subjectSetToken.totalSupply(); + const expectedCollateralBalance = toUSDCDecimals(initialCollateralBalance) + .add(preciseMul(subjectDepositQuantity, totalSupply)); + expect(toUSDCDecimals(finalCollateralBalance)).to.eq(expectedCollateralBalance); + }); + + + it("should set the expected position unit", async () => { + await subject(); + const externalPositionUnit = await subjectSetToken.getExternalPositionRealUnit(usdc.address, perpLeverageModule.address); + const expectedExternalPositionUnit = await calculateExternalPositionUnit( + subjectSetToken, + perpLeverageModule, + perpSetup + ); + + // Deposit notional amount = specified position unit * totalSupply = 1 * 2 = $2 + // We've put on a position that hasn't had any real pnl, so we expect set ~= $2 net fees & slippage + // externalPositionUnit = 1_979_877 + expect(externalPositionUnit).eq(expectedExternalPositionUnit); + }); + + it("should decrease the leverage ratio", async () => { + const positionInfo = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken.address))[0]; + const totalSupply = await subjectSetToken.totalSupply(); + + const { + collateralBalance: initialCollateralBalance + } = await perpLeverageModule.getAccountInfo(subjectSetToken.address); + + const initialLeverageRatio = await perpSetup.getCurrentLeverage( + subjectSetToken.address, + positionInfo, + initialCollateralBalance + ); + + await subject(); + + const { + collateralBalance: finalCollateralBalance + } = await perpLeverageModule.getAccountInfo(subjectSetToken.address); + + const finalLeverageRatio = await perpSetup.getCurrentLeverage( + subjectSetToken.address, + positionInfo, + finalCollateralBalance + ); + + const expectedCollateralBalance = toUSDCDecimals(initialCollateralBalance) + .add(preciseMul(subjectDepositQuantity, totalSupply)); + + // initialLeverageRatio = 2_040_484_848_517_694_106 + // finalLeverageRatio = 1_009_978_994_844_697_153 + expect(toUSDCDecimals(finalCollateralBalance)).to.eq(expectedCollateralBalance); + expect(finalLeverageRatio).lt(initialLeverageRatio); + }); + }); + + describe("when the position is short", async () => { + beforeEach(async () => { + await subject(); + baseToken = vETH.address; + await leverUp( + subjectSetToken, + perpLeverageModule, + perpSetup, + owner, + baseToken, + 2, + ether(.02), + false + ); + }); + + it("should create a deposit", async () => { + const { + collateralBalance: initialCollateralBalance + } = await perpLeverageModule.getAccountInfo(subjectSetToken.address); + + await subject(); + + const { + collateralBalance: finalCollateralBalance + } = await perpLeverageModule.getAccountInfo(subjectSetToken.address); + + const totalSupply = await subjectSetToken.totalSupply(); + const expectedCollateralBalance = toUSDCDecimals(initialCollateralBalance) + .add(preciseMul(subjectDepositQuantity, totalSupply)); + expect(toUSDCDecimals(finalCollateralBalance)).to.eq(expectedCollateralBalance); + }); + + it("should set the expected position unit", async () => { + await subject(); + const externalPositionUnit = await subjectSetToken.getExternalPositionRealUnit(usdc.address, perpLeverageModule.address); + const expectedExternalPositionUnit = await calculateExternalPositionUnit( + subjectSetToken, + perpLeverageModule, + perpSetup + ); + + // Deposit amount = $1 * 2 (two deposits) + // We've put on a position that hasn't had any real pnl, so we expect set ~= $2 net fees & slippage + // externalPositionUnit = 1_980_080 + expect(externalPositionUnit).eq(expectedExternalPositionUnit); + }); + + it("should decrease the leverage ratio", async () => { + const positionInfo = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken.address))[0]; + const totalSupply = await subjectSetToken.totalSupply(); + + const { + collateralBalance: initialCollateralBalance + } = await perpLeverageModule.getAccountInfo(subjectSetToken.address); + + const initialLeverageRatio = await perpSetup.getCurrentLeverage( + subjectSetToken.address, + positionInfo, + initialCollateralBalance + ); + + await subject(); + + const { + collateralBalance: finalCollateralBalance + } = await perpLeverageModule.getAccountInfo(subjectSetToken.address); + + const finalLeverageRatio = await perpSetup.getCurrentLeverage( + subjectSetToken.address, + positionInfo, + finalCollateralBalance + ); + + const expectedCollateralBalance = toUSDCDecimals(initialCollateralBalance) + .add(preciseMul(subjectDepositQuantity, totalSupply)); + + // initialLeverageRatio = 2_041_235_426_575_610_129 + // finalLeverageRation = 1_010_244_489_779_359_264 + expect(toUSDCDecimals(finalCollateralBalance)).to.eq(expectedCollateralBalance); + expect(finalLeverageRatio).lt(initialLeverageRatio); + }); + }); + + describe("when the position is mixed long and short", async () => { + beforeEach(async () => { + await subject(); + baseToken = vETH.address; + await leverUp( + subjectSetToken, + perpLeverageModule, + perpSetup, + owner, + vETH.address, + 2, + ether(.02), + true // long + ); + + await leverUp( + subjectSetToken, + perpLeverageModule, + perpSetup, + owner, + vBTC.address, + 2, + ether(.02), + false // short + ); + }); + + it("should create a deposit", async () => { + const { + collateralBalance: initialCollateralBalance + } = await perpLeverageModule.getAccountInfo(subjectSetToken.address); + + await subject(); + + const { + collateralBalance: finalCollateralBalance + } = await perpLeverageModule.getAccountInfo(subjectSetToken.address); + + const totalSupply = await subjectSetToken.totalSupply(); + const expectedCollateralBalance = toUSDCDecimals(initialCollateralBalance) + .add(preciseMul(subjectDepositQuantity, totalSupply)); + expect(toUSDCDecimals(finalCollateralBalance)).to.eq(expectedCollateralBalance); + }); + + it("should set the expected position unit", async () => { + await subject(); + const externalPositionUnit = await subjectSetToken.getExternalPositionRealUnit(usdc.address, perpLeverageModule.address); + const expectedExternalPositionUnit = await calculateExternalPositionUnit( + subjectSetToken, + perpLeverageModule, + perpSetup + ); + + // Deposit amount = $1 * 2 (two deposits) + // We've put on a position that hasn't had any real pnl, so we expect set ~= $2 net fees & slippage + // EPU is slightly lower here than previous cases since we've traded twice + // + // externalPositionUnit = 1_959_917 + expect(externalPositionUnit).eq(expectedExternalPositionUnit); + }); + }); + }); + + describe("when deposit amount is 0", async () => { + beforeEach(() => { + subjectDepositQuantity = usdcUnits(0); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Deposit amount is 0"); + }); + }); + + describe("when not called by manager", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be the SetToken manager"); + }); + }); + }); + + 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"); + }); + }); + }); + + describe("#withdraw", () => { + let depositQuantity: BigNumber; + let subjectSetToken: SetToken; + let subjectWithdrawQuantity: BigNumber; + let subjectCaller: Account; + let isInitialized: boolean; + + const initializeContracts = async () => { + subjectSetToken = await setup.createSetToken( + [usdc.address], + [usdcUnits(100)], + [perpLeverageModule.address, debtIssuanceMock.address, setup.issuanceModule.address] + ); + + await debtIssuanceMock.initialize(subjectSetToken.address); + await perpLeverageModule.updateAllowedSetToken(subjectSetToken.address, true); + + if (isInitialized) { + await perpLeverageModule.initialize(subjectSetToken.address); + + const issueQuantity = ether(2); + await usdc.approve(setup.issuanceModule.address, usdcUnits(1000)); + await setup.issuanceModule.initialize(subjectSetToken.address, ADDRESS_ZERO); + await setup.issuanceModule.issue(subjectSetToken.address, issueQuantity, owner.address); + + // Deposit 10 USDC + depositQuantity = usdcUnits(10); + await perpLeverageModule + .connect(owner.wallet) + .deposit(subjectSetToken.address, depositQuantity); + } + }; + + const initializeSubjectVariables = () => { + subjectCaller = owner; + subjectWithdrawQuantity = usdcUnits(5); + }; + + async function subject(): Promise { + return await perpLeverageModule + .connect(subjectCaller.wallet) + .withdraw(subjectSetToken.address, subjectWithdrawQuantity); + } + + describe("when module is initialized", () => { + beforeEach(async () => { + isInitialized = true; + }); + + cacheBeforeEach(initializeContracts); + beforeEach(() => initializeSubjectVariables()); + + it("should withdraw an amount", async () => { + const initialCollateralBalance = (await perpLeverageModule.getAccountInfo(subjectSetToken.address)).collateralBalance; + + await subject(); + + const finalCollateralBalance = (await perpLeverageModule.getAccountInfo(subjectSetToken.address)).collateralBalance; + + const totalSupply = await subjectSetToken.totalSupply(); + const expectedCollateralBalance = toUSDCDecimals(initialCollateralBalance) + .sub(preciseMul(subjectWithdrawQuantity, totalSupply)); + expect(toUSDCDecimals(finalCollateralBalance)).to.eq(expectedCollateralBalance); + }); + + it("should update the USDC defaultPositionUnit", async () => { + const initialDefaultPosition = await subjectSetToken.getDefaultPositionRealUnit(usdc.address); + await subject(); + const finalDefaultPosition = await subjectSetToken.getDefaultPositionRealUnit(usdc.address);; + + const expectedDefaultPosition = initialDefaultPosition.add(subjectWithdrawQuantity); + expect(finalDefaultPosition).to.eq(expectedDefaultPosition); + }); + + it("should update the USDC externalPositionUnit", async () => { + const initialExternalPositionUnit = await subjectSetToken.getExternalPositionRealUnit(usdc.address, perpLeverageModule.address); + await subject(); + const finalExternalPositionUnit = await subjectSetToken.getExternalPositionRealUnit(usdc.address, perpLeverageModule.address); + + const expectedExternalPositionUnit = initialExternalPositionUnit.sub(subjectWithdrawQuantity); + expect(finalExternalPositionUnit).to.eq(expectedExternalPositionUnit); + }); + + it("should emit the correct CollateralWithdrawn event", async () => { + const totalSupply = await subjectSetToken.totalSupply(); + + await expect(subject()).to.emit(perpLeverageModule, "CollateralWithdrawn").withArgs( + subjectSetToken.address, + perpSetup.usdc.address, + preciseMul(subjectWithdrawQuantity, totalSupply) + ); + }); + + describe("when withdraw amount is 0", async () => { + beforeEach(() => { + subjectWithdrawQuantity = usdcUnits(0); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Withdraw amount is 0"); + }); + }); + + describe("when the entire amount is withdrawn", async () => { + beforeEach(() => { + subjectWithdrawQuantity = depositQuantity; + }); + + it("should remove Perp as an external position module", async () => { + const initialExternalModules = await subjectSetToken.getExternalPositionModules(usdc.address); + await subject(); + const finalExternalPositionModules = await subjectSetToken.getExternalPositionModules(usdc.address); + + expect(initialExternalModules.length).eq(1); + expect(finalExternalPositionModules.length).eq(0); + }); + }); + + describe("when withdrawing and a position exists", () => { + let baseToken: Address; + + beforeEach(async () => { + baseToken = vETH.address; + await leverUp( + subjectSetToken, + perpLeverageModule, + perpSetup, + owner, + baseToken, + 2, + ether(.02), + true + ); + + subjectWithdrawQuantity = usdcUnits(2.5); + }); + + it("should decrease the collateral balance", async () => { + const initialCollateralBalance = (await perpLeverageModule.getAccountInfo(subjectSetToken.address)).collateralBalance; + + await subject(); + + const finalCollateralBalance = (await perpLeverageModule.getAccountInfo(subjectSetToken.address)).collateralBalance; + + const totalSupply = await subjectSetToken.totalSupply(); + const expectedCollateralBalance = toUSDCDecimals(initialCollateralBalance) + .sub(preciseMul(subjectWithdrawQuantity, totalSupply)); + + expect(toUSDCDecimals(finalCollateralBalance)).to.eq(expectedCollateralBalance); + }); + + it("should increase the leverage ratio", async () => { + const positionInfo = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken.address))[0]; + const totalSupply = await subjectSetToken.totalSupply(); + + const { + collateralBalance: initialCollateralBalance + } = await perpLeverageModule.getAccountInfo(subjectSetToken.address); + + const initialLeverageRatio = await perpSetup.getCurrentLeverage( + subjectSetToken.address, + positionInfo, + initialCollateralBalance + ); + + await subject(); + + const { + collateralBalance: finalCollateralBalance + } = await perpLeverageModule.getAccountInfo(subjectSetToken.address); + + const finalLeverageRatio = await perpSetup.getCurrentLeverage( + subjectSetToken.address, + positionInfo, + finalCollateralBalance + ); + + const expectedCollateralBalance = toUSDCDecimals(initialCollateralBalance) + .sub(preciseMul(subjectWithdrawQuantity, totalSupply)); + + // initialLeverageRatio = 2_041_219_945_269_276_819 + // finalLeverageRatio = 2_739_702_831_474_076_071 + expect(toUSDCDecimals(finalCollateralBalance)).to.eq(expectedCollateralBalance); + expect(finalLeverageRatio).gt(initialLeverageRatio); + }); + }); + + describe("when not called by manager", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be the SetToken manager"); + }); + }); + }); + + 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"); + }); + }); + }); + + describe("#moduleIssueHook", () => { + let setToken: SetToken; + let collateralQuantity: BigNumber; + let subjectSetToken: Address; + let subjectCaller: Account; + let subjectSetQuantity: BigNumber; + + // Start with initial total supply (2) + const initializeContracts = async () => { + collateralQuantity = usdcUnits(10); + setToken = await issueSetsAndDepositToPerp(collateralQuantity, true, ether(2)); + }; + + const initializeSubjectVariables = () => { + subjectSetToken = setToken.address; + subjectCaller = mockModule; + subjectSetQuantity = ether(1); + }; + + cacheBeforeEach(initializeContracts); + beforeEach(initializeSubjectVariables); + + async function subject(): Promise { + return await perpLeverageModule + .connect(subjectCaller.wallet) + .moduleIssueHook(subjectSetToken, subjectSetQuantity); + } + + describe("when long, single position", () => { + let baseToken: Address; + + // Set up as 2X Long, allow 2% slippage + cacheBeforeEach(async () => { + baseToken = vETH.address; + await leverUp( + setToken, + perpLeverageModule, + perpSetup, + owner, + baseToken, + 2, + ether(.02), + true + ); + }); + + describe("when issuing a single set", async () => { + let usdcTransferInQuantity: BigNumber; + + beforeEach(async () => { + usdcTransferInQuantity = await calculateUSDCTransferIn( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + }); + + it("buys expected amount of vBase", async () => { + const totalSupply = await setToken.totalSupply(); + + const initialBaseBalance = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0].baseBalance; + + await subject(); + + const finalBaseBalance = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0].baseBalance; + + const basePositionUnit = preciseDiv(initialBaseBalance, totalSupply); + const baseTokenBoughtNotional = preciseMul(basePositionUnit, subjectSetQuantity); + const expectedBaseBalance = initialBaseBalance.add(baseTokenBoughtNotional); + + expect(finalBaseBalance).eq(expectedBaseBalance); + }); + + // There are 2 ways to handle funding accrued between when we fetch the usdc amount to be + // transferred in and when we determine usdc amount to be transferred in on chain. + // 1. Force funding to zero by syncing the oracle price to spot price + // 2. Calculate accrued funding and add it to our usdc amount to be transferred in + describe("sync oracle in before each", async () => { + beforeEach(async () => { + await syncOracleToSpot(vETH); + }); + + it("should set the expected USDC externalPositionUnit", async () => { + const expectedExternalPositionUnit = preciseDiv(usdcTransferInQuantity, subjectSetQuantity); + + await subject(); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(externalPositionUnit).to.eq(expectedExternalPositionUnit); + }); + }); + + describe("do not sync oracle in before each", async () => { + beforeEach(async () => { + usdcTransferInQuantity = await calculateUSDCTransferInPreciseUnits( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup, + false + ); + }); + + it("should set the expected USDC externalPositionUnit", async () => { + const baseBalance = await perpSetup.accountBalance.getBase(setToken.address, baseToken); + + await subject(); + + const usdcAmountDelta = await getUSDCDeltaDueToFundingGrowth( + setToken, + subjectSetQuantity, + baseToken, + baseBalance, + perpSetup + ); + const expectedExternalPositionUnit = toUSDCDecimals( + preciseDiv(usdcTransferInQuantity.add(usdcAmountDelta), subjectSetQuantity) + ); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(externalPositionUnit).to.eq(expectedExternalPositionUnit); + }); + }); + }); + + describe("when there is positive owedRealizedPnl", async () => { + beforeEach(async () => { + // Move price up by maker buying 20k USDC of vETH + // Post trade spot price rises from ~10 USDC to 14_356_833_358_751_766_356 + await perpSetup.clearingHouse.connect(maker.wallet).openPosition({ + baseToken: vETH.address, + isBaseToQuote: false, // long + isExactInput: true, // `amount` is USDC + amount: ether(20000), + oppositeAmountBound: ZERO, + deadline: MAX_UINT_256, + sqrtPriceLimitX96: ZERO, + referralCode: ZERO_BYTES + }); + + // Sell a little, booking profit to owedRealizedPnl + await perpLeverageModule.connect(owner.wallet).trade( + subjectSetToken, + vETH.address, + ether(-.4), + ether(4) + ); + + await syncOracleToSpot(vETH); + }); + + it("should set the expected USDC externalPositionUnit", async () => { + const owedRealizedPnl = (await perpLeverageModule.getAccountInfo(subjectSetToken)).owedRealizedPnl; + + const usdcTransferInQuantity = await calculateUSDCTransferIn( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + const expectedExternalPositionUnit = preciseDiv( + usdcTransferInQuantity, + subjectSetQuantity + ); + + await subject(); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(owedRealizedPnl).gt(0); + expect(externalPositionUnit).to.eq(expectedExternalPositionUnit); + }); + }); + + describe("when there is negative owedRealizedPnl", async () => { + beforeEach(async () => { + // Move price down by maker selling 20k USDC of vETH + // Post trade spot price rises from ~10 USDC to 6_370_910_537_702_299_856 + await perpSetup.clearingHouse.connect(maker.wallet).openPosition({ + baseToken: vETH.address, + isBaseToQuote: true, // short + isExactInput: false, // `amount` is USDC + amount: ether(20000), + oppositeAmountBound: ZERO, + deadline: MAX_UINT_256, + sqrtPriceLimitX96: ZERO, + referralCode: ZERO_BYTES + }); + + // Sell a little, booking loss to owedRealizedPnl + await perpLeverageModule.connect(owner.wallet).trade( + subjectSetToken, + vETH.address, + ether(-.4), + ether(1) + ); + + await syncOracleToSpot(vETH); + }); + + it("should set the expected USDC externalPositionUnit", async () => { + const owedRealizedPnl = (await perpLeverageModule.getAccountInfo(subjectSetToken)).owedRealizedPnl; + + const usdcTransferInQuantity = await calculateUSDCTransferIn( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + const expectedExternalPositionUnit = preciseDiv(usdcTransferInQuantity, subjectSetQuantity); + + await subject(); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(owedRealizedPnl).lt(ether(1).mul(-1)); + expect(externalPositionUnit).to.be.eq(expectedExternalPositionUnit); + }); + }); + + describe("when the Set owes funding", async () => { + beforeEach(async() => { + // set funding rate to non-zero value + await perpSetup.clearingHouseConfig.setMaxFundingRate(BigNumber.from(0.1e6)); // 10% in decimal 6 + }); + + it("should socialize the funding payment among existing set holders", async () => { + // Move oracle price down and wait one day + await perpSetup.setBaseTokenOraclePrice(vETH, usdcUnits(9.5)); + await increaseTimeAsync(ONE_DAY_IN_SECONDS); + + const baseBalance = await perpSetup.accountBalance.getBase(setToken.address, baseToken); + const pendingFunding = (await perpLeverageModule.getAccountInfo(subjectSetToken)).pendingFundingPayments; + const usdcTransferInQuantity = await calculateUSDCTransferInPreciseUnits( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup, + false // don't include funding + ); + + await subject(); + + const usdcAmountDelta = await getUSDCDeltaDueToFundingGrowth(setToken, subjectSetQuantity, baseToken, baseBalance, perpSetup); + const expectedExternalPositionUnit = toUSDCDecimals( + preciseDiv(usdcTransferInQuantity.add(usdcAmountDelta), subjectSetQuantity) + ); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(pendingFunding).lt(0); + expect(externalPositionUnit).to.be.eq(expectedExternalPositionUnit); + }); + }); + + describe("when the Set is owed funding", async () => { + beforeEach(async() => { + // set funding rate to non-zero value + await perpSetup.clearingHouseConfig.setMaxFundingRate(BigNumber.from(0.1e6)); // 10% in decimal 6 + }); + + it("should socialize the funding payment among existing set holders", async () => { + // Move oracle price up and wait one day + await perpSetup.setBaseTokenOraclePrice(vETH, usdcUnits(15)); + await increaseTimeAsync(ONE_DAY_IN_SECONDS); + + const baseBalance = await perpSetup.accountBalance.getBase(setToken.address, baseToken); + const pendingFunding = (await perpLeverageModule.getAccountInfo(subjectSetToken)).pendingFundingPayments; + const usdcTransferInQuantity = await calculateUSDCTransferInPreciseUnits( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup, + false // don't include funding + ); + + await subject(); + + const usdcAmountDelta = await getUSDCDeltaDueToFundingGrowth(setToken, subjectSetQuantity, baseToken, baseBalance, perpSetup); + const expectedExternalPositionUnit = toUSDCDecimals( + preciseDiv(usdcTransferInQuantity.add(usdcAmountDelta), subjectSetQuantity) + ); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(pendingFunding).gt(ZERO); + expect(externalPositionUnit).to.be.eq(expectedExternalPositionUnit); + }); + }); + + describe("when the market price moves up and leverage drops", async () => { + it("test assumptions and preconditions should be correct", async () => { + let positionInfo = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0]; + let collateralBalance = (await perpLeverageModule.getAccountInfo(subjectSetToken)).collateralBalance; + + const initialLeverage = await perpSetup.getCurrentLeverage(subjectSetToken, positionInfo, collateralBalance); + const initialSpotPrice = await perpSetup.getSpotPrice(vETH.address); + const initialUSDCTransferInQuantity = await calculateUSDCTransferIn( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + await perpSetup.clearingHouse.connect(maker.wallet).openPosition({ + baseToken: vETH.address, + isBaseToQuote: false, // long + isExactInput: true, // `amount` is USDC + amount: ether(10000), // move price up by buying 10k USDC of vETH + oppositeAmountBound: ZERO, + deadline: MAX_UINT_256, + sqrtPriceLimitX96: ZERO, + referralCode: ZERO_BYTES + }); + + positionInfo = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0]; + collateralBalance = (await perpLeverageModule.getAccountInfo(subjectSetToken)).collateralBalance; + + const finalLeverage = await perpSetup.getCurrentLeverage(subjectSetToken, positionInfo, collateralBalance); + const finalSpotPrice = await perpSetup.getSpotPrice(vETH.address); + const finalUSDCTransferInQuantity = await calculateUSDCTransferIn( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + expect(initialSpotPrice).lt(finalSpotPrice); + expect(initialUSDCTransferInQuantity).lt(finalUSDCTransferInQuantity); + expect(initialLeverage).gt(ZERO); + expect(finalLeverage).gt(ZERO); + expect(initialLeverage).gt(finalLeverage); + }); + + it("buys expected amount of vBase", async () => { + await perpSetup.clearingHouse.connect(maker.wallet).openPosition({ + baseToken: vETH.address, + isBaseToQuote: false, // long + isExactInput: true, // `amount` is USDC + amount: ether(10000), // move price up by buying 10k USDC of vETH + oppositeAmountBound: ZERO, + deadline: MAX_UINT_256, + sqrtPriceLimitX96: ZERO, + referralCode: ZERO_BYTES + }); + + const initialBaseBalance = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0].baseBalance; + await subject(); + const finalBaseBalance = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0].baseBalance; + + const basePositionUnit = preciseDiv(initialBaseBalance, await setToken.totalSupply()); + const baseTokenBoughtNotional = preciseMul(basePositionUnit, subjectSetQuantity); + const expectedBaseBalance = initialBaseBalance.add(baseTokenBoughtNotional); + + expect(finalBaseBalance).eq(expectedBaseBalance); + }); + + it("should set the expected USDC externalPositionUnit", async () => { + await perpSetup.clearingHouse.connect(maker.wallet).openPosition({ + baseToken: vETH.address, + isBaseToQuote: false, // long + isExactInput: true, // `amount` is USDC + amount: ether(10000), // move price up by buying 10k worth of vETH + oppositeAmountBound: ZERO, + deadline: MAX_UINT_256, + sqrtPriceLimitX96: ZERO, + referralCode: ZERO_BYTES + }); + + const usdcTransferInQuantity = await calculateUSDCTransferInPreciseUnits( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + await subject(); + + const expectedExternalPositionUnit = toUSDCDecimals( + preciseDiv(usdcTransferInQuantity, subjectSetQuantity) + ); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(externalPositionUnit).to.be.eq(expectedExternalPositionUnit); + }); + }); + + describe("when issuing multiple sets", async () => { + beforeEach(async () => { + subjectSetQuantity = ether(2); + }); + + it("buys expected amount of vETH, vBTC", async () => { + const totalSupply = await setToken.totalSupply(); + const initialPositionInfo = await perpLeverageModule.getPositionNotionalInfo(subjectSetToken); + const initialBalance = initialPositionInfo[0].baseBalance; + + await subject(); + + const finalPositionInfo = await perpLeverageModule.getPositionNotionalInfo(subjectSetToken); + const finalBalance = finalPositionInfo[0].baseBalance; + + const basePositionUnit = preciseDiv(initialBalance, totalSupply); + const baseBoughtNotional = preciseMul(basePositionUnit, subjectSetQuantity); + + const expectedBalance = initialBalance.add(baseBoughtNotional); + + expect(finalBalance).eq(expectedBalance); + }); + + it("should set the expected USDC externalPositionUnit", async () => { + const usdcTransferInQuantity = await calculateUSDCTransferInPreciseUnits( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + await subject(); + + const expectedExternalPositionUnit = toUSDCDecimals( + preciseDiv(usdcTransferInQuantity, subjectSetQuantity) + ); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(externalPositionUnit).to.be.eq(expectedExternalPositionUnit); + }); + }); + }); + + describe("when long, multiple positions", async () => { + // Set up as 2X Long, allow 2% slippage + cacheBeforeEach(async () => { + await leverUp( + setToken, + perpLeverageModule, + perpSetup, + owner, + vETH.address, + 2, + ether(.02), + true + ); + + await leverUp( + setToken, + perpLeverageModule, + perpSetup, + owner, + vBTC.address, + 1, + ether(.02), + true + ); + }); + + describe("when issuing a single set", async () => { + it("buys expected amount of vETH, vBTC", async () => { + const totalSupply = await setToken.totalSupply(); + const initialPositionInfo = await perpLeverageModule.getPositionNotionalInfo(subjectSetToken); + + const initialVETHBalance = initialPositionInfo[0].baseBalance; + const initialVBTCBalance = initialPositionInfo[1].baseBalance; + + await subject(); + + const finalPositionInfo = await perpLeverageModule.getPositionNotionalInfo(subjectSetToken); + const finalVETHBalance = finalPositionInfo[0].baseBalance; + const finalVBTCBalance = finalPositionInfo[1].baseBalance; + + const vETHPositionUnit = preciseDiv(initialVETHBalance, totalSupply); + const vBTCPositionUnit = preciseDiv(initialVBTCBalance, totalSupply); + + const vETHBoughtNotional = preciseMul(vETHPositionUnit, subjectSetQuantity); + const vBTCBoughtNotional = preciseMul(vBTCPositionUnit, subjectSetQuantity); + + const expectedVETHBalance = initialVETHBalance.add(vETHBoughtNotional); + const expectedVBTCBalance = initialVBTCBalance.add(vBTCBoughtNotional); + + expect(finalVETHBalance).eq(expectedVETHBalance); + expect(finalVBTCBalance).eq(expectedVBTCBalance); + }); + + it("should set the expected USDC externalPositionUnit", async () => { + const usdcTransferInQuantity = await calculateUSDCTransferInPreciseUnits( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + await subject(); + + const expectedExternalPositionUnit = toUSDCDecimals( + preciseDiv(usdcTransferInQuantity, subjectSetQuantity) + ); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(externalPositionUnit).to.be.eq(expectedExternalPositionUnit); + }); + }); + + describe("when issuing multiple sets", async () => { + beforeEach(async () => { + subjectSetQuantity = ether(2); + }); + + it("buys expected amount of vETH, vBTC", async () => { + const totalSupply = await setToken.totalSupply(); + const initialPositionInfo = await perpLeverageModule.getPositionNotionalInfo(subjectSetToken); + + const initialVETHBalance = initialPositionInfo[0].baseBalance; + const initialVBTCBalance = initialPositionInfo[1].baseBalance; + + await subject(); + + const finalPositionInfo = await perpLeverageModule.getPositionNotionalInfo(subjectSetToken); + const finalVETHBalance = finalPositionInfo[0].baseBalance; + const finalVBTCBalance = finalPositionInfo[1].baseBalance; + + const vETHPositionUnit = preciseDiv(initialVETHBalance, totalSupply); + const vBTCPositionUnit = preciseDiv(initialVBTCBalance, totalSupply); + + const vETHBoughtNotional = preciseMul(vETHPositionUnit, subjectSetQuantity); + const vBTCBoughtNotional = preciseMul(vBTCPositionUnit, subjectSetQuantity); + + const expectedVETHBalance = initialVETHBalance.add(vETHBoughtNotional); + const expectedVBTCBalance = initialVBTCBalance.add(vBTCBoughtNotional); + + expect(finalVETHBalance).eq(expectedVETHBalance); + expect(finalVBTCBalance).eq(expectedVBTCBalance); + }); + + it("should set the expected USDC externalPositionUnit", async () => { + const usdcTransferInQuantity = await calculateUSDCTransferInPreciseUnits( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + await subject(); + + const expectedExternalPositionUnit = toUSDCDecimals( + preciseDiv(usdcTransferInQuantity, subjectSetQuantity) + ); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(externalPositionUnit).to.be.eq(expectedExternalPositionUnit); + }); + }); + + describe("when the Set owes funding", async () => { + beforeEach(async() => { + // set funding rate to non-zero value + await perpSetup.clearingHouseConfig.setMaxFundingRate(BigNumber.from(0.1e6)); // 10% in decimal 6 + }); + + it("should socialize the funding payment among existing set holders", async () => { + // Move oracle price down and wait one day + await perpSetup.setBaseTokenOraclePrice(vETH, usdcUnits(9.8)); + await perpSetup.setBaseTokenOraclePrice(vBTC, usdcUnits(19.9)); + await increaseTimeAsync(ONE_DAY_IN_SECONDS); + + const vEthBalance = await perpSetup.accountBalance.getBase(setToken.address, vETH.address); + const vBtcBalance = await perpSetup.accountBalance.getBase(setToken.address, vBTC.address); + const pendingFunding = (await perpLeverageModule.getAccountInfo(subjectSetToken)).pendingFundingPayments; + const usdcTransferInQuantity = await calculateUSDCTransferInPreciseUnits( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup, + false // don't include funding + ); + + await subject(); + + const vEthUsdcAmountDelta = await getUSDCDeltaDueToFundingGrowth(setToken, subjectSetQuantity, vETH.address, vEthBalance, perpSetup); + const vBtcUsdcAmountDelta = await getUSDCDeltaDueToFundingGrowth(setToken, subjectSetQuantity, vBTC.address, vBtcBalance, perpSetup); + const expectedExternalPositionUnit = toUSDCDecimals( + preciseDiv(usdcTransferInQuantity.add(vEthUsdcAmountDelta).add(vBtcUsdcAmountDelta), subjectSetQuantity) + ); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(pendingFunding).lt(0); + expect(externalPositionUnit).to.be.eq(expectedExternalPositionUnit); + }); + }); + + describe("when the Set is owed funding", async () => { + beforeEach(async() => { + // set funding rate to non-zero value + await perpSetup.clearingHouseConfig.setMaxFundingRate(BigNumber.from(0.1e6)); // 10% in decimal 6 + }); + + it("should socialize the funding payment among existing set holders", async () => { + // Move oracle price up and wait one day + await perpSetup.setBaseTokenOraclePrice(vETH, usdcUnits(10.5)); + await perpSetup.setBaseTokenOraclePrice(vBTC, usdcUnits(20.1)); + + await increaseTimeAsync(ONE_DAY_IN_SECONDS); + + const vEthBalance = await perpSetup.accountBalance.getBase(setToken.address, vETH.address); + const vBtcBalance = await perpSetup.accountBalance.getBase(setToken.address, vBTC.address); + const pendingFunding = (await perpLeverageModule.getAccountInfo(subjectSetToken)).pendingFundingPayments; + const usdcTransferInQuantity = await calculateUSDCTransferInPreciseUnits( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup, + false // don't include funding + ); + + await subject(); + + const vEthUsdcAmountDelta = await getUSDCDeltaDueToFundingGrowth(setToken, subjectSetQuantity, vETH.address, vEthBalance, perpSetup); + const vBtcUsdcAmountDelta = await getUSDCDeltaDueToFundingGrowth(setToken, subjectSetQuantity, vBTC.address, vBtcBalance, perpSetup); + const expectedExternalPositionUnit = toUSDCDecimals( + preciseDiv(usdcTransferInQuantity.add(vEthUsdcAmountDelta).add(vBtcUsdcAmountDelta), subjectSetQuantity) + ); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(pendingFunding).gt(ZERO); + expect(externalPositionUnit).to.be.eq(expectedExternalPositionUnit); + }); + }); + + describe("when there is positive owedRealizedPnl", async () => { + beforeEach(async () => { + // Move price up by maker buying 20k USDC of vETH + await perpSetup.clearingHouse.connect(maker.wallet).openPosition({ + baseToken: vETH.address, + isBaseToQuote: false, // long + isExactInput: true, // `amount` is USDC + amount: ether(20000), + oppositeAmountBound: ZERO, + deadline: MAX_UINT_256, + sqrtPriceLimitX96: ZERO, + referralCode: ZERO_BYTES + }); + + await perpSetup.clearingHouse.connect(maker.wallet).openPosition({ + baseToken: vBTC.address, + isBaseToQuote: false, // long + isExactInput: true, // `amount` is USDC + amount: ether(20000), + oppositeAmountBound: ZERO, + deadline: MAX_UINT_256, + sqrtPriceLimitX96: ZERO, + referralCode: ZERO_BYTES + }); + + // Delever a little, booking profit to owedRealizedPnl + await perpLeverageModule.connect(owner.wallet).trade( + subjectSetToken, + vETH.address, + ether(-.4), + ether(4) + ); + + await perpLeverageModule.connect(owner.wallet).trade( + subjectSetToken, + vBTC.address, + ether(-.2), + ether(2) + ); + }); + + it("should set the expected USDC externalPositionUnit", async () => { + const owedRealizedPnl = (await perpLeverageModule.getAccountInfo(subjectSetToken)).owedRealizedPnl; + + const usdcTransferInQuantity = await calculateUSDCTransferIn( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + const expectedExternalPositionUnit = preciseDiv(usdcTransferInQuantity, subjectSetQuantity); + + await subject(); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(owedRealizedPnl).gt(0); + expect(externalPositionUnit).to.be.eq(expectedExternalPositionUnit); + }); + }); + }); + + describe("when short", async () => { + let baseToken: Address; + + // Set up as 2X Short, allow 2% slippage + cacheBeforeEach(async () => { + baseToken = vETH.address; + await leverUp( + setToken, + perpLeverageModule, + perpSetup, + owner, + baseToken, + 2, + ether(.02), + false + ); + }); + + async function subject(): Promise { + await perpLeverageModule + .connect(subjectCaller.wallet) + .moduleIssueHook(subjectSetToken, subjectSetQuantity); + } + + describe("when issuing a single set", async () => { + it("shorts expected amount of vBase", async () => { + const totalSupply = await setToken.totalSupply(); + + const initialBaseBalance = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0].baseBalance; + await subject(); + const finalBaseBalance = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0].baseBalance; + + const basePositionUnit = preciseDiv(initialBaseBalance, totalSupply); + const baseTokenSoldNotional = preciseMul(basePositionUnit, subjectSetQuantity); + const expectedBaseBalance = initialBaseBalance.add(baseTokenSoldNotional); + + expect(finalBaseBalance).eq(expectedBaseBalance); + }); + + it("should set the expected USDC externalPositionUnit", async () => { + const usdcTransferInQuantity = await calculateUSDCTransferInPreciseUnits( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + await subject(); + + const expectedExternalPositionUnit = toUSDCDecimals( + preciseDiv(usdcTransferInQuantity, subjectSetQuantity) + ); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(externalPositionUnit).to.be.eq(expectedExternalPositionUnit); + }); + }); + + describe("when there is positive owedRealizedPnl", async () => { + beforeEach(async () => { + // Move price down by maker selling 10k USDC of vETH + await perpSetup.clearingHouse.connect(maker.wallet).openPosition({ + baseToken: vETH.address, + isBaseToQuote: true, // short + isExactInput: false, // `amount` is USDC + amount: ether(1000), + oppositeAmountBound: ZERO, + deadline: MAX_UINT_256, + sqrtPriceLimitX96: ZERO, + referralCode: ZERO_BYTES + }); + + // Buy a little, booking profit to owedRealizedPnl + await perpLeverageModule.connect(owner.wallet).trade( + subjectSetToken, + vETH.address, + ether(.1), + ether(2) + ); + }); + + it("should set the expected USDC externalPositionUnit", async () => { + const owedRealizedPnl = (await perpLeverageModule.getAccountInfo(subjectSetToken)).owedRealizedPnl; + + const usdcTransferInQuantity = await calculateUSDCTransferIn( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + const expectedExternalPositionUnit = preciseDiv( + usdcTransferInQuantity, + subjectSetQuantity + ); + + await subject(); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(owedRealizedPnl).gt(0); + expect(externalPositionUnit).to.be.eq(expectedExternalPositionUnit); + }); + }); + + describe("when there is negative owedRealizedPnl", async () => { + beforeEach(async () => { + // Move price up by maker buying 20k USDC of vETH + await perpSetup.clearingHouse.connect(maker.wallet).openPosition({ + baseToken: vETH.address, + isBaseToQuote: false, // long + isExactInput: true, // `amount` is USDC + amount: ether(1000), + oppositeAmountBound: ZERO, + deadline: MAX_UINT_256, + sqrtPriceLimitX96: ZERO, + referralCode: ZERO_BYTES + }); + + // Buy a little, booking loss to owedRealizedPnl + await perpLeverageModule.connect(owner.wallet).trade( + subjectSetToken, + vETH.address, + ether(.1), + ether(2) + ); + }); + + it("should set the expected USDC externalPositionUnit", async () => { + const owedRealizedPnl = (await perpLeverageModule.getAccountInfo(subjectSetToken)).owedRealizedPnl; + + + const usdcTransferInQuantity = await calculateUSDCTransferIn( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + const expectedExternalPositionUnit = preciseDiv( + usdcTransferInQuantity, + subjectSetQuantity + ); + + await subject(); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(owedRealizedPnl).lt(0); + expect(externalPositionUnit).to.be.eq(expectedExternalPositionUnit); + }); + }); + + describe("when the Set owes funding", async () => { + beforeEach(async() => { + // set funding rate to non-zero value + await perpSetup.clearingHouseConfig.setMaxFundingRate(BigNumber.from(0.1e6)); // 10% in decimal 6 + }); + + it("should socialize the funding payment among existing set holders", async () => { + // Move oracle price up and wait one day + await perpSetup.setBaseTokenOraclePrice(vETH, usdcUnits(10.5)); + await increaseTimeAsync(ONE_DAY_IN_SECONDS); + + const baseBalance = await perpSetup.accountBalance.getBase(setToken.address, baseToken); + const pendingFunding = (await perpLeverageModule.getAccountInfo(subjectSetToken)).pendingFundingPayments; + + const usdcTransferInQuantity = await calculateUSDCTransferInPreciseUnits( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup, + false // don't include funding + ); + + await subject(); + + const usdcAmountDelta = await getUSDCDeltaDueToFundingGrowth(setToken, subjectSetQuantity, baseToken, baseBalance, perpSetup); + const expectedExternalPositionUnit = toUSDCDecimals( + preciseDiv(usdcTransferInQuantity.add(usdcAmountDelta), subjectSetQuantity) + ); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(pendingFunding).lt(0); + expect(externalPositionUnit).to.be.eq(expectedExternalPositionUnit); + }); + }); + + describe("when the Set is owed funding", async () => { + beforeEach(async() => { + // set funding rate to non-zero value + await perpSetup.clearingHouseConfig.setMaxFundingRate(BigNumber.from(0.1e6)); // 10% in decimal 6 + }); + + it("should socialize the funding payment among existing set holders", async () => { + // Move oracle price down and wait one day + await perpSetup.setBaseTokenOraclePrice(vETH, usdcUnits(5)); + await increaseTimeAsync(ONE_DAY_IN_SECONDS); + + const baseBalance = await perpSetup.accountBalance.getBase(setToken.address, baseToken); + const pendingFunding = (await perpLeverageModule.getAccountInfo(subjectSetToken)).pendingFundingPayments; + const usdcTransferInQuantity = await calculateUSDCTransferInPreciseUnits( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup, + false // don't include funding + ); + + await subject(); + + const usdcAmountDelta = await getUSDCDeltaDueToFundingGrowth(setToken, subjectSetQuantity, baseToken, baseBalance, perpSetup); + const expectedExternalPositionUnit = toUSDCDecimals( + preciseDiv(usdcTransferInQuantity.add(usdcAmountDelta), subjectSetQuantity) + ); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(pendingFunding).gt(ZERO); + expect(externalPositionUnit).to.be.eq(expectedExternalPositionUnit); + }); + }); + + describe("when the market price moves down and leverage drops", async () => { + it("test assumptions and preconditions should be correct", async () => { + let positionInfo = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0]; + let collateralBalance = (await perpLeverageModule.getAccountInfo(subjectSetToken)).collateralBalance; + + const initialLeverage = await perpSetup.getCurrentLeverage(subjectSetToken, positionInfo, collateralBalance); + const initialSpotPrice = await perpSetup.getSpotPrice(vETH.address); + const initialUSDCTransferInQuantity = await calculateUSDCTransferIn( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + await perpSetup.clearingHouse.connect(maker.wallet).openPosition({ + baseToken: vETH.address, + isBaseToQuote: true, // short + isExactInput: false, // `amount` is USDC + amount: ether(10000), // move price down by selling 10k USDC of vETH + oppositeAmountBound: ZERO, + deadline: MAX_UINT_256, + sqrtPriceLimitX96: ZERO, + referralCode: ZERO_BYTES + }); + + positionInfo = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0]; + collateralBalance = (await perpLeverageModule.getAccountInfo(subjectSetToken)).collateralBalance; + + const finalLeverage = await perpSetup.getCurrentLeverage(subjectSetToken, positionInfo, collateralBalance); + const finalSpotPrice = await perpSetup.getSpotPrice(vETH.address); + const finalUSDCTransferInQuantity = await calculateUSDCTransferIn( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + expect(initialSpotPrice).gt(finalSpotPrice); + expect(initialUSDCTransferInQuantity).lt(finalUSDCTransferInQuantity); + expect(initialLeverage).gt(ZERO); + expect(finalLeverage).gt(ZERO); + expect(initialLeverage).gt(finalLeverage); + }); + + it("sells expected amount of vBase", async () => { + await perpSetup.clearingHouse.connect(maker.wallet).openPosition({ + baseToken: vETH.address, + isBaseToQuote: true, // short + isExactInput: false, // `amount` is USDC + amount: ether(10000), // move price down by selling 10k USDC of vETH + oppositeAmountBound: ZERO, + deadline: MAX_UINT_256, + sqrtPriceLimitX96: ZERO, + referralCode: ZERO_BYTES + }); + + const initialBaseBalance = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0].baseBalance; + await subject(); + const finalBaseBalance = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0].baseBalance; + + const basePositionUnit = preciseDiv(initialBaseBalance, await setToken.totalSupply()); + const baseTokenBoughtNotional = preciseMul(basePositionUnit, subjectSetQuantity); + const expectedBaseBalance = initialBaseBalance.add(baseTokenBoughtNotional); + + expect(finalBaseBalance).eq(expectedBaseBalance); + }); + + it("should set the expected USDC externalPositionUnit", async () => { + await perpSetup.clearingHouse.connect(maker.wallet).openPosition({ + baseToken: vETH.address, + isBaseToQuote: true, // short + isExactInput: false, // `amount` is USDC + amount: ether(10000), // move price down by selling 10k USDC of vETH + oppositeAmountBound: ZERO, + deadline: MAX_UINT_256, + sqrtPriceLimitX96: ZERO, + referralCode: ZERO_BYTES + }); + + const usdcTransferInQuantity = await calculateUSDCTransferInPreciseUnits( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + await subject(); + + const expectedExternalPositionUnit = toUSDCDecimals( + preciseDiv(usdcTransferInQuantity, subjectSetQuantity) + ); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(externalPositionUnit).to.be.eq(expectedExternalPositionUnit); + }); + }); + }); + + describe("when long one asset and short another", async () => { + // Long 2 ETH @ 10 USDC, Short 1 BTC @ 20 USDC + cacheBeforeEach(async () => { + await leverUp(setToken, perpLeverageModule, perpSetup, owner, vETH.address, 2, ether(.02), true); + await leverUp(setToken, perpLeverageModule, perpSetup, owner, vBTC.address, 2, ether(.02), false); + }); + + describe("when issuing a single set", async () => { + beforeEach(async () => { + const vETHSpotPrice = await perpSetup.getSpotPrice(vETH.address); + await perpSetup.setBaseTokenOraclePrice(vETH, vETHSpotPrice.div(10 ** 12)); + }); + + it("buys expected amount of vETH, vBTC", async () => { + const totalSupply = await setToken.totalSupply(); + const initialPositionInfo = await perpLeverageModule.getPositionNotionalInfo(subjectSetToken); + + const initialVETHBalance = initialPositionInfo[0].baseBalance; + const initialVBTCBalance = initialPositionInfo[1].baseBalance; + + await subject(); + + const finalPositionInfo = await perpLeverageModule.getPositionNotionalInfo(subjectSetToken); + const finalVETHBalance = finalPositionInfo[0].baseBalance; + const finalVBTCBalance = finalPositionInfo[1].baseBalance; + + const vETHPositionUnit = preciseDiv(initialVETHBalance, totalSupply); + const vBTCPositionUnit = preciseDiv(initialVBTCBalance, totalSupply); + + const vETHBoughtNotional = preciseMul(vETHPositionUnit, subjectSetQuantity); + const vBTCBoughtNotional = preciseMul(vBTCPositionUnit, subjectSetQuantity); + + const expectedVETHBalance = initialVETHBalance.add(vETHBoughtNotional); + const expectedVBTCBalance = initialVBTCBalance.add(vBTCBoughtNotional); + + expect(finalVETHBalance).eq(expectedVETHBalance); + expect(finalVBTCBalance).eq(expectedVBTCBalance); + }); + + it("should set the expected USDC externalPositionUnit", async () => { + const usdcTransferInQuantity = await calculateUSDCTransferInPreciseUnits( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + await subject(); + + const expectedExternalPositionUnit = toUSDCDecimals( + preciseDiv(usdcTransferInQuantity, subjectSetQuantity) + ); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + expect(externalPositionUnit).to.be.eq(expectedExternalPositionUnit); + }); + + // Long profit case + describe("when the long asset market price moves up and leverage drops", async () => { + it("test assumptions and preconditions should be correct", async () => { + let positionInfo = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0]; + let collateralBalance = (await perpLeverageModule.getAccountInfo(subjectSetToken)).collateralBalance; + + const initialLeverage = await perpSetup.getCurrentLeverage(subjectSetToken, positionInfo, collateralBalance); + const initialSpotPrice = await perpSetup.getSpotPrice(vETH.address); + const initialUSDCTransferInQuantity = await calculateUSDCTransferIn( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + // Price increases from ~10 USDC to 12_086_807_119_488_051_322 (~20%) + await perpSetup.clearingHouse.connect(maker.wallet).openPosition({ + baseToken: vETH.address, + isBaseToQuote: false, // long + isExactInput: true, // `amount` is USDC + amount: ether(10000), // move price up by buying 10k USDC of vETH + oppositeAmountBound: ZERO, + deadline: MAX_UINT_256, + sqrtPriceLimitX96: ZERO, + referralCode: ZERO_BYTES + }); + + positionInfo = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0]; + collateralBalance = (await perpLeverageModule.getAccountInfo(subjectSetToken)).collateralBalance; + + const finalLeverage = await perpSetup.getCurrentLeverage(subjectSetToken, positionInfo, collateralBalance); + const finalSpotPrice = await perpSetup.getSpotPrice(vETH.address); + const finalUSDCTransferInQuantity = await calculateUSDCTransferIn( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + + // Leverage should drop as asset value rises + // initialLeverage = 2041219945269276819 + // finalLeverage = 1731198978421953524 + + // Set should be worth 2X more as price increases in short asset + // Price rose ~20%, so set worth ~40% more + // initialUSDCTransferInQuantity = 10018070 + // finalUSDCTransferInQuantity = 14218995 + expect(initialSpotPrice).lt(finalSpotPrice); + expect(initialUSDCTransferInQuantity).lt(finalUSDCTransferInQuantity); + expect(initialLeverage).gt(ZERO); + expect(finalLeverage).gt(ZERO); + expect(initialLeverage).gt(finalLeverage); + }); + + it("sells expected amount of vBase", async () => { + await perpSetup.clearingHouse.connect(maker.wallet).openPosition({ + baseToken: vETH.address, + isBaseToQuote: true, // long + isExactInput: false, // `amount` is USDC + amount: ether(10000), // move price up by buying 10k USDC of vETH + oppositeAmountBound: ZERO, + deadline: MAX_UINT_256, + sqrtPriceLimitX96: ZERO, + referralCode: ZERO_BYTES + }); + + const initialBaseBalance = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0].baseBalance; + await subject(); + const finalBaseBalance = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0].baseBalance; + + const basePositionUnit = preciseDiv(initialBaseBalance, await setToken.totalSupply()); + const baseTokenBoughtNotional = preciseMul(basePositionUnit, subjectSetQuantity); + const expectedBaseBalance = initialBaseBalance.add(baseTokenBoughtNotional); + + expect(finalBaseBalance).eq(expectedBaseBalance); + }); + + it("should set the expected USDC externalPositionUnit", async () => { + await perpSetup.clearingHouse.connect(maker.wallet).openPosition({ + baseToken: vETH.address, + isBaseToQuote: true, // long + isExactInput: false, // `amount` is USDC + amount: ether(1000), // move price up by buying 1k USDC of vETH + oppositeAmountBound: ZERO, + deadline: MAX_UINT_256, + sqrtPriceLimitX96: ZERO, + referralCode: ZERO_BYTES + }); + + const usdcTransferInQuantity = await calculateUSDCTransferInPreciseUnits( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + await subject(); + + const expectedExternalPositionUnit = toUSDCDecimals( + preciseDiv(usdcTransferInQuantity, subjectSetQuantity) + ); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(externalPositionUnit).to.be.eq(expectedExternalPositionUnit); + }); + }); + + // Short profit case + describe("when the short asset market price moves down and leverage drops", async () => { + it("test assumptions and preconditions should be correct", async () => { + let positionInfo = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[1]; + let collateralBalance = (await perpLeverageModule.getAccountInfo(subjectSetToken)).collateralBalance; + + const initialLeverage = await perpSetup.getCurrentLeverage(subjectSetToken, positionInfo, collateralBalance); + const initialSpotPrice = await perpSetup.getSpotPrice(vBTC.address); + const initialUSDCTransferInQuantity = await calculateUSDCTransferIn( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + // Price decreases from ~20 USDC to 16_156_467_088_301_771_700 (~20%) + await perpSetup.clearingHouse.connect(maker.wallet).openPosition({ + baseToken: vBTC.address, + isBaseToQuote: true, // short + isExactInput: false, // `amount` is USDC + amount: ether(20000), // move price down by selling 20k USDC of vBTC + oppositeAmountBound: ZERO, + deadline: MAX_UINT_256, + sqrtPriceLimitX96: ZERO, + referralCode: ZERO_BYTES + }); + + + positionInfo = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[1]; + collateralBalance = (await perpLeverageModule.getAccountInfo(subjectSetToken)).collateralBalance; + + const finalLeverage = await perpSetup.getCurrentLeverage(subjectSetToken, positionInfo, collateralBalance); + const finalSpotPrice = await perpSetup.getSpotPrice(vBTC.address); + const finalUSDCTransferInQuantity = await calculateUSDCTransferIn( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + // Leverage should drop as asset value rises + // initialLeverage = 2039159946037302515 + // finalLeverage = 1184528742574169001 + + // Set should be worth 2X more as price decreases in short asset + // Price dropped ~20%, so set worth ~40% more + // initialUSDCTransferInQuantity = 10_018_070 + // finalUSDCTransferInQuantity = 13_814_709 + expect(initialSpotPrice).gt(finalSpotPrice); + expect(initialUSDCTransferInQuantity).lt(finalUSDCTransferInQuantity); + expect(initialLeverage).gt(ZERO); + expect(finalLeverage).gt(ZERO); + expect(initialLeverage).gt(finalLeverage); + }); + + it("sells expected amount of vBase", async () => { + await perpSetup.clearingHouse.connect(maker.wallet).openPosition({ + baseToken: vBTC.address, + isBaseToQuote: true, // short + isExactInput: false, // `amount` is USDC + amount: ether(20000), // move price down by selling 20k USDC of vBTC + oppositeAmountBound: ZERO, + deadline: MAX_UINT_256, + sqrtPriceLimitX96: ZERO, + referralCode: ZERO_BYTES + }); + + const initialBaseBalance = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0].baseBalance; + await subject(); + const finalBaseBalance = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0].baseBalance; + + const basePositionUnit = preciseDiv(initialBaseBalance, await setToken.totalSupply()); + const baseTokenBoughtNotional = preciseMul(basePositionUnit, subjectSetQuantity); + const expectedBaseBalance = initialBaseBalance.add(baseTokenBoughtNotional); + + expect(finalBaseBalance).eq(expectedBaseBalance); + }); + + it("should set the expected USDC externalPositionUnit", async () => { + await perpSetup.clearingHouse.connect(maker.wallet).openPosition({ + baseToken: vBTC.address, + isBaseToQuote: true, // short + isExactInput: false, // `amount` is USDC + amount: ether(2000), // move price down by selling 2k USDC of vBTC + oppositeAmountBound: ZERO, + deadline: MAX_UINT_256, + sqrtPriceLimitX96: ZERO, + referralCode: ZERO_BYTES + }); + + const usdcTransferInQuantity = await calculateUSDCTransferInPreciseUnits( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + await subject(); + + const expectedExternalPositionUnit = toUSDCDecimals( + preciseDiv(usdcTransferInQuantity, subjectSetQuantity) + ); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(externalPositionUnit).to.be.eq(expectedExternalPositionUnit); + }); + }); + }); + + describe("when there is positive owedRealizedPnl", async () => { + beforeEach(async () => { + // Move price up by maker selling buying USDC of vETH + await perpSetup.clearingHouse.connect(maker.wallet).openPosition({ + baseToken: vETH.address, + isBaseToQuote: false, // long + isExactInput: true, // `amount` is USDC + amount: ether(2000), + oppositeAmountBound: ZERO, + deadline: MAX_UINT_256, + sqrtPriceLimitX96: ZERO, + referralCode: ZERO_BYTES + }); + + // Sell a little, booking profit to owedRealizedPnl + await perpLeverageModule.connect(owner.wallet).trade( + subjectSetToken, + vETH.address, + ether(.1).mul(-1), + ZERO + ); + + const vETHSpotPrice = await perpSetup.getSpotPrice(vETH.address); + await perpSetup.setBaseTokenOraclePrice(vETH, vETHSpotPrice.div(10 ** 12)); + }); + + it("should set the expected USDC externalPositionUnit", async () => { + const owedRealizedPnl = (await perpLeverageModule.getAccountInfo(subjectSetToken)).owedRealizedPnl; + + const usdcTransferInQuantity = await calculateUSDCTransferIn( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + const expectedExternalPositionUnit = preciseDiv( + usdcTransferInQuantity, + subjectSetQuantity + ); + + await subject(); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(externalPositionUnit).to.be.eq(expectedExternalPositionUnit); + expect(owedRealizedPnl).gt(0); + }); + }); + + describe("when there is negative owedRealizedPnl", async () => { + beforeEach(async () => { + // Move price down by maker selling 1k USDC of vETH + await perpSetup.clearingHouse.connect(maker.wallet).openPosition({ + baseToken: vETH.address, + isBaseToQuote: true, // short + isExactInput: false, // `amount` is USDC + amount: ether(1000), + oppositeAmountBound: ZERO, + deadline: MAX_UINT_256, + sqrtPriceLimitX96: ZERO, + referralCode: ZERO_BYTES + }); + + // Sell a little, booking profit to owedRealizedPnl + await perpLeverageModule.connect(owner.wallet).trade( + subjectSetToken, + vETH.address, + ether(.1).mul(-1), + ZERO + ); + + const vETHSpotPrice = await perpSetup.getSpotPrice(vETH.address); + await perpSetup.setBaseTokenOraclePrice(vETH, vETHSpotPrice.div(10 ** 12)); + }); + + it("should set the expected USDC externalPositionUnit", async () => { + const owedRealizedPnl = (await perpLeverageModule.getAccountInfo(subjectSetToken)).owedRealizedPnl; + + const usdcTransferInQuantity = await calculateUSDCTransferIn( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + const expectedExternalPositionUnit = preciseDiv( + usdcTransferInQuantity, + subjectSetQuantity + ); + + await subject(); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(externalPositionUnit).to.be.eq(expectedExternalPositionUnit); + expect(owedRealizedPnl).lt(0); + }); + }); + + describe("when the Set owes funding", async () => { + beforeEach(async() => { + // set funding rate to non-zero value + await perpSetup.clearingHouseConfig.setMaxFundingRate(BigNumber.from(0.1e6)); // 10% in decimal 6 + }); + + it("should socialize the funding payment among existing set holders", async () => { + // Move oracle price up and wait one day + await perpSetup.setBaseTokenOraclePrice(vETH, usdcUnits(9.5)); + await increaseTimeAsync(ONE_DAY_IN_SECONDS); + + const vEthBalance = await perpSetup.accountBalance.getBase(setToken.address, vETH.address); + const vBtcBalance = await perpSetup.accountBalance.getBase(setToken.address, vBTC.address); + const pendingFunding = (await perpLeverageModule.getAccountInfo(subjectSetToken)).pendingFundingPayments; + const usdcTransferInQuantity = await calculateUSDCTransferInPreciseUnits( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup, + false // don't include funding + ); + + await subject(); + + const vEthUsdcAmountDelta = await getUSDCDeltaDueToFundingGrowth(setToken, subjectSetQuantity, vETH.address, vEthBalance, perpSetup); + const vBtcUsdcAmountDelta = await getUSDCDeltaDueToFundingGrowth(setToken, subjectSetQuantity, vBTC.address, vBtcBalance, perpSetup); + const expectedExternalPositionUnit = toUSDCDecimals( + preciseDiv(usdcTransferInQuantity.add(vEthUsdcAmountDelta).add(vBtcUsdcAmountDelta), subjectSetQuantity) + ); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(pendingFunding).lt(0); + expect(externalPositionUnit).to.be.eq(expectedExternalPositionUnit); + }); + }); + + describe("when the Set is owed funding", async () => { + beforeEach(async() => { + // set funding rate to non-zero value + await perpSetup.clearingHouseConfig.setMaxFundingRate(BigNumber.from(0.1e6)); // 10% in decimal 6 + }); + + it("should socialize the funding payment among existing set holders", async () => { + // Move oracle price down and wait one day + await perpSetup.setBaseTokenOraclePrice(vETH, usdcUnits(11)); + await increaseTimeAsync(ONE_DAY_IN_SECONDS); + + const vEthBalance = await perpSetup.accountBalance.getBase(setToken.address, vETH.address); + const vBtcBalance = await perpSetup.accountBalance.getBase(setToken.address, vBTC.address); + const pendingFunding = (await perpLeverageModule.getAccountInfo(subjectSetToken)).pendingFundingPayments; + const usdcTransferInQuantity = await calculateUSDCTransferInPreciseUnits( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup, + false // don't include funding + ); + + await subject(); + + const vEthUsdcAmountDelta = await getUSDCDeltaDueToFundingGrowth(setToken, subjectSetQuantity, vETH.address, vEthBalance, perpSetup); + const vBtcUsdcAmountDelta = await getUSDCDeltaDueToFundingGrowth(setToken, subjectSetQuantity, vBTC.address, vBtcBalance, perpSetup); + const expectedExternalPositionUnit = toUSDCDecimals( + preciseDiv(usdcTransferInQuantity.add(vEthUsdcAmountDelta).add(vBtcUsdcAmountDelta), subjectSetQuantity) + ); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(pendingFunding).gt(ZERO); + expect(externalPositionUnit).to.be.eq(expectedExternalPositionUnit); + }); + }); + + describe("when market prices move up and leverage drops", async () => { + it("test assumptions and preconditions should be correct", async () => { + let positionInfo = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0]; + let collateralBalance = (await perpLeverageModule.getAccountInfo(subjectSetToken)).collateralBalance; + + const initialLeverage = await perpSetup.getCurrentLeverage(subjectSetToken, positionInfo, collateralBalance); + const initialSpotPrice = await perpSetup.getSpotPrice(vETH.address); + const initialUSDCTransferInQuantity = await calculateUSDCTransferIn( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + await perpSetup.clearingHouse.connect(maker.wallet).openPosition({ + baseToken: vETH.address, + isBaseToQuote: false, // long + isExactInput: true, // `amount` is USDC + amount: ether(2000), // move price up by buying 10k USDC of vETH + oppositeAmountBound: ZERO, + deadline: MAX_UINT_256, + sqrtPriceLimitX96: ZERO, + referralCode: ZERO_BYTES + }); + + await perpSetup.clearingHouse.connect(maker.wallet).openPosition({ + baseToken: vBTC.address, + isBaseToQuote: false, // long + isExactInput: true, // `amount` is USDC + amount: ether(4000), // move price up by buying 10k USDC of vBTC + oppositeAmountBound: ZERO, + deadline: MAX_UINT_256, + sqrtPriceLimitX96: ZERO, + referralCode: ZERO_BYTES + }); + + positionInfo = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0]; + collateralBalance = (await perpLeverageModule.getAccountInfo(subjectSetToken)).collateralBalance; + + const finalLeverage = await perpSetup.getCurrentLeverage(subjectSetToken, positionInfo, collateralBalance); + const finalSpotPrice = await perpSetup.getSpotPrice(vETH.address); + const finalUSDCTransferInQuantity = await calculateUSDCTransferIn( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + expect(initialSpotPrice).lt(finalSpotPrice); + expect(initialUSDCTransferInQuantity).lt(finalUSDCTransferInQuantity); + expect(initialLeverage).gt(ZERO); + expect(finalLeverage).gt(ZERO); + expect(initialLeverage).gt(finalLeverage); + }); + + it("sells expected amount of vBase", async () => { + await perpSetup.clearingHouse.connect(maker.wallet).openPosition({ + baseToken: vETH.address, + isBaseToQuote: true, // short + isExactInput: false, // `amount` is USDC + amount: ether(10000), // move price down by selling 10k USDC of vETH + oppositeAmountBound: ZERO, + deadline: MAX_UINT_256, + sqrtPriceLimitX96: ZERO, + referralCode: ZERO_BYTES + }); + + const initialBaseBalance = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0].baseBalance; + await subject(); + const finalBaseBalance = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0].baseBalance; + + const basePositionUnit = preciseDiv(initialBaseBalance, await setToken.totalSupply()); + const baseTokenBoughtNotional = preciseMul(basePositionUnit, subjectSetQuantity); + const expectedBaseBalance = initialBaseBalance.add(baseTokenBoughtNotional); + + expect(finalBaseBalance).eq(expectedBaseBalance); + }); + + it("should set the expected USDC externalPositionUnit", async () => { + await perpSetup.clearingHouse.connect(maker.wallet).openPosition({ + baseToken: vETH.address, + isBaseToQuote: true, // short + isExactInput: false, // `amount` is USDC + amount: ether(10000), // move price down by selling 10k USDC of vETH + oppositeAmountBound: ZERO, + deadline: MAX_UINT_256, + sqrtPriceLimitX96: ZERO, + referralCode: ZERO_BYTES + }); + + const usdcTransferInQuantity = await calculateUSDCTransferIn( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + const expectedExternalPositionUnit = preciseDiv(usdcTransferInQuantity, subjectSetQuantity); + + await subject(); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(externalPositionUnit).to.be.eq(expectedExternalPositionUnit); + }); + }); + }); + + describe("when collateral is deposited but no position is open", async () => { + async function subject(): Promise { + await perpLeverageModule + .connect(subjectCaller.wallet) + .moduleIssueHook(subjectSetToken, subjectSetQuantity); + } + + it("deposits the correct amount of collateral", async () => { + const currentPositionUnit = await setToken.getExternalPositionRealUnit(perpSetup.usdc.address, perpLeverageModule.address); + + await subject(); + + const newPositionUnit = await setToken.getExternalPositionRealUnit(perpSetup.usdc.address, perpLeverageModule.address); + + expect(currentPositionUnit).eq(newPositionUnit); + }); + }); + + describe("when total supply is 0", async () => { + let otherSetToken: SetToken; + + beforeEach(async () => { + otherSetToken = await setup.createSetToken( + [usdc.address], + [usdcUnits(10)], + [perpLeverageModule.address, debtIssuanceMock.address] + ); + await debtIssuanceMock.initialize(otherSetToken.address); + await perpLeverageModule.updateAllowedSetToken(otherSetToken.address, true); + await perpLeverageModule.connect(owner.wallet).initialize(otherSetToken.address); + + // Initialize mock module + await otherSetToken.addModule(mockModule.address); + await otherSetToken.connect(mockModule.wallet).initializeModule(); + + subjectSetToken = otherSetToken.address; + }); + + it("should not update the USDC externalPositionUnit", async () => { + const initialExternalPositionUnit = await otherSetToken.getExternalPositionRealUnit(usdc.address, perpLeverageModule.address); + await subject(); + const finalExternalPositionUnit = await otherSetToken.getExternalPositionRealUnit(usdc.address, perpLeverageModule.address); + + const expectedExternalPositionUnit = ZERO; + expect(initialExternalPositionUnit).to.eq(finalExternalPositionUnit); + expect(finalExternalPositionUnit).to.eq(expectedExternalPositionUnit); + }); + }); + + describe("when there is no external USDC position", () => { + let otherSetToken: SetToken; + + beforeEach(async () => { + otherSetToken = await setup.createSetToken( + [usdc.address], + [usdcUnits(10)], + [perpLeverageModule.address, debtIssuanceMock.address, setup.issuanceModule.address] + ); + + await debtIssuanceMock.initialize(otherSetToken.address); + await perpLeverageModule.updateAllowedSetToken(otherSetToken.address, true); + + await perpLeverageModule.connect(owner.wallet).initialize(otherSetToken.address); + + await otherSetToken.addModule(mockModule.address); + await otherSetToken.connect(mockModule.wallet).initializeModule(); + + // Issue to create some supply + await usdc.approve(setup.issuanceModule.address, usdcUnits(1000)); + await setup.issuanceModule.initialize(otherSetToken.address, ADDRESS_ZERO); + await setup.issuanceModule.issue(otherSetToken.address, ether(1), owner.address); + + subjectSetToken = otherSetToken.address; + }); + + it("should not update the externalPositionUnit", async () => { + const initialExternalPositionUnit = await otherSetToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + await subject(); + + const finalExternalPositionUnit = await otherSetToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(initialExternalPositionUnit).eq(ZERO); + expect(initialExternalPositionUnit).eq(finalExternalPositionUnit); + }); + }); + + describe("when caller is not module", async () => { + beforeEach(async () => { + subjectCaller = owner; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Only the module can call"); + }); + }); + + describe("if disabled module is caller", async () => { + beforeEach(async () => { + await setup.controller.removeModule(mockModule.address); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Module must be enabled on controller"); + }); + }); + }); + + describe("#moduleRedeemHook", () => { + let setToken: SetToken; + let collateralQuantity: BigNumber; + let subjectSetToken: Address; + let subjectSetQuantity: BigNumber; + let subjectCaller: Account; + + // Start with initial total supply (2) + const initializeContracts = async () => { + collateralQuantity = usdcUnits(10); + setToken = await issueSetsAndDepositToPerp(collateralQuantity, true, ether(2)); + }; + + const initializeSubjectVariables = () => { + subjectSetToken = setToken.address; + subjectCaller = mockModule; + subjectSetQuantity = ether(1); + }; + + cacheBeforeEach(initializeContracts); + beforeEach(initializeSubjectVariables); + + async function subject(): Promise { + return await perpLeverageModule + .connect(subjectCaller.wallet) + .moduleRedeemHook(subjectSetToken, subjectSetQuantity); + } + + describe("when long", async () => { + let baseToken: Address; + + // Set up as 2X Long, allow 2% slippage + cacheBeforeEach(async () => { + baseToken = vETH.address; + await leverUp( + setToken, + perpLeverageModule, + perpSetup, + owner, + baseToken, + 2, + ether(.02), + true + ); + }); + + describe("when redeeming a single set", async () => { + it("sells expected amount of vBase", async () => { + const totalSupply = await setToken.totalSupply(); + const initialBaseBalance = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0].baseBalance; + const basePositionUnit = preciseDiv(initialBaseBalance, totalSupply); + + await subject(); + + const finalBaseBalance = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0].baseBalance; + const baseTokenSoldNotional = initialBaseBalance.sub(finalBaseBalance); + + const expectedBaseTokenSoldNotional = preciseMul(basePositionUnit, subjectSetQuantity); + + expect(baseTokenSoldNotional).eq(expectedBaseTokenSoldNotional); + }); + + it("should set the expected USDC externalPositionUnit", async () => { + const usdcTransferOutQuantity = await calculateUSDCTransferOutPreciseUnits( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + await subject(); + + const expectedExternalPositionUnit = toUSDCDecimals( + preciseDiv(usdcTransferOutQuantity, subjectSetQuantity) + ); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(externalPositionUnit).to.eq(expectedExternalPositionUnit); + }); + }); + + describe("when there is positive owedRealizedPnl", async () => { + beforeEach(async () => { + // Move price up by maker buying 1k USDC of vETH + await perpSetup.clearingHouse.connect(maker.wallet).openPosition({ + baseToken: vETH.address, + isBaseToQuote: false, // long + isExactInput: true, // `amount` is USDC + amount: ether(20000), + oppositeAmountBound: ZERO, + deadline: MAX_UINT_256, + sqrtPriceLimitX96: ZERO, + referralCode: ZERO_BYTES + }); + + // Sell a little, booking profit to owedRealizedPnl + await perpLeverageModule.connect(owner.wallet).trade( + subjectSetToken, + vETH.address, + ether(-.4), + ether(4) + ); + }); + + it("should set the expected USDC externalPositionUnit", async () => { + const owedRealizedPnl = (await perpLeverageModule.getAccountInfo(subjectSetToken)).owedRealizedPnl; + + const usdcTransferOutQuantity = await calculateUSDCTransferOut( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + const expectedExternalPositionUnit = preciseDiv( + usdcTransferOutQuantity, + subjectSetQuantity + ); + + await subject(); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(owedRealizedPnl).gt(0); + expect(externalPositionUnit).to.be.eq(expectedExternalPositionUnit); + }); + }); + + describe("when there is negative owedRealizedPnl", async () => { + beforeEach(async () => { + // Move price down by maker selling 1k USDC of vETH + await perpSetup.clearingHouse.connect(maker.wallet).openPosition({ + baseToken: vETH.address, + isBaseToQuote: true, // short + isExactInput: false, // `amount` is USDC + amount: ether(10000), + oppositeAmountBound: ZERO, + deadline: MAX_UINT_256, + sqrtPriceLimitX96: ZERO, + referralCode: ZERO_BYTES + }); + + // Sell a little, booking loss to owedRealizedPnl + await perpLeverageModule.connect(owner.wallet).trade( + subjectSetToken, + vETH.address, + ether(-.2), + ether(1) + ); + }); + + it("should set the expected USDC externalPositionUnit", async () => { + const owedRealizedPnl = (await perpLeverageModule.getAccountInfo(subjectSetToken)).owedRealizedPnl; + + const usdcTransferOutQuantity = await calculateUSDCTransferOut( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + const expectedExternalPositionUnit = preciseDiv( + usdcTransferOutQuantity, + subjectSetQuantity + ); + + await subject(); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(owedRealizedPnl).lt(0); + expect(externalPositionUnit).to.be.eq(expectedExternalPositionUnit); + }); + }); + + describe("when the Set owes funding", async () => { + beforeEach(async() => { + // set funding rate to non-zero value + await perpSetup.clearingHouseConfig.setMaxFundingRate(BigNumber.from(0.1e6)); // 10% in decimal 6 + }); + + it("should socialize the funding payment among existing set holders", async () => { + // Move oracle price down and wait one day + await perpSetup.setBaseTokenOraclePrice(vETH, usdcUnits(9.5)); + await increaseTimeAsync(ONE_DAY_IN_SECONDS); + + const vEthBalance = await perpSetup.accountBalance.getBase(setToken.address, vETH.address); + const pendingFunding = (await perpLeverageModule.getAccountInfo(subjectSetToken)).pendingFundingPayments; + const usdcTransferOutQuantity = await calculateUSDCTransferOutPreciseUnits( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup, + false // don't include funding + ); + + await subject(); + + const vEthUsdcAmountDelta = await getUSDCDeltaDueToFundingGrowth(setToken, subjectSetQuantity, vETH.address, vEthBalance, perpSetup); + const expectedExternalPositionUnit = toUSDCDecimals( + preciseDiv(usdcTransferOutQuantity.add(vEthUsdcAmountDelta), subjectSetQuantity) + ); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(pendingFunding).lt(0); + expect(externalPositionUnit).to.be.eq(expectedExternalPositionUnit); + }); + }); + + describe("when the Set is owed funding", async () => { + beforeEach(async() => { + // set funding rate to non-zero value + await perpSetup.clearingHouseConfig.setMaxFundingRate(BigNumber.from(0.1e6)); // 10% in decimal 6 + }); + + it("should socialize the funding payment among existing set holders", async () => { + // Move oracle price up and wait one day + await perpSetup.setBaseTokenOraclePrice(vETH, usdcUnits(15)); + await increaseTimeAsync(ONE_DAY_IN_SECONDS); + + const vEthBalance = await perpSetup.accountBalance.getBase(setToken.address, vETH.address); + const pendingFunding = (await perpLeverageModule.getAccountInfo(subjectSetToken)).pendingFundingPayments; + const usdcTransferOutQuantity = await calculateUSDCTransferOutPreciseUnits( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup, + false // don't include funding + ); + + await subject(); + + const vEthUsdcAmountDelta = await getUSDCDeltaDueToFundingGrowth(setToken, subjectSetQuantity, vETH.address, vEthBalance, perpSetup); + const expectedExternalPositionUnit = toUSDCDecimals( + preciseDiv(usdcTransferOutQuantity.add(vEthUsdcAmountDelta), subjectSetQuantity) + ); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(pendingFunding).gt(ether(1)); + expect(externalPositionUnit).to.be.eq(expectedExternalPositionUnit); + }); + }); + + describe("when the market price moves up and leverage drops", async () => { + it("test assumptions and preconditions should be correct", async () => { + let positionInfo = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0]; + let collateralBalance = (await perpLeverageModule.getAccountInfo(subjectSetToken)).collateralBalance; + + const initialLeverage = await perpSetup.getCurrentLeverage(subjectSetToken, positionInfo, collateralBalance); + const initialSpotPrice = await perpSetup.getSpotPrice(vETH.address); + const initialUSDCTransferOutQuantity = await calculateUSDCTransferOut( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + await perpSetup.clearingHouse.connect(maker.wallet).openPosition({ + baseToken: vETH.address, + isBaseToQuote: false, // long + isExactInput: true, // `amount` is USDC + amount: ether(10000), // move price up by buying 10k USDC of vETH + oppositeAmountBound: ZERO, + deadline: MAX_UINT_256, + sqrtPriceLimitX96: ZERO, + referralCode: ZERO_BYTES + }); + + positionInfo = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0]; + collateralBalance = (await perpLeverageModule.getAccountInfo(subjectSetToken)).collateralBalance; + + const finalLeverage = await perpSetup.getCurrentLeverage(subjectSetToken, positionInfo, collateralBalance); + const finalSpotPrice = await perpSetup.getSpotPrice(vETH.address); + const finalUSDCTransferOutQuantity = await calculateUSDCTransferOut( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + expect(initialSpotPrice).lt(finalSpotPrice); + expect(initialUSDCTransferOutQuantity).lt(finalUSDCTransferOutQuantity); + expect(initialLeverage).gt(ZERO); + expect(finalLeverage).gt(ZERO); + expect(initialLeverage).gt(finalLeverage); + }); + + it("buys expected amount of vBase", async () => { + await perpSetup.clearingHouse.connect(maker.wallet).openPosition({ + baseToken: vETH.address, + isBaseToQuote: false, // long + isExactInput: true, // `amount` is USDC + amount: ether(10000), // move price up by buying 10k USDC of vETH + oppositeAmountBound: ZERO, + deadline: MAX_UINT_256, + sqrtPriceLimitX96: ZERO, + referralCode: ZERO_BYTES + }); + + const initialBaseBalance = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0].baseBalance; + await subject(); + const finalBaseBalance = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0].baseBalance; + + const basePositionUnit = preciseDiv(initialBaseBalance, await setToken.totalSupply()); + const baseTokenBoughtNotional = preciseMul(basePositionUnit, subjectSetQuantity); + const expectedBaseBalance = initialBaseBalance.sub(baseTokenBoughtNotional); + + expect(finalBaseBalance).eq(expectedBaseBalance); + }); + + it("should set the expected USDC externalPositionUnit", async () => { + await perpSetup.clearingHouse.connect(maker.wallet).openPosition({ + baseToken: vETH.address, + isBaseToQuote: false, // long + isExactInput: true, // `amount` is USDC + amount: ether(10000), // move price up by buying 10k USDC of vETH + oppositeAmountBound: ZERO, + deadline: MAX_UINT_256, + sqrtPriceLimitX96: ZERO, + referralCode: ZERO_BYTES + }); + + const usdcTransferOutQuantity = await calculateUSDCTransferOut( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + const expectedExternalPositionUnit = preciseDiv(usdcTransferOutQuantity, subjectSetQuantity); + + await subject(); + + const actualExternalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(actualExternalPositionUnit).to.be.eq(expectedExternalPositionUnit); + }); + }); + + describe("when redeeming multiple sets", async () => { + beforeEach(async () => { + subjectSetQuantity = ether(2); + }); + + it("sells expected amount of vBase", async () => { + const totalSupply = await setToken.totalSupply(); + const initialBaseBalance = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0].baseBalance; + const basePositionUnit = preciseDiv(initialBaseBalance, totalSupply); + + await subject(); + + const finalBaseBalance = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0].baseBalance; + const baseTokenSoldNotional = initialBaseBalance.sub(finalBaseBalance); + + const expectedBaseTokenSoldNotional = preciseMul(basePositionUnit, subjectSetQuantity); + + expect(baseTokenSoldNotional).eq(expectedBaseTokenSoldNotional); + }); + + it("should set the expected USDC externalPositionUnit", async () => { + const usdcTransferOutQuantity = await calculateUSDCTransferOutPreciseUnits( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + await subject(); + + const expectedExternalPositionUnit = toUSDCDecimals( + preciseDiv(usdcTransferOutQuantity, subjectSetQuantity) + ); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(externalPositionUnit).to.eq(expectedExternalPositionUnit); + }); + }); + }); + + describe("when long, multiple positions", async () => { + // Set up as 2X Long, allow 2% slippage + cacheBeforeEach(async () => { + await leverUp( + setToken, + perpLeverageModule, + perpSetup, + owner, + vETH.address, + 2, + ether(.02), + true + ); + + await leverUp( + setToken, + perpLeverageModule, + perpSetup, + owner, + vBTC.address, + 1, + ether(.02), + true + ); + }); + + describe("when redeeming a single set", async () => { + it("sells expected amount of vETH, vBTC", async () => { + const totalSupply = await setToken.totalSupply(); + const initialPositionInfo = await perpLeverageModule.getPositionNotionalInfo(subjectSetToken); + + const initialVETHBalance = initialPositionInfo[0].baseBalance; + const initialVBTCBalance = initialPositionInfo[1].baseBalance; + + await subject(); + + const finalPositionInfo = await perpLeverageModule.getPositionNotionalInfo(subjectSetToken); + const finalVETHBalance = finalPositionInfo[0].baseBalance; + const finalVBTCBalance = finalPositionInfo[1].baseBalance; + + const vETHPositionUnit = preciseDiv(initialVETHBalance, totalSupply); + const vBTCPositionUnit = preciseDiv(initialVBTCBalance, totalSupply); + + const vETHSoldNotional = preciseMul(vETHPositionUnit, subjectSetQuantity); + const vBTCSoldNotional = preciseMul(vBTCPositionUnit, subjectSetQuantity); + + const expectedVETHBalance = initialVETHBalance.sub(vETHSoldNotional); + const expectedVBTCBalance = initialVBTCBalance.sub(vBTCSoldNotional); + + expect(finalVETHBalance).eq(expectedVETHBalance); + expect(finalVBTCBalance).eq(expectedVBTCBalance); + }); + + it("should set the expected USDC externalPositionUnit", async () => { + const usdcTransferOutQuantity = await calculateUSDCTransferOutPreciseUnits( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + await subject(); + + const expectedExternalPositionUnit = toUSDCDecimals( + preciseDiv(usdcTransferOutQuantity, subjectSetQuantity) + ); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(externalPositionUnit).to.be.eq(expectedExternalPositionUnit); + }); + }); + + describe("when redeeming multiple sets", async () => { + it("buys expected amount of vETH, vBTC", async () => { + const totalSupply = await setToken.totalSupply(); + const initialPositionInfo = await perpLeverageModule.getPositionNotionalInfo(subjectSetToken); + + const initialVETHBalance = initialPositionInfo[0].baseBalance; + const initialVBTCBalance = initialPositionInfo[1].baseBalance; + + await subject(); + + const finalPositionInfo = await perpLeverageModule.getPositionNotionalInfo(subjectSetToken); + const finalVETHBalance = finalPositionInfo[0].baseBalance; + const finalVBTCBalance = finalPositionInfo[1].baseBalance; + + const vETHPositionUnit = preciseDiv(initialVETHBalance, totalSupply); + const vBTCPositionUnit = preciseDiv(initialVBTCBalance, totalSupply); + + const vETHBoughtNotional = preciseMul(vETHPositionUnit, subjectSetQuantity); + const vBTCBoughtNotional = preciseMul(vBTCPositionUnit, subjectSetQuantity); + + const expectedVETHBalance = initialVETHBalance.sub(vETHBoughtNotional); + const expectedVBTCBalance = initialVBTCBalance.sub(vBTCBoughtNotional); + + expect(finalVETHBalance).eq(expectedVETHBalance); + expect(finalVBTCBalance).eq(expectedVBTCBalance); + }); + + it("should set the expected USDC externalPositionUnit", async () => { + const usdcTransferOutQuantity = await calculateUSDCTransferOutPreciseUnits( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + await subject(); + + const expectedExternalPositionUnit = toUSDCDecimals( + preciseDiv(usdcTransferOutQuantity, subjectSetQuantity) + ); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(externalPositionUnit).to.be.eq(expectedExternalPositionUnit); + }); + }); + + describe("when the Set owes funding", async () => { + beforeEach(async() => { + // set funding rate to non-zero value + await perpSetup.clearingHouseConfig.setMaxFundingRate(BigNumber.from(0.1e6)); // 10% in decimal 6 + }); + + it("should socialize the funding payment among existing set holders", async () => { + // Move oracle price down and wait one day + await perpSetup.setBaseTokenOraclePrice(vETH, usdcUnits(9.8)); + await perpSetup.setBaseTokenOraclePrice(vBTC, usdcUnits(19.9)); + + await increaseTimeAsync(ONE_DAY_IN_SECONDS); + const vEthBalance = await perpSetup.accountBalance.getBase(setToken.address, vETH.address); + const vBtcBalance = await perpSetup.accountBalance.getBase(setToken.address, vBTC.address); + const pendingFunding = (await perpLeverageModule.getAccountInfo(subjectSetToken)).pendingFundingPayments; + const usdcTransferOutQuantity = await calculateUSDCTransferOutPreciseUnits( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup, + false // don't include funding + ); + + await subject(); + + const vEthUsdcAmountDelta = await getUSDCDeltaDueToFundingGrowth(setToken, subjectSetQuantity, vETH.address, vEthBalance, perpSetup); + const vBtcUsdcAmountDelta = await getUSDCDeltaDueToFundingGrowth(setToken, subjectSetQuantity, vBTC.address, vBtcBalance, perpSetup); + const expectedExternalPositionUnit = toUSDCDecimals( + preciseDiv(usdcTransferOutQuantity.add(vEthUsdcAmountDelta).add(vBtcUsdcAmountDelta), subjectSetQuantity) + ); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(pendingFunding).lt(0); + expect(externalPositionUnit).to.be.eq(expectedExternalPositionUnit); + }); + }); + + describe("when the Set is owed funding", async () => { + beforeEach(async() => { + // set funding rate to non-zero value + await perpSetup.clearingHouseConfig.setMaxFundingRate(BigNumber.from(0.1e6)); // 10% in decimal 6 + }); + + it("should socialize the funding payment among existing set holders", async () => { + // Move oracle price up and wait one day + await perpSetup.setBaseTokenOraclePrice(vETH, usdcUnits(10.3)); + await perpSetup.setBaseTokenOraclePrice(vBTC, usdcUnits(20.2)); + + await increaseTimeAsync(ONE_DAY_IN_SECONDS); + + const vEthBalance = await perpSetup.accountBalance.getBase(setToken.address, vETH.address); + const vBtcBalance = await perpSetup.accountBalance.getBase(setToken.address, vBTC.address); + const pendingFunding = (await perpLeverageModule.getAccountInfo(subjectSetToken)).pendingFundingPayments; + const usdcTransferOutQuantity = await calculateUSDCTransferOutPreciseUnits( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup, + false // don't include funding + ); + + await subject(); + + const vEthUsdcAmountDelta = await getUSDCDeltaDueToFundingGrowth(setToken, subjectSetQuantity, vETH.address, vEthBalance, perpSetup); + const vBtcUsdcAmountDelta = await getUSDCDeltaDueToFundingGrowth(setToken, subjectSetQuantity, vBTC.address, vBtcBalance, perpSetup); + const expectedExternalPositionUnit = toUSDCDecimals( + preciseDiv(usdcTransferOutQuantity.add(vEthUsdcAmountDelta).add(vBtcUsdcAmountDelta), subjectSetQuantity) + ); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(pendingFunding).gt(ether(1)); + expect(externalPositionUnit).to.be.eq(expectedExternalPositionUnit); + }); + }); + + describe("when there is positive owedRealizedPnl", async () => { + beforeEach(async () => { + // Move price up by maker buying 2k USDC of vETH + await perpSetup.clearingHouse.connect(maker.wallet).openPosition({ + baseToken: vETH.address, + isBaseToQuote: false, // long + isExactInput: true, // `amount` is USDC + amount: ether(2000), + oppositeAmountBound: ZERO, + deadline: MAX_UINT_256, + sqrtPriceLimitX96: ZERO, + referralCode: ZERO_BYTES + }); + + await perpSetup.clearingHouse.connect(maker.wallet).openPosition({ + baseToken: vBTC.address, + isBaseToQuote: false, // long + isExactInput: true, // `amount` is USDC + amount: ether(2000), + oppositeAmountBound: ZERO, + deadline: MAX_UINT_256, + sqrtPriceLimitX96: ZERO, + referralCode: ZERO_BYTES + }); + + // Sell a little, booking profit to owedRealizedPnl + await perpLeverageModule.connect(owner.wallet).trade( + subjectSetToken, + vETH.address, + ether(-.4), + ether(4) + ); + + await perpLeverageModule.connect(owner.wallet).trade( + subjectSetToken, + vBTC.address, + ether(-.4), + ether(4) + ); + }); + + it("should set the expected USDC externalPositionUnit", async () => { + const owedRealizedPnl = (await perpLeverageModule.getAccountInfo(subjectSetToken)).owedRealizedPnl; + + const usdcTransferOutQuantity = await calculateUSDCTransferOut( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + const expectedExternalPositionUnit = preciseDiv( + usdcTransferOutQuantity, + subjectSetQuantity + ); + + await subject(); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(owedRealizedPnl).gt(0); + expect(externalPositionUnit).to.be.eq(expectedExternalPositionUnit); + }); + }); + }); + + describe("when short", async () => { + let baseToken: Address; + + // Set up as 2X Short, allow 2% slippage + cacheBeforeEach(async () => { + baseToken = vETH.address; + await leverUp( + setToken, + perpLeverageModule, + perpSetup, + owner, + baseToken, + 2, + ether(.02), + false + ); + }); + + describe("when redeeming a single set", async () => { + it("buys expected amount of vBase", async () => { + const totalSupply = await setToken.totalSupply(); + + const initialBaseBalance = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0].baseBalance; + await subject(); + const finalBaseBalance = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0].baseBalance; + + const basePositionUnit = preciseDiv(initialBaseBalance, totalSupply); + const baseTokenBoughtNotional = preciseMul(basePositionUnit, subjectSetQuantity); + const expectedBaseBalance = initialBaseBalance.sub(baseTokenBoughtNotional); + + expect(finalBaseBalance).eq(expectedBaseBalance); + }); + + it("should set the expected USDC externalPositionUnit", async () => { + const usdcTransferOutQuantity = await calculateUSDCTransferOutPreciseUnits( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + await subject(); + + const expectedExternalPositionUnit = toUSDCDecimals( + preciseDiv(usdcTransferOutQuantity, subjectSetQuantity) + ); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(externalPositionUnit).to.be.eq(expectedExternalPositionUnit); + }); + }); + + describe("when there is positive owedRealizedPnl", async () => { + beforeEach(async () => { + // Move price down by maker buying 1k USDC of vETH + await perpSetup.clearingHouse.connect(maker.wallet).openPosition({ + baseToken: vETH.address, + isBaseToQuote: true, // short + isExactInput: false, // `amount` is USDC + amount: ether(10000), + oppositeAmountBound: ZERO, + deadline: MAX_UINT_256, + sqrtPriceLimitX96: ZERO, + referralCode: ZERO_BYTES + }); + + // Buy a little, booking profit to owedRealizedPnl + await perpLeverageModule.connect(owner.wallet).trade( + subjectSetToken, + vETH.address, + ether(.1), + ether(2) + ); + }); + + it("should set the expected USDC externalPositionUnit", async () => { + // owedRealizedPnl = 1_643_798_014_140_064_947 + const owedRealizedPnl = (await perpLeverageModule.getAccountInfo(subjectSetToken)).owedRealizedPnl; + + const usdcTransferOutQuantity = await calculateUSDCTransferOut( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + const expectedExternalPositionUnit = preciseDiv( + usdcTransferOutQuantity, + subjectSetQuantity + ); + + await subject(); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(owedRealizedPnl).gt(0); + expect(externalPositionUnit).to.be.eq(expectedExternalPositionUnit); + }); + }); + + describe("when there is negative owedRealizedPnl", async () => { + beforeEach(async () => { + // Move price up by maker buy 1k USDC of vETH + await perpSetup.clearingHouse.connect(maker.wallet).openPosition({ + baseToken: vETH.address, + isBaseToQuote: false, // long + isExactInput: true, // `amount` is USDC + amount: ether(10000), + oppositeAmountBound: ZERO, + deadline: MAX_UINT_256, + sqrtPriceLimitX96: ZERO, + referralCode: ZERO_BYTES + }); + + // Buy a little, booking loss to owedRealizedPnl + await perpLeverageModule.connect(owner.wallet).trade( + subjectSetToken, + vETH.address, + ether(.1), + ether(2) + ); + }); + + it("should set the expected USDC externalPositionUnit", async () => { + const owedRealizedPnl = (await perpLeverageModule.getAccountInfo(subjectSetToken)).owedRealizedPnl; + + const usdcTransferOutQuantity = await calculateUSDCTransferOut( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + const expectedExternalPositionUnit = preciseDiv( + usdcTransferOutQuantity, + subjectSetQuantity + ); + + await subject(); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(owedRealizedPnl).lt(0); + expect(externalPositionUnit).to.be.eq(expectedExternalPositionUnit); + }); + }); + + describe("when the Set owes funding", async () => { + beforeEach(async() => { + // set funding rate to non-zero value + await perpSetup.clearingHouseConfig.setMaxFundingRate(BigNumber.from(0.1e6)); // 10% in decimal 6 + }); + + it("should socialize the funding payment among existing set holders", async () => { + // Move oracle price up and wait one day + await perpSetup.setBaseTokenOraclePrice(vETH, usdcUnits(10.5)); + await increaseTimeAsync(ONE_DAY_IN_SECONDS); + + const vEthBalance = await perpSetup.accountBalance.getBase(setToken.address, vETH.address); + const pendingFunding = (await perpLeverageModule.getAccountInfo(subjectSetToken)).pendingFundingPayments; + const usdcTransferOutQuantity = await calculateUSDCTransferOutPreciseUnits( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup, + false // don't include funding + ); + + await subject(); + + const vEthUsdcAmountDelta = await getUSDCDeltaDueToFundingGrowth(setToken, subjectSetQuantity, vETH.address, vEthBalance, perpSetup); + const expectedExternalPositionUnit = toUSDCDecimals( + preciseDiv(usdcTransferOutQuantity.add(vEthUsdcAmountDelta), subjectSetQuantity) + ); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(pendingFunding).lt(0); + expect(externalPositionUnit).to.be.eq(expectedExternalPositionUnit); + }); + }); + + describe("when the Set is owed funding", async () => { + beforeEach(async() => { + // set funding rate to non-zero value + await perpSetup.clearingHouseConfig.setMaxFundingRate(BigNumber.from(0.1e6)); // 10% in decimal 6 + }); + + it("should socialize the funding payment among existing set holders", async () => { + // Move oracle price down and wait one day + await perpSetup.setBaseTokenOraclePrice(vETH, usdcUnits(5)); + await increaseTimeAsync(ONE_DAY_IN_SECONDS); + + const vEthBalance = await perpSetup.accountBalance.getBase(setToken.address, vETH.address); + const pendingFunding = (await perpLeverageModule.getAccountInfo(subjectSetToken)).pendingFundingPayments; + const usdcTransferOutQuantity = await calculateUSDCTransferOutPreciseUnits( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup, + false // don't include funding + ); + + await subject(); + + const vEthUsdcAmountDelta = await getUSDCDeltaDueToFundingGrowth(setToken, subjectSetQuantity, vETH.address, vEthBalance, perpSetup); + const expectedExternalPositionUnit = toUSDCDecimals( + preciseDiv(usdcTransferOutQuantity.add(vEthUsdcAmountDelta), subjectSetQuantity) + ); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + // We expect pending funding to be greater than -1 USDC and discount applied + expect(pendingFunding).gt(0); + expect(externalPositionUnit).to.be.eq(expectedExternalPositionUnit); + }); + }); + + describe("when the market price moves down and leverage drops", async () => { + it("test assumptions and preconditions should be correct", async () => { + let positionInfo = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0]; + let collateralBalance = (await perpLeverageModule.getAccountInfo(subjectSetToken)).collateralBalance; + + const initialLeverage = await perpSetup.getCurrentLeverage(subjectSetToken, positionInfo, collateralBalance); + const initialSpotPrice = await perpSetup.getSpotPrice(vETH.address); + const initialUSDCTransferOutQuantity = await calculateUSDCTransferOut( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + await perpSetup.clearingHouse.connect(maker.wallet).openPosition({ + baseToken: vETH.address, + isBaseToQuote: true, // short + isExactInput: false, // `amount` is USDC + amount: ether(10000), // move price down by selling 10k USDC of vETH + oppositeAmountBound: ZERO, + deadline: MAX_UINT_256, + sqrtPriceLimitX96: ZERO, + referralCode: ZERO_BYTES + }); + + positionInfo = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0]; + collateralBalance = (await perpLeverageModule.getAccountInfo(subjectSetToken)).collateralBalance; + + const finalLeverage = await perpSetup.getCurrentLeverage(subjectSetToken, positionInfo, collateralBalance); + const finalSpotPrice = await perpSetup.getSpotPrice(vETH.address); + const finalUSDCTransferOutQuantity = await calculateUSDCTransferOut( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + expect(initialSpotPrice).gt(finalSpotPrice); + expect(initialUSDCTransferOutQuantity).lt(finalUSDCTransferOutQuantity); + expect(initialLeverage).gt(ZERO); + expect(finalLeverage).gt(ZERO); + expect(initialLeverage).gt(finalLeverage); + }); + + it("buys expected amount of vBase", async () => { + await perpSetup.clearingHouse.connect(maker.wallet).openPosition({ + baseToken: vETH.address, + isBaseToQuote: true, // short + isExactInput: false, // `amount` is USDC + amount: ether(10000), // move price down by selling 10k USDC of vETH + oppositeAmountBound: ZERO, + deadline: MAX_UINT_256, + sqrtPriceLimitX96: ZERO, + referralCode: ZERO_BYTES + }); + + const initialBaseBalance = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0].baseBalance; + await subject(); + const finalBaseBalance = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0].baseBalance; + + const basePositionUnit = preciseDiv(initialBaseBalance, await setToken.totalSupply()); + const baseTokenBoughtNotional = preciseMul(basePositionUnit, subjectSetQuantity); + const expectedBaseBalance = initialBaseBalance.sub(baseTokenBoughtNotional); + + expect(finalBaseBalance).eq(expectedBaseBalance); + }); + + it("should set the expected USDC externalPositionUnit", async () => { + await perpSetup.clearingHouse.connect(maker.wallet).openPosition({ + baseToken: vETH.address, + isBaseToQuote: true, // short + isExactInput: false, // `amount` is USDC + amount: ether(10000), // move price down by selling 10k USDC of vETH + oppositeAmountBound: ZERO, + deadline: MAX_UINT_256, + sqrtPriceLimitX96: ZERO, + referralCode: ZERO_BYTES + }); + + const usdcTransferOutQuantity = await calculateUSDCTransferOut( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + const expectedExternalPositionUnit = preciseDiv(usdcTransferOutQuantity, subjectSetQuantity); + + await subject(); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(externalPositionUnit).to.be.eq(expectedExternalPositionUnit); + }); + }); + + describe("when redeeming multiple sets", async () => { + it("should set the expected USDC externalPositionUnit", async () => { + const usdcTransferOutQuantity = await calculateUSDCTransferOutPreciseUnits( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + await subject(); + + const expectedExternalPositionUnit = toUSDCDecimals( + preciseDiv(usdcTransferOutQuantity, subjectSetQuantity) + ); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(externalPositionUnit).to.be.eq(expectedExternalPositionUnit); + }); + }); + }); + + describe("when long one asset and short another", async () => { + // Long 2 ETH @ 10 USDC, Short 1 BTC @ 20 USDC + cacheBeforeEach(async () => { + await leverUp(setToken, perpLeverageModule, perpSetup, owner, vETH.address, 2, ether(.02), true); + await leverUp(setToken, perpLeverageModule, perpSetup, owner, vBTC.address, 2, ether(.02), false); + }); + + describe("when redeeming a single set", async () => { + beforeEach(async () => { + const vETHSpotPrice = await perpSetup.getSpotPrice(vETH.address); + await perpSetup.setBaseTokenOraclePrice(vETH, vETHSpotPrice.div(10 ** 12)); + }); + + it("sells expected amount of vETH, buys expected amount of vBTC", async () => { + const totalSupply = await setToken.totalSupply(); + const initialPositionInfo = await perpLeverageModule.getPositionNotionalInfo(subjectSetToken); + + const initialVETHBalance = initialPositionInfo[0].baseBalance; + const initialVBTCBalance = initialPositionInfo[1].baseBalance; + + await subject(); + + const finalPositionInfo = await perpLeverageModule.getPositionNotionalInfo(subjectSetToken); + const finalVETHBalance = finalPositionInfo[0].baseBalance; + const finalVBTCBalance = finalPositionInfo[1].baseBalance; + + const vETHPositionUnit = preciseDiv(initialVETHBalance, totalSupply); + const vBTCPositionUnit = preciseDiv(initialVBTCBalance, totalSupply); + + const vETHBoughtNotional = preciseMul(vETHPositionUnit, subjectSetQuantity); + const vBTCBoughtNotional = preciseMul(vBTCPositionUnit, subjectSetQuantity); + + const expectedVETHBalance = initialVETHBalance.sub(vETHBoughtNotional); + const expectedVBTCBalance = initialVBTCBalance.sub(vBTCBoughtNotional); + + expect(finalVETHBalance).eq(expectedVETHBalance); + expect(finalVBTCBalance).eq(expectedVBTCBalance); + }); + + it("should set the expected USDC externalPositionUnit", async () => { + const usdcTransferOutQuantity = await calculateUSDCTransferOutPreciseUnits( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + await subject(); + + const expectedExternalPositionUnit = toUSDCDecimals( + preciseDiv(usdcTransferOutQuantity, subjectSetQuantity) + ); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + expect(externalPositionUnit).to.be.eq(expectedExternalPositionUnit); + }); + + // Long profit case + describe("when the long asset market price moves up and leverage drops", async () => { + it("test assumptions and preconditions should be correct", async () => { + let positionInfo = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0]; + let collateralBalance = (await perpLeverageModule.getAccountInfo(subjectSetToken)).collateralBalance; + + const initialLeverage = await perpSetup.getCurrentLeverage(subjectSetToken, positionInfo, collateralBalance); + const initialSpotPrice = await perpSetup.getSpotPrice(vETH.address); + const initialUSDCTransferOutQuantity = await calculateUSDCTransferOut( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + // Price increases from ~10 USDC to 12_086_807_119_488_051_322 (~20%) + await perpSetup.clearingHouse.connect(maker.wallet).openPosition({ + baseToken: vETH.address, + isBaseToQuote: false, // long + isExactInput: true, // `amount` is USDC + amount: ether(10000), // move price up by buying 10k USDC of vETH + oppositeAmountBound: ZERO, + deadline: MAX_UINT_256, + sqrtPriceLimitX96: ZERO, + referralCode: ZERO_BYTES + }); + + positionInfo = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0]; + collateralBalance = (await perpLeverageModule.getAccountInfo(subjectSetToken)).collateralBalance; + + const finalLeverage = await perpSetup.getCurrentLeverage(subjectSetToken, positionInfo, collateralBalance); + const finalSpotPrice = await perpSetup.getSpotPrice(vETH.address); + const finalUSDCTransferOutQuantity = await calculateUSDCTransferOut( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + + // Leverage should drop as asset value rises + // initialLeverage = 2041219945269276819 + // finalLeverage = 1731198978421953524 + + // Set should be worth 2X more as price increases in short asset + // Price rose ~20%, so set worth ~40% more + // initialUSDCTransferInQuantity = 9201861 + // finalUSDCTransferInQuantity = 13316592 + expect(initialSpotPrice).lt(finalSpotPrice); + expect(initialUSDCTransferOutQuantity).lt(finalUSDCTransferOutQuantity); + expect(initialLeverage).gt(ZERO); + expect(finalLeverage).gt(ZERO); + expect(initialLeverage).gt(finalLeverage); + }); + + it("sells expected amount of vBase", async () => { + await perpSetup.clearingHouse.connect(maker.wallet).openPosition({ + baseToken: vETH.address, + isBaseToQuote: true, // long + isExactInput: false, // `amount` is USDC + amount: ether(10000), // move price up by buying 10k USDC of vETH + oppositeAmountBound: ZERO, + deadline: MAX_UINT_256, + sqrtPriceLimitX96: ZERO, + referralCode: ZERO_BYTES + }); + + const initialBaseBalance = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0].baseBalance; + await subject(); + const finalBaseBalance = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0].baseBalance; + + const basePositionUnit = preciseDiv(initialBaseBalance, await setToken.totalSupply()); + const baseTokenSoldNotional = preciseMul(basePositionUnit, subjectSetQuantity); + const expectedBaseBalance = initialBaseBalance.sub(baseTokenSoldNotional); + + expect(finalBaseBalance).eq(expectedBaseBalance); + }); + + it("should set the expected USDC externalPositionUnit", async () => { + await perpSetup.clearingHouse.connect(maker.wallet).openPosition({ + baseToken: vETH.address, + isBaseToQuote: true, // long + isExactInput: false, // `amount` is USDC + amount: ether(10000), // move price up by buying 10k USDC of vETH + oppositeAmountBound: ZERO, + deadline: MAX_UINT_256, + sqrtPriceLimitX96: ZERO, + referralCode: ZERO_BYTES + }); + + const usdcTransferOutQuantity = await calculateUSDCTransferOutPreciseUnits( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + await subject(); + + const expectedExternalPositionUnit = toUSDCDecimals( + preciseDiv(usdcTransferOutQuantity, subjectSetQuantity) + ); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(externalPositionUnit).to.be.eq(expectedExternalPositionUnit); + }); + }); + + // Short profit case + describe("when the short asset market price moves down and leverage drops", async () => { + it("test assumptions and preconditions should be correct", async () => { + let positionInfo = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[1]; + let collateralBalance = (await perpLeverageModule.getAccountInfo(subjectSetToken)).collateralBalance; + + const initialLeverage = await perpSetup.getCurrentLeverage(subjectSetToken, positionInfo, collateralBalance); + const initialSpotPrice = await perpSetup.getSpotPrice(vBTC.address); + const initialUSDCTransferOutQuantity = await calculateUSDCTransferOut( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + // Price decreases from ~20 USDC to 16_156_467_088_301_771_700 (~20%) + await perpSetup.clearingHouse.connect(maker.wallet).openPosition({ + baseToken: vBTC.address, + isBaseToQuote: true, // short + isExactInput: false, // `amount` is USDC + amount: ether(20000), // move price down by selling 20k USDC of vBTC + oppositeAmountBound: ZERO, + deadline: MAX_UINT_256, + sqrtPriceLimitX96: ZERO, + referralCode: ZERO_BYTES + }); + + + positionInfo = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[1]; + collateralBalance = (await perpLeverageModule.getAccountInfo(subjectSetToken)).collateralBalance; + + const finalLeverage = await perpSetup.getCurrentLeverage(subjectSetToken, positionInfo, collateralBalance); + const finalSpotPrice = await perpSetup.getSpotPrice(vBTC.address); + const finalUSDCTransferOutQuantity = await calculateUSDCTransferOut( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + // Leverage should drop as asset value rises + // initialLeverage = 2039159946037302515 + // finalLeverage = 1184528742574169001 + + // Set should be worth 2X more as price decreases in short asset + // Price dropped ~20%, so set worth ~40% more + // initialUSDCTransferOutQuantity = 9201861 + // finalUSDCTransferOutQuantity = 13076691 + expect(initialSpotPrice).gt(finalSpotPrice); + expect(initialUSDCTransferOutQuantity).lt(finalUSDCTransferOutQuantity); + expect(initialLeverage).gt(ZERO); + expect(finalLeverage).gt(ZERO); + expect(initialLeverage).gt(finalLeverage); + }); + + it("buys expected amount of vBase", async () => { + await perpSetup.clearingHouse.connect(maker.wallet).openPosition({ + baseToken: vBTC.address, + isBaseToQuote: true, // short + isExactInput: false, // `amount` is USDC + amount: ether(20000), // move price down by selling 20k USDC of vBTC + oppositeAmountBound: ZERO, + deadline: MAX_UINT_256, + sqrtPriceLimitX96: ZERO, + referralCode: ZERO_BYTES + }); + + const initialBaseBalance = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0].baseBalance; + await subject(); + const finalBaseBalance = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0].baseBalance; + + const basePositionUnit = preciseDiv(initialBaseBalance, await setToken.totalSupply()); + const baseTokenBoughtNotional = preciseMul(basePositionUnit, subjectSetQuantity); + const expectedBaseBalance = initialBaseBalance.sub(baseTokenBoughtNotional); + + expect(finalBaseBalance).eq(expectedBaseBalance); + }); + + it("should set the expected USDC externalPositionUnit", async () => { + await perpSetup.clearingHouse.connect(maker.wallet).openPosition({ + baseToken: vBTC.address, + isBaseToQuote: true, // short + isExactInput: false, // `amount` is USDC + amount: ether(2000), // move price down by selling 2k USDC of vBTC + oppositeAmountBound: ZERO, + deadline: MAX_UINT_256, + sqrtPriceLimitX96: ZERO, + referralCode: ZERO_BYTES + }); + + const usdcTransferOutQuantity = await calculateUSDCTransferOutPreciseUnits( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + await subject(); + + const expectedExternalPositionUnit = toUSDCDecimals( + preciseDiv(usdcTransferOutQuantity, subjectSetQuantity) + ); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(externalPositionUnit).to.be.eq(expectedExternalPositionUnit); + }); + }); + }); + + describe("when there is positive owedRealizedPnl", async () => { + beforeEach(async () => { + // Move price down by maker selling 2k USDC of vETH + await perpSetup.clearingHouse.connect(maker.wallet).openPosition({ + baseToken: vETH.address, + isBaseToQuote: false, // long + isExactInput: true, // `amount` is USDC + amount: ether(2000), + oppositeAmountBound: ZERO, + deadline: MAX_UINT_256, + sqrtPriceLimitX96: ZERO, + referralCode: ZERO_BYTES + }); + + // Sell a little, booking profit to owedRealizedPnl + await perpLeverageModule.connect(owner.wallet).trade( + subjectSetToken, + vETH.address, + ether(.1).mul(-1), + ZERO + ); + + const vETHSpotPrice = await perpSetup.getSpotPrice(vETH.address); + await perpSetup.setBaseTokenOraclePrice(vETH, vETHSpotPrice.div(10 ** 12)); + }); + + it("should set the expected USDC externalPositionUnit", async () => { + const owedRealizedPnl = (await perpLeverageModule.getAccountInfo(subjectSetToken)).owedRealizedPnl; + + const usdcTransferOutQuantity = await calculateUSDCTransferOut( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + const expectedExternalPositionUnit = preciseDiv( + usdcTransferOutQuantity, + subjectSetQuantity + ); + + await subject(); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(owedRealizedPnl).gt(0); + expect(externalPositionUnit).to.be.eq(expectedExternalPositionUnit); + }); + }); + + describe("when there is negative owedRealizedPnl", async () => { + beforeEach(async () => { + // Move price down by maker selling 1k USDC of vETH + await perpSetup.clearingHouse.connect(maker.wallet).openPosition({ + baseToken: vETH.address, + isBaseToQuote: true, // short + isExactInput: false, // `amount` is USDC + amount: ether(1000), + oppositeAmountBound: ZERO, + deadline: MAX_UINT_256, + sqrtPriceLimitX96: ZERO, + referralCode: ZERO_BYTES + }); + + // Sell a little, booking profit to owedRealizedPnl + await perpLeverageModule.connect(owner.wallet).trade( + subjectSetToken, + vETH.address, + ether(.1).mul(-1), + ZERO + ); + + const vETHSpotPrice = await perpSetup.getSpotPrice(vETH.address); + await perpSetup.setBaseTokenOraclePrice(vETH, vETHSpotPrice.div(10 ** 12)); + }); + + it("should set the expected USDC externalPositionUnit", async () => { + const owedRealizedPnl = (await perpLeverageModule.getAccountInfo(subjectSetToken)).owedRealizedPnl; + + const usdcTransferOutQuantity = await calculateUSDCTransferOut( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + const expectedExternalPositionUnit = preciseDiv( + usdcTransferOutQuantity, + subjectSetQuantity + ); + + await subject(); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(owedRealizedPnl).lt(0); + expect(externalPositionUnit).to.be.eq(expectedExternalPositionUnit); + }); + }); + + describe("when the Set owes funding", async () => { + beforeEach(async() => { + // set funding rate to non-zero value + await perpSetup.clearingHouseConfig.setMaxFundingRate(BigNumber.from(0.1e6)); // 10% in decimal 6 + }); + + it("should socialize the funding payment among existing set holders", async () => { + // Move oracle price up and wait one day + await perpSetup.setBaseTokenOraclePrice(vETH, usdcUnits(9.7)); + await increaseTimeAsync(ONE_DAY_IN_SECONDS); + + const vEthBalance = await perpSetup.accountBalance.getBase(setToken.address, vETH.address); + const vBtcBalance = await perpSetup.accountBalance.getBase(setToken.address, vBTC.address); + const pendingFunding = (await perpLeverageModule.getAccountInfo(subjectSetToken)).pendingFundingPayments; + const usdcTransferOutQuantity = await calculateUSDCTransferOutPreciseUnits( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup, + false // don't include funding + ); + + await subject(); + + const vEthUsdcAmountDelta = await getUSDCDeltaDueToFundingGrowth(setToken, subjectSetQuantity, vETH.address, vEthBalance, perpSetup); + const vBtcUsdcAmountDelta = await getUSDCDeltaDueToFundingGrowth(setToken, subjectSetQuantity, vBTC.address, vBtcBalance, perpSetup); + const expectedExternalPositionUnit = toUSDCDecimals( + preciseDiv(usdcTransferOutQuantity.add(vEthUsdcAmountDelta).add(vBtcUsdcAmountDelta), subjectSetQuantity) + ); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(pendingFunding).lt(0); + expect(externalPositionUnit).to.be.eq(expectedExternalPositionUnit); + }); + }); + + describe("when the Set is owed funding", async () => { + beforeEach(async() => { + // set funding rate to non-zero value + await perpSetup.clearingHouseConfig.setMaxFundingRate(BigNumber.from(0.1e6)); // 10% in decimal 6 + }); + + it("should socialize the funding payment among existing set holders", async () => { + // Move oracle price down and wait one day + await perpSetup.setBaseTokenOraclePrice(vETH, usdcUnits(11)); + await increaseTimeAsync(ONE_DAY_IN_SECONDS); + + const vEthBalance = await perpSetup.accountBalance.getBase(setToken.address, vETH.address); + const vBtcBalance = await perpSetup.accountBalance.getBase(setToken.address, vBTC.address); + const pendingFunding = (await perpLeverageModule.getAccountInfo(subjectSetToken)).pendingFundingPayments; + const usdcTransferOutQuantity = await calculateUSDCTransferOutPreciseUnits( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup, + false // don't include funding + ); + + await subject(); + + const vEthUsdcAmountDelta = await getUSDCDeltaDueToFundingGrowth(setToken, subjectSetQuantity, vETH.address, vEthBalance, perpSetup); + const vBtcUsdcAmountDelta = await getUSDCDeltaDueToFundingGrowth(setToken, subjectSetQuantity, vBTC.address, vBtcBalance, perpSetup); + const expectedExternalPositionUnit = toUSDCDecimals( + preciseDiv(usdcTransferOutQuantity.add(vEthUsdcAmountDelta).add(vBtcUsdcAmountDelta), subjectSetQuantity) + ); + + const externalPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(pendingFunding).gt(ZERO); + expect(externalPositionUnit).to.be.eq(expectedExternalPositionUnit); + }); + }); + }); + + describe("when total supply is 0", async () => { + let otherSetToken: SetToken; + + beforeEach(async () => { + otherSetToken = await setup.createSetToken( + [usdc.address], + [usdcUnits(10)], + [perpLeverageModule.address, debtIssuanceMock.address] + ); + await debtIssuanceMock.initialize(otherSetToken.address); + await perpLeverageModule.updateAllowedSetToken(otherSetToken.address, true); + await perpLeverageModule.connect(owner.wallet).initialize(otherSetToken.address); + + // Initialize mock module + await otherSetToken.addModule(mockModule.address); + await otherSetToken.connect(mockModule.wallet).initializeModule(); + + subjectSetToken = otherSetToken.address; + }); + + it("should not update the USDC externalPositionUnit", async () => { + const initialExternalPositionUnit = await otherSetToken.getExternalPositionRealUnit(usdc.address, perpLeverageModule.address); + await subject(); + const finalExternalPositionUnit = await otherSetToken.getExternalPositionRealUnit(usdc.address, perpLeverageModule.address); + + const expectedExternalPositionUnit = ZERO; + expect(initialExternalPositionUnit).to.eq(finalExternalPositionUnit); + expect(finalExternalPositionUnit).to.eq(expectedExternalPositionUnit); + }); + }); + + describe("when there is no external USDC position", () => { + let otherSetToken: SetToken; + + beforeEach(async () => { + otherSetToken = await setup.createSetToken( + [usdc.address], + [usdcUnits(10)], + [perpLeverageModule.address, debtIssuanceMock.address, setup.issuanceModule.address] + ); + + await debtIssuanceMock.initialize(otherSetToken.address); + await perpLeverageModule.updateAllowedSetToken(otherSetToken.address, true); + + await perpLeverageModule.connect(owner.wallet).initialize(otherSetToken.address); + + await otherSetToken.addModule(mockModule.address); + await otherSetToken.connect(mockModule.wallet).initializeModule(); + + // Issue to create some supply + await usdc.approve(setup.issuanceModule.address, usdcUnits(1000)); + await setup.issuanceModule.initialize(otherSetToken.address, ADDRESS_ZERO); + await setup.issuanceModule.issue(otherSetToken.address, ether(2), owner.address); + + subjectSetToken = otherSetToken.address; + }); + + it("should not update the externalPositionUnit", async () => { + const initialExternalPositionUnit = await otherSetToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + await subject(); + + const finalExternalPositionUnit = await otherSetToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + expect(initialExternalPositionUnit).eq(ZERO); + expect(initialExternalPositionUnit).eq(finalExternalPositionUnit); + }); + }); + + describe("when caller is not module", async () => { + beforeEach(async () => subjectCaller = owner); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Only the module can call"); + }); + }); + + describe("if disabled module is caller", async () => { + beforeEach(async () => { + await setup.controller.removeModule(mockModule.address); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Module must be enabled on controller"); + }); + }); + }); + + describe("#componentIssueHook", () => { + let setToken: SetToken; + let collateralQuantity: BigNumber; + let subjectSetToken: Address; + let subjectSetQuantity: BigNumber; + let subjectIsEquity: boolean; + let subjectCaller: Account; + + const initializeContracts = async () => { + collateralQuantity = usdcUnits(10); + setToken = await issueSetsAndDepositToPerp(collateralQuantity); + }; + + const initializeSubjectVariables = () => { + subjectSetToken = setToken.address; + subjectCaller = mockModule; + subjectSetQuantity = ether(1); + subjectIsEquity = true; + }; + + cacheBeforeEach(initializeContracts); + beforeEach(initializeSubjectVariables); + + async function subject(): Promise { + return await perpLeverageModule.connect(subjectCaller.wallet).componentIssueHook( + subjectSetToken, + subjectSetQuantity, + usdc.address, + subjectIsEquity + ); + } + + describe("when long", () => { + // Set up as 2X Long, allow 2% slippage + cacheBeforeEach(async () => { + await leverUp( + setToken, + perpLeverageModule, + perpSetup, + owner, + vETH.address, + 2, + ether(.02), + true + ); + + await perpLeverageModule + .connect(subjectCaller.wallet) + .moduleIssueHook(subjectSetToken, subjectSetQuantity); + }); + + it("transfer the expected amount from SetToken to Perp vault", async () => { + const initialSetTokenUSDCBalance = await usdc.balanceOf(subjectSetToken); + + const externalUSDCPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + const usdcToTransferOut = preciseMul(externalUSDCPositionUnit, subjectSetQuantity); + + await subject(); + + const finalSetTokenUSDCBalance = await usdc.balanceOf(subjectSetToken); + const expectedSetTokenUSDCBalance = initialSetTokenUSDCBalance.sub(usdcToTransferOut); + + expect(finalSetTokenUSDCBalance).eq(expectedSetTokenUSDCBalance); + }); + + it("should not update the USDC defaultPositionUnit", async () => { + const initialDefaultPosition = await setToken.getDefaultPositionRealUnit(usdc.address); + + await subject(); + + const finalDefaultPosition = await setToken.getDefaultPositionRealUnit(usdc.address); + + expect(finalDefaultPosition).to.eq(initialDefaultPosition); + }); + + it("should not update the USDC externalPositionUnit", async () => { + const initialExternalPositionUnit = await setToken.getExternalPositionRealUnit(usdc.address, perpLeverageModule.address); + await subject(); + const finalExternalPositionUnit = await setToken.getExternalPositionRealUnit(usdc.address, perpLeverageModule.address); + + expect(initialExternalPositionUnit).eq(finalExternalPositionUnit); + }); + }); + + describe("when short", () => { + // Set up as 2X Short, allow 2% slippage + cacheBeforeEach(async () => { + await leverUp( + setToken, + perpLeverageModule, + perpSetup, + owner, + vETH.address, + 2, + ether(.02), + false + ); + + await perpLeverageModule + .connect(subjectCaller.wallet) + .moduleIssueHook(subjectSetToken, subjectSetQuantity); + }); + + it("transfer the expected amount from SetToken to Perp vault", async () => { + const initialSetTokenUSDCBalance = await usdc.balanceOf(subjectSetToken); + + const externalUSDCPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + const usdcToTransferOut = preciseMul(externalUSDCPositionUnit, subjectSetQuantity); + + await subject(); + + const finalSetTokenUSDCBalance = await usdc.balanceOf(subjectSetToken); + const expectedSetTokenUSDCBalance = initialSetTokenUSDCBalance.sub(usdcToTransferOut); + + expect(finalSetTokenUSDCBalance).eq(expectedSetTokenUSDCBalance); + }); + + it("should not update the USDC defaultPositionUnit", async () => { + const initialDefaultPosition = await setToken.getDefaultPositionRealUnit(usdc.address); + + await subject(); + + const finalDefaultPosition = await setToken.getDefaultPositionRealUnit(usdc.address); + + expect(finalDefaultPosition).to.eq(initialDefaultPosition); + }); + + it("should not update the USDC externalPositionUnit", async () => { + const initialExternalPositionUnit = await setToken.getExternalPositionRealUnit(usdc.address, perpLeverageModule.address); + await subject(); + const finalExternalPositionUnit = await setToken.getExternalPositionRealUnit(usdc.address, perpLeverageModule.address); + + expect(initialExternalPositionUnit).eq(finalExternalPositionUnit); + }); + }); + + describe("when isEquity is false", async () => { + beforeEach(async () => { + subjectIsEquity = false; + }); + + it("should deposit nothing", async () => { + const { + collateralBalance: initialCollateralBalance + } = await perpLeverageModule.getAccountInfo(subjectSetToken); + + await subject(); + + const { + collateralBalance: finalCollateralBalance + } = await perpLeverageModule.getAccountInfo(subjectSetToken); + + expect(initialCollateralBalance).to.eq(finalCollateralBalance); + }); + }); + + describe("when caller is not module", async () => { + beforeEach(async () => { + subjectCaller = owner; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Only the module can call"); + }); + }); + + describe("if disabled module is caller", async () => { + beforeEach(async () => { + await setup.controller.removeModule(mockModule.address); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Module must be enabled on controller"); + }); + }); + }); + + describe("#componentRedeemHook", () => { + let setToken: SetToken; + let collateralQuantity: BigNumber; + let subjectSetToken: Address; + let subjectSetQuantity: BigNumber; + let subjectIsEquity: boolean; + let subjectCaller: Account; + + const initializeContracts = async () => { + collateralQuantity = usdcUnits(10); + setToken = await issueSetsAndDepositToPerp(collateralQuantity); + }; + + const initializeSubjectVariables = () => { + subjectSetToken = setToken.address; + subjectCaller = mockModule; + subjectSetQuantity = ether(.5); // Sell half + subjectIsEquity = true; + }; + + cacheBeforeEach(initializeContracts); + beforeEach(initializeSubjectVariables); + + async function subject(): Promise { + return await perpLeverageModule.connect(subjectCaller.wallet).componentRedeemHook( + subjectSetToken, + subjectSetQuantity, + usdc.address, + subjectIsEquity + ); + } + + describe("when long", () => { + // Set up as 2X Long, allow 2% slippage + cacheBeforeEach(async () => { + await leverUp( + setToken, + perpLeverageModule, + perpSetup, + owner, + vETH.address, + 2, + ether(.02), + true + ); + + await perpLeverageModule + .connect(subjectCaller.wallet) + .moduleRedeemHook(subjectSetToken, subjectSetQuantity); + }); + + it("transfer the expected amount from Perp vault to SetToken", async () => { + const initialSetTokenUSDCBalance = await usdc.balanceOf(subjectSetToken); + + const externalUSDCPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + const usdcToTransferOut = preciseMul(externalUSDCPositionUnit, subjectSetQuantity); + + await subject(); + + const finalSetTokenUSDCBalance = await usdc.balanceOf(subjectSetToken); + const expectedSetTokenUSDCBalance = initialSetTokenUSDCBalance.add(usdcToTransferOut); + + expect(finalSetTokenUSDCBalance).eq(expectedSetTokenUSDCBalance); + }); + + it("should not update the USDC defaultPositionUnit", async () => { + const initialDefaultPositionUnit = await setToken.getDefaultPositionRealUnit(usdc.address); + await subject(); + const finalDefaultPositionUnit = await setToken.getDefaultPositionRealUnit(usdc.address); + + expect(initialDefaultPositionUnit).eq(finalDefaultPositionUnit); + }); + + it("should not update the USDC externalPositionUnit", async () => { + const initialExternalPositionUnit = await setToken.getExternalPositionRealUnit(usdc.address, perpLeverageModule.address); + await subject(); + const finalExternalPositionUnit = await setToken.getExternalPositionRealUnit(usdc.address, perpLeverageModule.address); + + expect(initialExternalPositionUnit).eq(finalExternalPositionUnit); + }); + }); + + describe("when short", () => { + // Set up as 2X Short, allow 2% slippage + cacheBeforeEach(async () => { + await leverUp( + setToken, + perpLeverageModule, + perpSetup, + owner, + vETH.address, + 2, + ether(.02), + false + ); + + await perpLeverageModule + .connect(subjectCaller.wallet) + .moduleRedeemHook(subjectSetToken, subjectSetQuantity); + }); + + it("transfer the expected amount from Perp vault to SetToken", async () => { + const initialSetTokenUSDCBalance = await usdc.balanceOf(subjectSetToken); + + const externalUSDCPositionUnit = await setToken.getExternalPositionRealUnit( + usdc.address, + perpLeverageModule.address + ); + + const usdcToTransferOut = preciseMul(externalUSDCPositionUnit, subjectSetQuantity); + + await subject(); + + const finalSetTokenUSDCBalance = await usdc.balanceOf(subjectSetToken); + const expectedSetTokenUSDCBalance = initialSetTokenUSDCBalance.add(usdcToTransferOut); + + expect(finalSetTokenUSDCBalance).eq(expectedSetTokenUSDCBalance); + }); + + it("should not update the USDC defaultPositionUnit", async () => { + const initialDefaultPositionUnit = await setToken.getDefaultPositionRealUnit(usdc.address); + await subject(); + const finalDefaultPositionUnit = await setToken.getDefaultPositionRealUnit(usdc.address); + + expect(initialDefaultPositionUnit).eq(finalDefaultPositionUnit); + }); + + it("should not update the USDC externalPositionUnit", async () => { + const initialExternalPositionUnit = await setToken.getExternalPositionRealUnit(usdc.address, perpLeverageModule.address); + await subject(); + const finalExternalPositionUnit = await setToken.getExternalPositionRealUnit(usdc.address, perpLeverageModule.address); + + expect(initialExternalPositionUnit).eq(finalExternalPositionUnit); + }); + }); + + describe("when isEquity is false", async () => { + beforeEach(async () => { + subjectIsEquity = false; + }); + + it("should withdraw nothing", async () => { + const { + collateralBalance: initialCollateralBalance + } = await perpLeverageModule.getAccountInfo(subjectSetToken); + + await subject(); + + const { + collateralBalance: finalCollateralBalance + } = await perpLeverageModule.getAccountInfo(subjectSetToken); + + expect(initialCollateralBalance).to.eq(finalCollateralBalance); + }); + }); + + describe("when caller is not module", async () => { + beforeEach(async () => { + subjectCaller = owner; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Only the module can call"); + }); + }); + + describe("if disabled module is caller", async () => { + beforeEach(async () => { + await setup.controller.removeModule(mockModule.address); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Module must be enabled on controller"); + }); + }); + }); + + describe("#registerToModule", async () => { + let setToken: SetToken; + let otherIssuanceModule: DebtIssuanceMock; + let isInitialized: boolean; + let subjectSetToken: Address; + let subjectDebtIssuanceModule: Address; + + const initializeContracts = async function () { + otherIssuanceModule = await deployer.mocks.deployDebtIssuanceMock(); + await setup.controller.addModule(otherIssuanceModule.address); + + setToken = await setup.createSetToken( + [usdc.address], + [ether(100)], + [perpLeverageModule.address, setup.issuanceModule.address, debtIssuanceMock.address] + ); + await debtIssuanceMock.initialize(setToken.address); + // Add SetToken to allow list + await perpLeverageModule.updateAllowedSetToken(setToken.address, true); + // Initialize module if set to true + if (isInitialized) { + await perpLeverageModule.initialize(setToken.address); + } + await setup.issuanceModule.initialize(setToken.address, ADDRESS_ZERO); + // Add other issuance mock after initializing PerpV2LeverageModuleV2, so register is never called + await setToken.addModule(otherIssuanceModule.address); + await otherIssuanceModule.initialize(setToken.address); + }; + + const initializeSubjectVariables = () => { + subjectSetToken = setToken.address; + subjectDebtIssuanceModule = otherIssuanceModule.address; + }; + + async function subject(): Promise { + return perpLeverageModule.registerToModule(subjectSetToken, subjectDebtIssuanceModule); + } + + describe("when module is initialized", () => { + beforeEach(() => { + isInitialized = true; + }); + + cacheBeforeEach(initializeContracts); + beforeEach(initializeSubjectVariables); + + it("should register on the other issuance module", async () => { + const previousIsRegistered = await otherIssuanceModule.isRegistered(setToken.address); + await subject(); + const currentIsRegistered = await otherIssuanceModule.isRegistered(setToken.address); + expect(previousIsRegistered).to.be.false; + expect(currentIsRegistered).to.be.true; + }); + + describe("when SetToken is not valid", async () => { + beforeEach(async () => { + const nonEnabledSetToken = await setup.createNonControllerEnabledSetToken( + [usdc.address], + [ether(1)], + [perpLeverageModule.address], + owner.address + ); + + subjectSetToken = nonEnabledSetToken.address; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be a valid and initialized SetToken"); + }); + }); + + describe("when debt issuance module is not initialized on SetToken", async () => { + beforeEach(async () => { + await setToken.removeModule(otherIssuanceModule.address); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Issuance not initialized"); + }); + }); + }); + + 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"); + }); + }); + }); + + describe("#removeModule", async () => { + let setToken: SetToken; + let subjectModule: Address; + + cacheBeforeEach(async () => { + setToken = await setup.createSetToken( + [usdc.address], + [ether(100)], + [perpLeverageModule.address, debtIssuanceMock.address, setup.issuanceModule.address] + ); + await debtIssuanceMock.initialize(setToken.address); + // Add SetToken to allow list + await perpLeverageModule.updateAllowedSetToken(setToken.address, true); + await perpLeverageModule.initialize(setToken.address); + await setup.issuanceModule.initialize(setToken.address, ADDRESS_ZERO); + + // Approve tokens to issuance module and call issue + await usdc.approve(setup.issuanceModule.address, ether(100)); + await setup.issuanceModule.issue(setToken.address, ether(1), owner.address); + }); + + beforeEach(() => { + subjectModule = perpLeverageModule.address; + }); + + async function subject(): Promise { + return setToken.removeModule(subjectModule); + } + + it("should remove the Module on the SetToken", async () => { + await subject(); + const isModuleEnabled = await setToken.isInitializedModule(perpLeverageModule.address); + expect(isModuleEnabled).to.be.false; + }); + + it("should unregister on the debt issuance module", async () => { + await subject(); + const isRegistered = await debtIssuanceMock.isRegistered(setToken.address); + expect(isRegistered).to.be.false; + }); + + describe("when the account balance is positive", async () => { + beforeEach(async () => { + await perpLeverageModule.deposit(setToken.address, usdcUnits(10)); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Account balance exists"); + }); + }); + }); + + describe("#getIssuanceAdjustments", () => { + let setToken: SetToken; + let collateralQuantity: BigNumber; + let subjectSetToken: Address; + let subjectCaller: Account; + let subjectSetQuantity: BigNumber; + + const initializeContracts = async () => { + collateralQuantity = usdcUnits(10); + setToken = await issueSetsAndDepositToPerp(collateralQuantity); + }; + + const initializeSubjectVariables = () => { + subjectSetToken = setToken.address; + subjectCaller = mockModule; + subjectSetQuantity = ether(1); + }; + + cacheBeforeEach(initializeContracts); + beforeEach(initializeSubjectVariables); + + async function subject(): Promise { + return await perpLeverageModule + .connect(subjectCaller.wallet) + .callStatic + .getIssuanceAdjustments(subjectSetToken, subjectSetQuantity); + } + + describe("when long, single position", () => { + let baseToken: Address; + + // Set up as 2X Long, allow 2% slippage + cacheBeforeEach(async () => { + baseToken = vETH.address; + + await leverUp( + setToken, + perpLeverageModule, + perpSetup, + owner, + baseToken, + 2, + ether(.02), + true + ); + }); + + describe("when issuing a single set", async () => { + it("does *not* change the vBase balance", async () => { + const initialBaseBalance = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0].baseBalance; + await subject(); + const finalBaseBalance = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0].baseBalance; + + expect(initialBaseBalance).eq(finalBaseBalance); + }); + + it("should return adjustment arrays of the correct length with value in correct position", async () => { + const components = await setToken.getComponents(); + const expectedAdjustmentsLength = components.length; + + const adjustments = await subject(); + + const equityAdjustmentsLength = adjustments[0].length; + const debtAdjustmentsLength = adjustments[1].length; + const wbtcAdjustment = adjustments[0][0]; + const usdcAdjustment = adjustments[0][1]; + + expect(equityAdjustmentsLength).eq(expectedAdjustmentsLength); + expect(debtAdjustmentsLength).eq(debtAdjustmentsLength); + expect(wbtcAdjustment).eq(ZERO); + expect(usdcAdjustment).gt(ZERO); + }); + + it("should return the expected USDC adjustment unit", async () => { + const oldExternalPositionUnit = await setToken.getExternalPositionRealUnit(usdc.address, perpLeverageModule.address); + const usdcTransferInQuantity = await calculateUSDCTransferInPreciseUnits( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + const actualAdjustmentUnit = (await subject())[0][1]; // call subject + + const newExternalPositionUnit = toUSDCDecimals(preciseDiv(usdcTransferInQuantity, subjectSetQuantity)); + const expectedAdjustmentUnit = newExternalPositionUnit.sub(oldExternalPositionUnit); + + expect(actualAdjustmentUnit).to.be.eq(expectedAdjustmentUnit); + }); + + describe("when the set token doesn't contain the collateral token", async () => { + let otherSetToken: SetToken; + + beforeEach(async () => { + otherSetToken = await setup.createSetToken( + [setup.wbtc.address], + [bitcoin(10)], + [perpLeverageModule.address, debtIssuanceMock.address, setup.issuanceModule.address] + ); + await debtIssuanceMock.initialize(otherSetToken.address); + await perpLeverageModule.updateAllowedSetToken(otherSetToken.address, true); + await perpLeverageModule.connect(owner.wallet).initialize(otherSetToken.address); + + subjectSetToken = otherSetToken.address; + }); + + it("should return empty arrays", async () => { + const components = await otherSetToken.getComponents(); + const adjustments = await subject(); + + const expectedAdjustmentsLength = 2; + const expectedAdjustmentValue = ZERO; + const expectedAdjustmentsArrayLength = components.length; + + expect(adjustments.length).eq(expectedAdjustmentsLength); + expect(adjustments[0].length).eq(expectedAdjustmentsArrayLength); + expect(adjustments[1].length).eq(expectedAdjustmentsArrayLength); + + for (const adjustment of adjustments[0]) { + expect(adjustment).eq(expectedAdjustmentValue); + } + + for (const adjustment of adjustments[1]) { + expect(adjustment).eq(expectedAdjustmentValue); + } + }); + }); + }); + }); + }); + + describe("#getRedemptionAdjustments", () => { + let setToken: SetToken; + let collateralQuantity: BigNumber; + let subjectSetToken: Address; + let subjectSetQuantity: BigNumber; + let subjectCaller: Account; + + const initializeContracts = async () => { + collateralQuantity = usdcUnits(10); + setToken = await issueSetsAndDepositToPerp(collateralQuantity); + }; + + const initializeSubjectVariables = () => { + subjectSetToken = setToken.address; + subjectCaller = mockModule; + subjectSetQuantity = ether(1); + }; + + cacheBeforeEach(initializeContracts); + beforeEach(initializeSubjectVariables); + + async function subject(): Promise { + return await perpLeverageModule + .connect(subjectCaller.wallet) + .callStatic + .getRedemptionAdjustments(subjectSetToken, subjectSetQuantity); + } + + describe("when long", async () => { + let baseToken: Address; + + // Set up as 2X Long, allow 2% slippage + cacheBeforeEach(async () => { + baseToken = vETH.address; + + await leverUp( + setToken, + perpLeverageModule, + perpSetup, + owner, + baseToken, + 2, + ether(.02), + true + ); + }); + + describe("when redeeming a single set", async () => { + it("should *not* alter the vBase balance", async () => { + const initialBaseBalance = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0].baseBalance; + await subject(); + const finalBaseBalance = (await perpLeverageModule.getPositionNotionalInfo(subjectSetToken))[0].baseBalance; + + expect(initialBaseBalance).eq(finalBaseBalance); + }); + + it("should return adjustment arrays of the correct length with value in correct position", async () => { + const components = await setToken.getComponents(); + const expectedAdjustmentsLength = components.length; + + const adjustments = await subject(); + + const equityAdjustmentsLength = adjustments[0].length; + const debtAdjustmentsLength = adjustments[1].length; + const wbtcAdjustment = adjustments[0][0]; + const usdcAdjustment = adjustments[0][1]; + + expect(equityAdjustmentsLength).eq(expectedAdjustmentsLength); + expect(debtAdjustmentsLength).eq(debtAdjustmentsLength); + expect(wbtcAdjustment).eq(ZERO); + expect(usdcAdjustment).lt(ZERO); + }); + + it("should return the expected USDC adjustment unit", async () => { + const oldExternalPositionUnit = await setToken.getExternalPositionRealUnit(usdc.address, perpLeverageModule.address); + const usdcTransferOutQuantity = await calculateUSDCTransferOutPreciseUnits( + setToken, + subjectSetQuantity, + perpLeverageModule, + perpSetup + ); + + const actualAdjustmentUnit = (await subject())[0][1]; // call subject + + const newExternalPositionUnit = toUSDCDecimals(preciseDiv(usdcTransferOutQuantity, subjectSetQuantity)); + const expectedAdjustmentUnit = newExternalPositionUnit.sub(oldExternalPositionUnit); + + expect(actualAdjustmentUnit).to.be.eq(expectedAdjustmentUnit); + }); + + describe("when the set token doesn't contain the collateral token", async () => { + let otherSetToken: SetToken; + + beforeEach(async () => { + otherSetToken = await setup.createSetToken( + [setup.wbtc.address], + [bitcoin(10)], + [perpLeverageModule.address, debtIssuanceMock.address, setup.issuanceModule.address] + ); + await debtIssuanceMock.initialize(otherSetToken.address); + await perpLeverageModule.updateAllowedSetToken(otherSetToken.address, true); + await perpLeverageModule.connect(owner.wallet).initialize(otherSetToken.address); + + subjectSetToken = otherSetToken.address; + }); + + it("should return empty arrays", async () => { + const components = await otherSetToken.getComponents(); + const adjustments = await subject(); + + const expectedAdjustmentsLength = 2; + const expectedAdjustmentValue = ZERO; + const expectedAdjustmentsArrayLength = components.length; + + expect(adjustments.length).eq(expectedAdjustmentsLength); + expect(adjustments[0].length).eq(expectedAdjustmentsArrayLength); + expect(adjustments[1].length).eq(expectedAdjustmentsArrayLength); + + for (const adjustment of adjustments[0]) { + expect(adjustment).eq(expectedAdjustmentValue); + } + + for (const adjustment of adjustments[1]) { + expect(adjustment).eq(expectedAdjustmentValue); + } + }); + }); + }); + }); + }); + + describe("#getPositionNotionalInfo", () => { + let setToken: SetToken; + let subjectSetToken: Address; + + let issueQuantity: BigNumber; + let expectedVETHToken: Address; + let expectedVBTCToken: Address; + let vethTradeQuantityUnits: BigNumber; + let vbtcTradeQuantityUnits: BigNumber; + let expectedDepositQuantity: BigNumber; + let expectedVETHDeltaQuote: BigNumber; + let expectedVBTCDeltaQuote: BigNumber; + + beforeEach(async () => { + expectedDepositQuantity = usdcUnits(100); + issueQuantity = ether(2); + + setToken = await issueSetsAndDepositToPerp(expectedDepositQuantity, true, issueQuantity); + + subjectSetToken = setToken.address; + expectedVETHToken = vETH.address; + expectedVBTCToken = vBTC.address; + vethTradeQuantityUnits = ether(1); + vbtcTradeQuantityUnits = ether(2); + + ({ deltaQuote: expectedVETHDeltaQuote } = await perpSetup.getSwapQuote( + expectedVETHToken, + preciseMul(vethTradeQuantityUnits, issueQuantity), + true + )); + + ({ deltaQuote: expectedVBTCDeltaQuote } = await perpSetup.getSwapQuote( + expectedVBTCToken, + preciseMul(vbtcTradeQuantityUnits, issueQuantity), + true + )); + + const vETHQuoteBoundQuantityUnits = ether(10.15); + const vBTCQuoteBoundQuantityUnits = ether(101); + + await perpLeverageModule.connect(owner.wallet).trade( + subjectSetToken, + expectedVETHToken, + vethTradeQuantityUnits, + vETHQuoteBoundQuantityUnits + ); + + await perpLeverageModule.connect(owner.wallet).trade( + subjectSetToken, + expectedVBTCToken, + vbtcTradeQuantityUnits, + vBTCQuoteBoundQuantityUnits + ); + }); + + async function subject(): Promise { + return perpLeverageModule.getPositionNotionalInfo(subjectSetToken); + } + + it("should return info for multiple positions", async () => { + const positionInfo = await subject(); + + const expectedVETHNotionalPosition = preciseMul(vethTradeQuantityUnits, issueQuantity); + const expectedVBTCNotionalPosition = preciseMul(vbtcTradeQuantityUnits, issueQuantity); + + expect(positionInfo.length).eq(2); + expect(positionInfo[0].baseToken).eq(expectedVETHToken); + expect(positionInfo[1].baseToken).eq(expectedVBTCToken); + expect(positionInfo[0].baseBalance).eq(expectedVETHNotionalPosition); + expect(positionInfo[1].baseBalance).eq(expectedVBTCNotionalPosition); + expect(positionInfo[0].quoteBalance).eq(expectedVETHDeltaQuote.mul(-1)); + expect(positionInfo[1].quoteBalance).eq(expectedVBTCDeltaQuote.mul(-1)); + }); + }); + + describe("#getPositionUnitInfo", () => { + let setToken: SetToken; + let issueQuantity: BigNumber; + let subjectSetToken: Address; + + let expectedVETHToken: Address; + let expectedVBTCToken: Address; + let vethTradeQuantityUnits: BigNumber; + let vbtcTradeQuantityUnits: BigNumber; + let expectedDepositQuantity: BigNumber; + let expectedVETHQuoteUnits: BigNumber; + let expectedVBTCQuoteUnits: BigNumber; + + beforeEach(async () => { + issueQuantity = ether(2); + expectedDepositQuantity = usdcUnits(100); + + // Issue 2 sets + setToken = await issueSetsAndDepositToPerp(expectedDepositQuantity, true, issueQuantity); + + subjectSetToken = setToken.address; + expectedVETHToken = vETH.address; + expectedVBTCToken = vBTC.address; + vethTradeQuantityUnits = preciseDiv(ether(1), issueQuantity); + vbtcTradeQuantityUnits = preciseDiv(ether(1), issueQuantity); + + const vETHQuoteBoundQuantityUnits = preciseDiv(ether(10.15), issueQuantity); + const vBTCQuoteBoundQuantityUnits = preciseDiv(ether(50.575), issueQuantity); + + await perpLeverageModule.connect(owner.wallet).trade( + subjectSetToken, + expectedVETHToken, + vethTradeQuantityUnits, + vETHQuoteBoundQuantityUnits + ); + + await perpLeverageModule.connect(owner.wallet).trade( + subjectSetToken, + expectedVBTCToken, + vbtcTradeQuantityUnits, + vBTCQuoteBoundQuantityUnits + ); + }); + + async function subject(): Promise { + return perpLeverageModule.getPositionUnitInfo(subjectSetToken); + } + + it("should return info for multiple positions", async () => { + const vETHQuoteBalance = await perpSetup.accountBalance.getQuote(subjectSetToken, expectedVETHToken); + const vBTCQuoteBalance = await perpSetup.accountBalance.getQuote(subjectSetToken, expectedVBTCToken); + + expectedVETHQuoteUnits = preciseDiv(vETHQuoteBalance, issueQuantity); + expectedVBTCQuoteUnits = preciseDiv(vBTCQuoteBalance, issueQuantity); + + const positionInfo = await subject(); + + expect(positionInfo.length).eq(2); + expect(positionInfo[0].baseToken).eq(expectedVETHToken); + expect(positionInfo[1].baseToken).eq(expectedVBTCToken); + expect(positionInfo[0].baseUnit).eq(vethTradeQuantityUnits); + expect(positionInfo[1].baseUnit).eq(vbtcTradeQuantityUnits); + expect(positionInfo[0].quoteUnit).eq(expectedVETHQuoteUnits); + expect(positionInfo[1].quoteUnit).eq(expectedVBTCQuoteUnits); + }); + }); + + describe("#getAccountInfo", () => { + let setToken: SetToken; + let subjectSetToken: Address; + let expectedDepositQuantity: BigNumber; + + beforeEach(async () => { + expectedDepositQuantity = usdcUnits(10); + setToken = await issueSetsAndDepositToPerp(expectedDepositQuantity); + subjectSetToken = setToken.address; + + await leverUp(setToken, perpLeverageModule, perpSetup, owner, vETH.address, 2, ether(0.02), true); + await increaseTimeAsync(ONE_DAY_IN_SECONDS); + }); + + async function subject(): Promise { + return perpLeverageModule.getAccountInfo(subjectSetToken); + } + + it("should return account info", async () => { + const pendingFunding = await perpSetup.exchange.getAllPendingFundingPayment(setToken.address); + + const accountInfo = await subject(); + + const expectedFunding = pendingFunding.mul(-1); + + expect(toUSDCDecimals(accountInfo.collateralBalance)).eq(expectedDepositQuantity); + expect(accountInfo.owedRealizedPnl).eq(0); + expect(accountInfo.pendingFundingPayments).eq(expectedFunding); + }); + }); + + describe("#updateMaxPerpPositionsPerSet", async () => { + let subjectCaller: Account; + let subjectMaxPerpPositionsPerSet: BigNumber; + + beforeEach(async () => { + subjectCaller = owner; + subjectMaxPerpPositionsPerSet = THREE; + }); + + async function subject(): Promise { + await perpLeverageModule.connect(subjectCaller.wallet).updateMaxPerpPositionsPerSet(subjectMaxPerpPositionsPerSet); + } + + it("should update max perp positions per set", async () => { + await subject(); + + const maxPerpPositionsPerSet = await perpLeverageModule.maxPerpPositionsPerSet(); + + expect(maxPerpPositionsPerSet).to.eq(THREE); + }); + + describe("when owner is not caller", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Ownable: caller is not the owner"); + }); + }); + }); +}); diff --git a/utils/contracts/index.ts b/utils/contracts/index.ts index e9cc0dcd1..1483ac801 100644 --- a/utils/contracts/index.ts +++ b/utils/contracts/index.ts @@ -58,6 +58,7 @@ export { KyberNetworkProxyMock } from "../../typechain/KyberNetworkProxyMock"; export { LendToAaveMigrator } from "../../typechain/LendToAaveMigrator"; export { ManagerIssuanceHookMock } from "../../typechain/ManagerIssuanceHookMock"; export { ModuleBaseMock } from "../../typechain/ModuleBaseMock"; +export { ModuleBaseV2Mock } from "../../typechain/ModuleBaseV2Mock"; export { ModuleIssuanceHookMock } from "../../typechain/ModuleIssuanceHookMock"; export { NAVIssuanceCaller } from "../../typechain/NAVIssuanceCaller"; export { NAVIssuanceHookMock } from "../../typechain/NAVIssuanceHookMock"; @@ -70,11 +71,18 @@ export { CTokenOracle } from "../../typechain/CTokenOracle"; export { YearnVaultMock } from "../../typechain/YearnVaultMock"; export { YearnVaultOracle } from "../../typechain/YearnVaultOracle"; export { PositionMock } from "../../typechain/PositionMock"; +export { PositionV2Mock } from "../../typechain/PositionV2Mock"; export { PerpV2 } from "../../typechain/PerpV2"; +export { PerpV2LibraryV2 } from "../../typechain/PerpV2LibraryV2"; export { PerpV2Mock } from "../../typechain/PerpV2Mock"; +export { PerpV2LibraryV2Mock } from "../../typechain/PerpV2LibraryV2Mock"; +export { PerpV2Positions } from "../../typechain/PerpV2Positions"; +export { PerpV2PositionsMock } from "../../typechain/PerpV2PositionsMock"; export { PerpV2LeverageModule } from "../../typechain/PerpV2LeverageModule"; +export { PerpV2LeverageModuleV2 } from "../../typechain/PerpV2LeverageModuleV2"; export { PerpV2BasisTradingModule } from "../../typechain/PerpV2BasisTradingModule"; export { PerpV2LeverageModuleViewer } from "../../typechain/PerpV2LeverageModuleViewer"; +export { PositionV2 } from "../../typechain/PositionV2"; export { PreciseUnitMathMock } from "../../typechain/PreciseUnitMathMock"; export { PriceOracle } from "../../typechain/PriceOracle"; export { ProtocolViewer } from "../../typechain/ProtocolViewer"; diff --git a/utils/deploys/deployLibraries.ts b/utils/deploys/deployLibraries.ts index 425781754..a64dc53d7 100644 --- a/utils/deploys/deployLibraries.ts +++ b/utils/deploys/deployLibraries.ts @@ -3,12 +3,18 @@ import { Signer } from "ethers"; import { AaveV2, Compound, - PerpV2 + PerpV2, + PerpV2LibraryV2, + PerpV2Positions, + PositionV2 } from "../contracts"; import { Compound__factory } from "../../typechain/factories/Compound__factory"; import { AaveV2__factory } from "../../typechain/factories/AaveV2__factory"; import { PerpV2__factory } from "../../typechain/factories/PerpV2__factory"; +import { PerpV2LibraryV2__factory } from "../../typechain/factories/PerpV2LibraryV2__factory"; +import { PerpV2Positions__factory } from "../../typechain/factories/PerpV2Positions__factory"; +import { PositionV2__factory } from "../../typechain/factories/PositionV2__factory"; export default class DeployLibraries { private _deployerSigner: Signer; @@ -28,4 +34,16 @@ export default class DeployLibraries { public async deployPerpV2(): Promise { return await new PerpV2__factory(this._deployerSigner).deploy(); } + + public async deployPositionV2(): Promise { + return await new PositionV2__factory(this._deployerSigner).deploy(); + } + + public async deployPerpV2LibraryV2(): Promise { + return await new PerpV2LibraryV2__factory(this._deployerSigner).deploy(); + } + + public async deployPerpV2Positions(): Promise { + return await new PerpV2Positions__factory(this._deployerSigner).deploy(); + } } diff --git a/utils/deploys/deployMocks.ts b/utils/deploys/deployMocks.ts index b7dbccbe7..c36c7c8a6 100644 --- a/utils/deploys/deployMocks.ts +++ b/utils/deploys/deployMocks.ts @@ -24,6 +24,7 @@ import { ManagerIssuanceHookMock, ModuleIssuanceHookMock, ModuleBaseMock, + ModuleBaseV2Mock, NAVIssuanceCaller, NAVIssuanceHookMock, OneInchExchangeMock, @@ -31,7 +32,10 @@ import { OracleMock, YearnVaultMock, PerpV2Mock, + PerpV2LibraryV2Mock, + PerpV2PositionsMock, PositionMock, + PositionV2Mock, PreciseUnitMathMock, ResourceIdentifierMock, StakingAdapterMock, @@ -76,6 +80,7 @@ import { InvokeMock__factory } from "../../typechain/factories/InvokeMock__facto import { KyberNetworkProxyMock__factory } from "../../typechain/factories/KyberNetworkProxyMock__factory"; import { ManagerIssuanceHookMock__factory } from "../../typechain/factories/ManagerIssuanceHookMock__factory"; import { ModuleBaseMock__factory } from "../../typechain/factories/ModuleBaseMock__factory"; +import { ModuleBaseV2Mock__factory } from "../../typechain/factories/ModuleBaseV2Mock__factory"; import { ModuleIssuanceHookMock__factory } from "../../typechain/factories/ModuleIssuanceHookMock__factory"; import { NAVIssuanceCaller__factory } from "../../typechain/factories/NAVIssuanceCaller__factory"; import { NAVIssuanceHookMock__factory } from "../../typechain/factories/NAVIssuanceHookMock__factory"; @@ -84,7 +89,10 @@ import { OracleAdapterMock__factory } from "../../typechain/factories/OracleAdap import { OracleMock__factory } from "../../typechain/factories/OracleMock__factory"; import { YearnVaultMock__factory } from "../../typechain/factories/YearnVaultMock__factory"; import { PerpV2Mock__factory } from "../../typechain/factories/PerpV2Mock__factory"; +import { PerpV2LibraryV2Mock__factory } from "../../typechain/factories/PerpV2LibraryV2Mock__factory"; +import { PerpV2PositionsMock__factory } from "../../typechain/factories/PerpV2PositionsMock__factory"; import { PositionMock__factory } from "../../typechain/factories/PositionMock__factory"; +import { PositionV2Mock__factory } from "../../typechain/factories/PositionV2Mock__factory"; import { PreciseUnitMathMock__factory } from "../../typechain/factories/PreciseUnitMathMock__factory"; import { ResourceIdentifierMock__factory } from "../../typechain/factories/ResourceIdentifierMock__factory"; import { StakingAdapterMock__factory } from "../../typechain/factories/StakingAdapterMock__factory"; @@ -151,6 +159,10 @@ export default class DeployMocks { return await new ModuleBaseMock__factory(this._deployerSigner).deploy(controllerAddress); } + public async deployModuleBaseV2Mock(controllerAddress: Address): Promise { + return await new ModuleBaseV2Mock__factory(this._deployerSigner).deploy(controllerAddress); + } + public async deployGodModeMock(controllerAddress: Address): Promise { return await new GodModeMock__factory(this._deployerSigner).deploy(controllerAddress); } @@ -199,7 +211,6 @@ export default class DeployMocks { return await new YearnVaultMock__factory(this._deployerSigner).deploy(pricePerShare); } - public async deployOracleAdapterMock( asset: Address, dummyPrice: BigNumber @@ -211,6 +222,16 @@ export default class DeployMocks { return await new PositionMock__factory(this._deployerSigner).deploy(); } + public async deployPositionV2Mock(libraryName: string, libraryAddress: Address): Promise { + return await new PositionV2Mock__factory( + // @ts-ignore + { + [libraryName]: libraryAddress, + }, + this._deployerSigner + ).deploy(); + } + public async deployPreciseUnitMathMock(): Promise { return await new PreciseUnitMathMock__factory(this._deployerSigner).deploy(); } @@ -299,6 +320,29 @@ export default class DeployMocks { ).deploy(); } + public async deployPerpV2LibraryV2Mock(libraryName: string, libraryAddress: Address): Promise { + return await new PerpV2LibraryV2Mock__factory( + // @ts-ignore + { + [libraryName]: libraryAddress, + }, + this._deployerSigner + ).deploy(); + } + + public async deployPerpV2PositionsMock( + libraryName: string, + libraryAddress: Address + ): Promise { + return await new PerpV2PositionsMock__factory( + // @ts-ignore + { + [libraryName]: libraryAddress, + }, + this._deployerSigner + ).deploy(); + } + public async deployUniswapV3MathMock(): Promise { return await new UniswapV3MathMock__factory(this._deployerSigner).deploy(); } diff --git a/utils/deploys/deployModules.ts b/utils/deploys/deployModules.ts index f04556cbb..0309c5370 100644 --- a/utils/deploys/deployModules.ts +++ b/utils/deploys/deployModules.ts @@ -14,6 +14,7 @@ import { IssuanceModule, NavIssuanceModule, PerpV2LeverageModule, + PerpV2LeverageModuleV2, PerpV2BasisTradingModule, SingleIndexModule, SlippageIssuanceModule, @@ -40,6 +41,7 @@ import { GovernanceModule__factory } from "../../typechain/factories/GovernanceM import { IssuanceModule__factory } from "../../typechain/factories/IssuanceModule__factory"; import { NavIssuanceModule__factory } from "../../typechain/factories/NavIssuanceModule__factory"; import { PerpV2LeverageModule__factory } from "../../typechain/factories/PerpV2LeverageModule__factory"; +import { PerpV2LeverageModuleV2__factory } from "../../typechain/factories/PerpV2LeverageModuleV2__factory"; import { PerpV2BasisTradingModule__factory } from "../../typechain/factories/PerpV2BasisTradingModule__factory"; import { SingleIndexModule__factory } from "../../typechain/factories/SingleIndexModule__factory"; import { StakingModule__factory } from "../../typechain/factories/StakingModule__factory"; @@ -205,13 +207,43 @@ export default class DeployModules { perpQuoter: Address, perpMarketRegistry: Address, maxPerpPositionsPerSet: BigNumber, - libraryName: string, - libraryAddress: Address + perpV2LibraryName: string, + perpV2LibraryAddress: Address ): Promise { return await new PerpV2LeverageModule__factory( // @ts-ignore { - [libraryName]: libraryAddress, + [perpV2LibraryName]: perpV2LibraryAddress, + }, + this._deployerSigner + ).deploy( + controller, + perpVault, + perpQuoter, + perpMarketRegistry, + maxPerpPositionsPerSet + ); + } + + public async deployPerpV2LeverageModuleV2( + controller: Address, + perpVault: Address, + perpQuoter: Address, + perpMarketRegistry: Address, + maxPerpPositionsPerSet: BigNumber, + positionV2LibraryName: string, + positionV2LibraryAddress: Address, + perpV2LibraryName: string, + perpV2LibraryAddress: Address, + perpV2PositionsLibraryName: string, + perpV2PositionsLibraryAddress: Address + ): Promise { + return await new PerpV2LeverageModuleV2__factory( + // @ts-ignore + { + [positionV2LibraryName]: positionV2LibraryAddress, + [perpV2LibraryName]: perpV2LibraryAddress, + [perpV2PositionsLibraryName]: perpV2PositionsLibraryAddress, }, this._deployerSigner ).deploy( @@ -229,13 +261,19 @@ export default class DeployModules { perpQuoter: Address, perpMarketRegistry: Address, maxPerpPositionsPerSet: BigNumber, - libraryName: string, - libraryAddress: Address + positionV2LibraryName: string, + positionV2LibraryAddress: Address, + perpV2LibraryName: string, + perpV2LibraryAddress: Address, + perpV2PositionsLibraryName: string, + perpV2PositionsLibraryAddress: Address ): Promise { return await new PerpV2BasisTradingModule__factory( // @ts-ignore { - [libraryName]: libraryAddress, + [positionV2LibraryName]: positionV2LibraryAddress, + [perpV2LibraryName]: perpV2LibraryAddress, + [perpV2PositionsLibraryName]: perpV2PositionsLibraryAddress, }, this._deployerSigner ).deploy( diff --git a/yarn.lock b/yarn.lock index 308e98017..477b1e1ab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3381,6 +3381,15 @@ cli-cursor@^3.1.0: dependencies: restore-cursor "^3.1.0" +cli-table3@^0.6.0: + version "0.6.1" + resolved "https://registry.yarnpkg.com/cli-table3/-/cli-table3-0.6.1.tgz#36ce9b7af4847f288d3cdd081fbd09bf7bd237b8" + integrity sha512-w0q/enDHhPLq44ovMGdQeeDLvwxwavsJX7oQGYt/LrBlYsyaxyDnp6z3QzFut/6kLLKnlcUVJLrpB7KBfgG/RA== + dependencies: + string-width "^4.2.0" + optionalDependencies: + colors "1.4.0" + cli-truncate@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/cli-truncate/-/cli-truncate-2.1.0.tgz#c39e28bf05edcde5be3b98992a22deed5a2b93c7" @@ -3470,6 +3479,11 @@ color-name@~1.1.4: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== +colors@1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78" + integrity sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA== + combined-stream@^1.0.6, combined-stream@^1.0.8, combined-stream@~1.0.6: version "1.0.8" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" @@ -5941,6 +5955,14 @@ har-validator@~5.1.3: ajv "^6.12.3" har-schema "^2.0.0" +hardhat-contract-sizer@^2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/hardhat-contract-sizer/-/hardhat-contract-sizer-2.5.0.tgz#ae0ef708efbc433a129f655827478741cba84606" + integrity sha512-579Bm3QjrGyInL4RuPFPV/2jLDekw+fGmeLQ85GeiBciIKPHVS3ZYuZJDrp7E9J6A4Czk+QVCRA9YPT2Svn7lQ== + dependencies: + chalk "^4.0.0" + cli-table3 "^0.6.0" + hardhat-watcher@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/hardhat-watcher/-/hardhat-watcher-2.1.1.tgz#8b05fec429ed45da11808bbf6054a90f3e34c51a"