diff --git a/contracts/extensions/DeltaNeutralBasisTradingStrategyExtension.sol b/contracts/extensions/DeltaNeutralBasisTradingStrategyExtension.sol new file mode 100644 index 0000000..82650c4 --- /dev/null +++ b/contracts/extensions/DeltaNeutralBasisTradingStrategyExtension.sol @@ -0,0 +1,1554 @@ +/* + 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 { Address } from "@openzeppelin/contracts/utils/Address.sol"; +import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { Math } from "@openzeppelin/contracts/math/Math.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 { BytesLib } from "@setprotocol/set-protocol-v2/external/contracts/uniswap/v3/lib/BytesLib.sol"; +import { BytesArrayUtils } from "@setprotocol/set-protocol-v2/contracts/lib/BytesArrayUtils.sol"; +import { IAccountBalance } from "@setprotocol/set-protocol-v2/contracts/interfaces/external/perp-v2/IAccountBalance.sol"; +import { IPerpV2BasisTradingModule } from "@setprotocol/set-protocol-v2/contracts/interfaces/IPerpV2BasisTradingModule.sol"; +import { IPerpV2LeverageModuleV2 } from "@setprotocol/set-protocol-v2/contracts/interfaces/IPerpV2LeverageModuleV2.sol"; +import { ISetToken } from "@setprotocol/set-protocol-v2/contracts/interfaces/ISetToken.sol"; +import { ITradeModule } from "@setprotocol/set-protocol-v2/contracts/interfaces/ITradeModule.sol"; +import { IVault } from "@setprotocol/set-protocol-v2/contracts/interfaces/external/perp-v2/IVault.sol"; +import { PreciseUnitMath } from "@setprotocol/set-protocol-v2/contracts/lib/PreciseUnitMath.sol"; +import { StringArrayUtils } from "@setprotocol/set-protocol-v2/contracts/lib/StringArrayUtils.sol"; +import { UnitConversionUtils } from "@setprotocol/set-protocol-v2/contracts/lib/UnitConversionUtils.sol"; + +import { BaseExtension } from "../lib/BaseExtension.sol"; +import { IBaseManager } from "../interfaces/IBaseManager.sol"; +import { IPriceFeed } from "../interfaces/IPriceFeed.sol"; +import { IUniswapV3Quoter } from "../interfaces/IUniswapV3Quoter.sol"; + +/** + * @title DeltaNeutralBasisTradingStrategyExtension + * @author Set Protocol + * + * Strategy smart contract that transforms a SetToken into an on-chain delta neutral basis trading token that earns yield by shorting assets + * on PerpV2 and collecting funding, while maintaing delta neutral exposure to the short asset by holding an equal amount of spot asset. + * This extension is paired with the PerpV2BasisTradingModule from Set Protocol where module interactions are invoked via the IBaseManager + * contract. Any basis trading token can be constructed as long as the market is listed on Perp V2 and there is enough liquidity for the + * corresponding spot asset on UniswapV3. This extension contract also allows the operator to set an ETH reward to incentivize keepers + * calling the rebalance function at different leverage thresholds. + */ +contract DeltaNeutralBasisTradingStrategyExtension is BaseExtension { + using Address for address; + using PreciseUnitMath for uint256; + using PreciseUnitMath for int256; + using SafeMath for uint256; + using SignedSafeMath for int256; + using SafeCast for int256; + using SafeCast for uint256; + using StringArrayUtils for string[]; + using BytesLib for bytes; + using BytesArrayUtils for bytes; + using UnitConversionUtils for int256; + using UnitConversionUtils for uint256; + + /* ============ Enums ============ */ + + enum ShouldRebalance { + NONE, // Indicates no rebalance action can be taken + REBALANCE, // Indicates rebalance() function can be successfully called + ITERATE_REBALANCE, // Indicates iterateRebalance() function can be successfully called + RIPCORD, // Indicates ripcord() function can be successfully called + REINVEST // Indicates reinvest() function can be successfully called + } + + /* ============ Structs ============ */ + + struct ActionInfo { + int256 baseBalance; // Balance of virtual base asset from Perp in precise units (10e18). E.g. vWBTC = 10e18 + int256 quoteBalance; // Balance of virtual quote asset from Perp in precise units (10e18). E.g. vUSD = 10e18 + IPerpV2BasisTradingModule.AccountInfo accountInfo; // Info on perpetual account including, collateral balance, owedRealizedPnl and pendingFunding + int256 basePositionValue; // Valuation in USD adjusted for decimals in precise units (10e18) + int256 quoteValue; // Valuation in USD adjusted for decimals in precise units (10e18) + int256 basePrice; // Price of base asset in precise units (10e18) from PerpV2 Oracle + int256 quotePrice; // Price of quote asset in precise units (10e18) from PerpV2 Oracle + uint256 setTotalSupply; // Total supply of SetToken + } + + struct LeverageInfo { + ActionInfo action; + int256 currentLeverageRatio; // Current leverage ratio of Set. For short tokens, this will be negative + uint256 slippageTolerance; // Allowable percent trade slippage in preciseUnits (1% = 10^16) + uint256 twapMaxTradeSize; // Max trade size in base asset units allowed for rebalance action + } + + struct ContractSettings { + ISetToken setToken; // Instance of leverage token + IPerpV2BasisTradingModule basisTradingModule; // Instance of PerpV2 basis trading module + ITradeModule tradeModule; // Instance of the trade module + IUniswapV3Quoter quoter; // Instance of UniswapV3 Quoter + IAccountBalance perpV2AccountBalance; // Instance of PerpV2 AccountBalance contract used to fetch position balances + IPriceFeed baseUSDPriceOracle; // PerpV2 oracle that returns TWAP price for base asset in USD. IPriceFeed is a PerpV2 specific interface + // to interact with differnt oracle providers, e.g. Band Protocol and Chainlink, for different assets + // listed on PerpV2. + uint256 twapInterval; // TWAP interval to be used to fetch base asset price in seconds + // PerpV2 uses a 15 min TWAP interval, i.e. twapInterval = 900 + uint256 basePriceDecimalAdjustment; // Decimal adjustment for the price returned by the PerpV2 oracle for the base asset. + // Equal to vBaseAsset.decimals() - baseUSDPriceOracle.decimals() + address virtualBaseAddress; // Address of virtual base asset (e.g. vETH, vWBTC etc) + address virtualQuoteAddress; // Address of virtual USDC quote asset. The Perp V2 system uses USDC for all markets + address spotAssetAddress; // Address of spot asset corresponding to virtual base asset (e.g. if base asset is vETH, spot asset would be WETH) + } + + struct MethodologySettings { + int256 targetLeverageRatio; // Long term target ratio in precise units (10e18) for the Perpetual position. + // Should be negative as strategy is shorting the perp. E.g. -1 for ETH -1x. + int256 minLeverageRatio; // If magnitude of current leverage is lower, rebalance target is this ratio. In precise units (10e18). + // Should be negative as strategy is shorting the perp. E.g. -0.7e18 for ETH -1x. + int256 maxLeverageRatio; // If magniutde of current leverage is higher, rebalance target is this ratio. In precise units (10e18). + // Should be negative as strategy is shorting the perp. E.g. -1.3e18 for ETH -1x. + uint256 recenteringSpeed; // % at which to rebalance back to target leverage in precise units (10e18). Always a positive number + uint256 rebalanceInterval; // Period of time required since last rebalance timestamp in seconds + uint256 reinvestInterval; // Period of time required since last reinvestment timestamp in seconds + } + + struct ExecutionSettings { + uint256 slippageTolerance; // % in precise units to price min token receive amount from trade quantities + // NOTE: Applies to both perpetual and dex trades. + uint256 twapCooldownPeriod; // Cooldown period required since last trade timestamp in seconds + // NOTE: Applies to both perpetual and dex trades. + } + + struct ExchangeSettings { + string exchangeName; // Name of the exchange adapter to be used for dex trade. This contract only supports UnisawpV3 trades. + // So should be "UniswapV3ExchangeAdapterV2". + bytes buyExactSpotTradeData; // Bytes containing path and fixIn boolean which will be passed to TradeModule#trade to buy exact amount of spot asset. + // Can be generated using UniswapV3ExchangeAdapterV2#generateDataParam + bytes sellExactSpotTradeData; // Bytes containing path and fixIn boolean which will be passed to TradeModule#trade to sell exact amount of spot asset + // Can be generated using UniswapV3ExchangeAdapterV2#generateDataParam + bytes buySpotQuoteExactInputPath; // Bytes containing UniswapV3 path to buy spot asset using exact amount of input asset (USDC). Will be passed to Quoter#getExactInput. + uint256 twapMaxTradeSize; // Max trade size in base assset base units. Always a positive number + // NOTE: Applies to both perpetual and dex trades. + uint256 incentivizedTwapMaxTradeSize; // Max trade size for incentivized rebalances in base asset units. Always a positive number + // NOTE: Applies to both perpetual and dex trades. + } + + struct IncentiveSettings { + uint256 etherReward; // ETH reward for incentivized rebalances + int256 incentivizedLeverageRatio; // Leverage ratio for incentivized rebalances. Is a negative number lower than maxLeverageRatio. + // E.g. -2x for ETH -1x. + uint256 incentivizedSlippageTolerance; // Slippage tolerance percentage for incentivized rebalances + // NOTE: Applies to both perpetual and dex trades. + uint256 incentivizedTwapCooldownPeriod; // TWAP cooldown in seconds for incentivized rebalances + // NOTE: Applies to both perpetual and dex trades. + } + + /* ============ Events ============ */ + + // Below events emit `_chunkPerpRebalanceNotional` and `_totalPerpRebalanceNotional`. Spot rebalance notional amounts can be calculated by + // _chunkSpotRebalanceNotional = _chunkPerpRebalanceNotional * -1 and _totalSpotRebalanceNotional = _totalPerpRebalanceNotional * -1 + event Engaged( + int256 _currentLeverageRatio, + int256 _newLeverageRatio, + int256 _chunkPerpRebalanceNotional, + int256 _totalPerpRebalanceNotional + ); + event Rebalanced( + int256 _currentLeverageRatio, + int256 _newLeverageRatio, + int256 _chunkPerpRebalanceNotional, + int256 _totalPerpRebalanceNotional + ); + event RebalanceIterated( + int256 _currentLeverageRatio, + int256 _newTwapLeverageRatio, + int256 _chunkPerpRebalanceNotional, + int256 _totalPerpRebalanceNotional + ); + event RipcordCalled( + int256 _currentLeverageRatio, + int256 _newLeverageRatio, + int256 _perpRebalanceNotional, + uint256 _etherIncentive + ); + event Disengaged( + int256 _currentLeverageRatio, + int256 _newLeverageRatio, + int256 _chunkPerpRebalanceNotional, + int256 _totalPerpRebalanceNotional + ); + event Reinvested( + uint256 _usdcReinvestedNotional, + uint256 _spotRebalanceNotional + ); + event MethodologySettingsUpdated( + int256 _targetLeverageRatio, + int256 _minLeverageRatio, + int256 _maxLeverageRatio, + uint256 _recenteringSpeed, + uint256 _rebalanceInterval, + uint256 _reinvestInterval + ); + event ExecutionSettingsUpdated( + uint256 _twapCooldownPeriod, + uint256 _slippageTolerance + ); + event ExchangeSettingsUpdated( + string _exchangeName, + bytes _buyExactSpotTradeData, + bytes _sellExactSpotTradeData, + bytes _buySpotQuoteExactInputPath, + uint256 _twapMaxTradeSize, + uint256 _incentivizedTwapMaxTradeSize + ); + event IncentiveSettingsUpdated( + uint256 _etherReward, + int256 _incentivizedLeverageRatio, + uint256 _incentivizedSlippageTolerance, + uint256 _incentivizedTwapCooldownPeriod + ); + + /* ============ Modifiers ============ */ + + /** + * Throws if rebalance is currently in TWAP + */ + modifier noRebalanceInProgress() { + require(twapLeverageRatio == 0, "Rebalance is currently in progress"); + _; + } + + /* ============ State Variables ============ */ + + ContractSettings internal strategy; // Struct of contracts used in the strategy (SetToken, price oracles, leverage module etc) + MethodologySettings internal methodology; // Struct containing methodology parameters + ExecutionSettings internal execution; // Struct containing execution parameters + ExchangeSettings internal exchange; // Struct containing exchange settings + IncentiveSettings internal incentive; // Struct containing incentive parameters for ripcord + + IERC20 internal collateralToken; // Collateral token to be deposited to PerpV2. We set this in the constructor for reading later. + uint8 internal collateralDecimals; // Decimals of collateral token. We set this in the constructor for reading later. + + int256 public twapLeverageRatio; // Stored leverage ratio to keep track of target between TWAP rebalances + uint256 public lastTradeTimestamp; // Last rebalance timestamp. Current timestamp must be greater than this variable + rebalance + // interval to rebalance + uint256 public lastReinvestTimestamp; // Last reinvest timestamp. Current timestamp must be greater than this variable + reinvest + // interval to reinvest + + /* ============ Constructor ============ */ + + /** + * Instantiate addresses, methodology parameters, execution parameters, exchange parameters and incentive parameters. + * + * @param _manager Address of IBaseManager contract + * @param _strategy Struct of contract addresses + * @param _methodology Struct containing methodology parameters + * @param _execution Struct containing execution parameters + * @param _incentive Struct containing incentive parameters for ripcord + * @param _exchange Struct containing exchange parameters + */ + constructor( + IBaseManager _manager, + ContractSettings memory _strategy, + MethodologySettings memory _methodology, + ExecutionSettings memory _execution, + IncentiveSettings memory _incentive, + ExchangeSettings memory _exchange + ) + public + BaseExtension(_manager) + { + strategy = _strategy; + methodology = _methodology; + execution = _execution; + incentive = _incentive; + exchange = _exchange; + + collateralToken = strategy.basisTradingModule.collateralToken(); + collateralDecimals = ERC20(address(collateralToken)).decimals(); + + _validateExchangeSettings(_exchange); + _validateNonExchangeSettings(methodology, execution, incentive); + + // Set reinvest interval, so that first reinvestment takes place one reinvestment interval after deployment + lastReinvestTimestamp = block.timestamp; + } + + /* ============ External Functions ============ */ + + /** + * OEPRATOR ONLY: Deposits specified units of current USDC tokens not already being used as collateral into Perpetual Protocol. + * + * @param _collateralUnits Collateral to deposit in position units + */ + function deposit(uint256 _collateralUnits) external onlyOperator { + _deposit(_collateralUnits); + } + + /** + * OPERATOR ONLY: Withdraws specified units of USDC tokens from Perpetual Protocol and adds it as default position on the SetToken. + * + * @param _collateralUnits Collateral to withdraw in position units + */ + function withdraw(uint256 _collateralUnits) external onlyOperator { + _withdraw(_collateralUnits); + } + + /** + * OPERATOR ONLY: Engage to enter delta neutral position for the first time. SetToken will use 50% of the collateral token to acquire spot asset on Uniswapv3, and deposit + * rest of the collateral token to PerpV2 to open a new short base token position on PerpV2 such that net exposure to the spot assetis zero. If total rebalance notional + * is above max trade size, then TWAP is kicked off. + * To complete engage if TWAP, any valid caller must call iterateRebalance until target is met. + * Note: Unlike PerpV2LeverageStrategyExtension, `deposit()` should NOT be called before `engage()`. + */ + function engage() external onlyOperator { + LeverageInfo memory leverageInfo = _getAndValidateEngageInfo(); + + // Calculate total rebalance units and kick off TWAP if above max trade size + ( + int256 chunkRebalanceNotional, + int256 totalRebalanceNotional + ) = _calculateEngageRebalanceSize(leverageInfo, methodology.targetLeverageRatio); + + _executeEngageTrades(leverageInfo, chunkRebalanceNotional); + + _updateRebalanceState( + chunkRebalanceNotional, + totalRebalanceNotional, + methodology.targetLeverageRatio + ); + + emit Engaged( + leverageInfo.currentLeverageRatio, + methodology.targetLeverageRatio, + chunkRebalanceNotional, + totalRebalanceNotional + ); + } + + /** + * ONLY EOA AND ALLOWED CALLER: Rebalance product. If |min leverage ratio| < |current leverage ratio| < |max leverage ratio|, then rebalance + * can only be called once the rebalance interval has elapsed since last timestamp. If outside the max and min but below incentivized leverage ratio, + * rebalance can be called anytime to bring leverage ratio back to the max or min bounds. The methodology will determine whether to delever or lever. + * If levering up, SetToken increases the short position on PerpV2, withdraws collateral asset from PerpV2 and uses it to acquire more spot asset to keep + * the position delta-neutral. If delevering, SetToken decreases the short position on PerpV2, sells spot asset on UniswapV3 and deposits the returned + * collateral token to PerpV2 to collateralize the PerpV2 position. + * + * Note: If the calculated current leverage ratio is above the incentivized leverage ratio or in TWAP then rebalance cannot be called. Instead, you must call + * ripcord() which is incentivized with a reward in Ether or iterateRebalance(). + */ + function rebalance() external onlyEOA onlyAllowedCaller(msg.sender) { + LeverageInfo memory leverageInfo = _getAndValidateLeveragedInfo( + execution.slippageTolerance, + exchange.twapMaxTradeSize + ); + + _validateNormalRebalance(leverageInfo, methodology.rebalanceInterval, lastTradeTimestamp); + _validateNonTWAP(); + + int256 newLeverageRatio = _calculateNewLeverageRatio(leverageInfo.currentLeverageRatio); + + ( + int256 chunkRebalanceNotional, + int256 totalRebalanceNotional + ) = _handleRebalance(leverageInfo, newLeverageRatio); + + _updateRebalanceState(chunkRebalanceNotional, totalRebalanceNotional, newLeverageRatio); + + emit Rebalanced( + leverageInfo.currentLeverageRatio, + newLeverageRatio, + chunkRebalanceNotional, + totalRebalanceNotional + ); + } + + /** + * ONLY EOA AND ALLOWED CALLER: Iterate a rebalance when in TWAP. TWAP cooldown period must have elapsed. If price moves advantageously, then + * exit without rebalancing and clear TWAP state. This function can only be called when |current leverage ratio| < |incentivized leverage ratio| + * and in TWAP state. + */ + function iterateRebalance() external onlyEOA onlyAllowedCaller(msg.sender) { + LeverageInfo memory leverageInfo = _getAndValidateLeveragedInfo( + execution.slippageTolerance, + exchange.twapMaxTradeSize + ); + + _validateNormalRebalance(leverageInfo, execution.twapCooldownPeriod, lastTradeTimestamp); + _validateTWAP(); + + int256 chunkRebalanceNotional; + int256 totalRebalanceNotional; + if (!_isAdvantageousTWAP(leverageInfo.currentLeverageRatio)) { + (chunkRebalanceNotional, totalRebalanceNotional) = _handleRebalance(leverageInfo, twapLeverageRatio); + } + + // If not advantageous, then rebalance is skipped and chunk and total rebalance notional are both 0, which means TWAP state is cleared + _updateIterateState(chunkRebalanceNotional, totalRebalanceNotional); + + emit RebalanceIterated( + leverageInfo.currentLeverageRatio, + twapLeverageRatio, + chunkRebalanceNotional, + totalRebalanceNotional + ); + } + + /** + * ONLY EOA: In case |current leverage ratio| > |incentivized leverage ratio|, the ripcord function can be called by anyone to return leverage ratio + * back to the max leverage ratio. This function typically would only be called during times of high downside/upside volatility and / or normal keeper malfunctions. The + * caller of ripcord() will receive a reward in Ether. The ripcord function uses it's own TWAP cooldown period, slippage tolerance and TWAP max trade size which are + * typically looser than in regular rebalances. If chunk rebalance size is above max incentivized trade size, then caller must continue to call this function to pull + * the leverage ratio under the incentivized leverage ratio. Incentivized TWAP cooldown period must have elapsed. The function iterateRebalance will not work. + */ + function ripcord() external onlyEOA { + LeverageInfo memory leverageInfo = _getAndValidateLeveragedInfo( + incentive.incentivizedSlippageTolerance, + exchange.incentivizedTwapMaxTradeSize + ); + + _validateRipcord(leverageInfo, lastTradeTimestamp); + + ( int256 chunkRebalanceNotional, ) = _calculateChunkRebalanceNotional(leverageInfo, methodology.maxLeverageRatio); + + _executeRebalanceTrades(leverageInfo, chunkRebalanceNotional); + + _updateRipcordState(); + + uint256 etherTransferred = _transferEtherRewardToCaller(incentive.etherReward); + + emit RipcordCalled( + leverageInfo.currentLeverageRatio, + methodology.maxLeverageRatio, + chunkRebalanceNotional, + etherTransferred + ); + } + + /** + * OPERATOR ONLY: Close open baseToken position on Perpetual Protocol and sell spot assets. TWAP cooldown period must have elapsed. This can be used for upgrading or shutting down the strategy. + * SetToken will sell all virtual base token positions into virtual USDC and all spot asset to the collateral token (USDC). It deposits the recieved USDC to PerpV2 to collateralize the Perpetual + * position. If the chunk rebalance size is less than the total notional size, then this function will trade out of base and spot token position in one go. If chunk rebalance size is above max + * trade size, then operator must continue to call this function to completely unwind position. The function iterateRebalance will not work. + * + * Note: If rebalancing is open to anyone disengage TWAP can be counter traded by a griefing party calling rebalance. Set anyoneCallable to false before disengage to prevent such attacks. + */ + function disengage() external onlyOperator { + LeverageInfo memory leverageInfo = _getAndValidateLeveragedInfo( + execution.slippageTolerance, + exchange.twapMaxTradeSize + ); + + _validateDisengage(lastTradeTimestamp); + + // Reduce leverage to 0 + int256 newLeverageRatio = 0; + + ( + int256 chunkRebalanceNotional, + int256 totalRebalanceNotional + ) = _calculateChunkRebalanceNotional(leverageInfo, newLeverageRatio); + + _executeRebalanceTrades(leverageInfo, chunkRebalanceNotional); + + _updateDisengageState(); + + emit Disengaged( + leverageInfo.currentLeverageRatio, + newLeverageRatio, + chunkRebalanceNotional, + totalRebalanceNotional + ); + } + + /** + * ONLY EOA AND ALLOWED CALLER: Reinvests tracked settled funding to increase position. SetToken withdraws funding as collateral token using + * PerpV2BasisTradingModule. It uses the collateral token to acquire more spot asset and deposit the rest to PerpV2 to increase short perp position. + * It can only be called once the reinvest interval has elapsed since last reinvest timestamp. TWAP is not supported because reinvestment amounts + * would be generally small. + * + * NOTE: Rebalance is prioritized over reinvestment. This function can not be called when leverage ratio is out of bounds. Call `rebalance()` instead. + */ + function reinvest() external onlyEOA onlyAllowedCaller(msg.sender) { + // Uses the same slippage tolerance and twap max trade size as rebalancing + LeverageInfo memory leverageInfo = _getAndValidateLeveragedInfo( + execution.slippageTolerance, + exchange.twapMaxTradeSize + ); + + _validateReinvest(leverageInfo); + + _withdrawFunding(PreciseUnitMath.MAX_UINT_256); // Pass MAX_UINT_256 to withdraw all funding. + + ( + uint256 totalReinvestNotional, + uint256 spotReinvestNotional, + uint256 spotBuyNotional + ) = _calculateReinvestNotional(leverageInfo); + + require(totalReinvestNotional > 0, "Zero accrued funding"); + + _executeReinvestTrades(leverageInfo, spotReinvestNotional, spotBuyNotional); + + _updateReinvestState(); + + emit Reinvested(totalReinvestNotional, spotBuyNotional); + } + + /** + * OPERATOR ONLY: Set methodology settings and check new settings are valid. Note: Need to pass in existing parameters if only changing a few settings. + * Must not be in a rebalance. + * + * @param _newMethodologySettings Struct containing methodology parameters + */ + function setMethodologySettings(MethodologySettings memory _newMethodologySettings) external onlyOperator noRebalanceInProgress { + methodology = _newMethodologySettings; + + _validateNonExchangeSettings(methodology, execution, incentive); + + emit MethodologySettingsUpdated( + methodology.targetLeverageRatio, + methodology.minLeverageRatio, + methodology.maxLeverageRatio, + methodology.recenteringSpeed, + methodology.rebalanceInterval, + methodology.reinvestInterval + ); + } + + /** + * OPERATOR ONLY: Set execution settings and check new settings are valid. Note: Need to pass in existing parameters if only changing a few settings. + * Must not be in a rebalance. + * + * @param _newExecutionSettings Struct containing execution parameters + */ + function setExecutionSettings(ExecutionSettings memory _newExecutionSettings) external onlyOperator noRebalanceInProgress { + execution = _newExecutionSettings; + + _validateNonExchangeSettings(methodology, execution, incentive); + + emit ExecutionSettingsUpdated( + execution.twapCooldownPeriod, + execution.slippageTolerance + ); + } + + /** + * OPERATOR ONLY: Set incentive settings and check new settings are valid. Note: Need to pass in existing parameters if only changing a few settings. + * Must not be in a rebalance. + * + * @param _newIncentiveSettings Struct containing incentive parameters + */ + function setIncentiveSettings(IncentiveSettings memory _newIncentiveSettings) external onlyOperator noRebalanceInProgress { + incentive = _newIncentiveSettings; + + _validateNonExchangeSettings(methodology, execution, incentive); + + emit IncentiveSettingsUpdated( + incentive.etherReward, + incentive.incentivizedLeverageRatio, + incentive.incentivizedSlippageTolerance, + incentive.incentivizedTwapCooldownPeriod + ); + } + + /** + * OPERATOR ONLY: Set exchange settings and check new settings are valid. Updating exchange settings during rebalances is allowed, as it is not possible + * to enter an unexpected state while doing so. Note: Need to pass in existing parameters if only changing a few settings. + * + * @param _newExchangeSettings Struct containing exchange parameters + */ + function setExchangeSettings(ExchangeSettings memory _newExchangeSettings) + external + onlyOperator + { + _validateExchangeSettings(_newExchangeSettings); + + exchange = _newExchangeSettings; + + emit ExchangeSettingsUpdated( + exchange.exchangeName, + exchange.buyExactSpotTradeData, + exchange.sellExactSpotTradeData, + exchange.buySpotQuoteExactInputPath, + exchange.twapMaxTradeSize, + exchange.incentivizedTwapMaxTradeSize + ); + } + + /** + * OPERATOR ONLY: Withdraw entire balance of ETH in this contract to operator. Rebalance must not be in progress + */ + function withdrawEtherBalance() external onlyOperator noRebalanceInProgress { + msg.sender.transfer(address(this).balance); + } + + receive() external payable {} + + /* ============ External Getter Functions ============ */ + + /** + * Get current leverage ratio. Current leverage ratio is defined as the sum of USD values of all SetToken open positions on Perp V2 divided by its + * account value on PerpV2. Prices for base and quote asset are retrieved from the Chainlink Price Oracle. + * + * return currentLeverageRatio Current leverage ratio in precise units (10e18) + */ + function getCurrentLeverageRatio() public view returns(int256) { + ActionInfo memory currentLeverageInfo = _createActionInfo(); + + return _calculateCurrentLeverageRatio(currentLeverageInfo); + } + + /** + * Calculates the chunk rebalance size. This can be used by external contracts and keeper bots to track rebalances and fetch assets to be bought and sold. + * Note: This function does not take into account timestamps, so it may return a nonzero value even when shouldRebalance would return ShouldRebalance.NONE + * (since minimum delays have not elapsed). + * + * @return size Total notional chunk size. Measured in the asset that would be sold. + * @return sellAssetOnPerp Asset that would be sold during a rebalance on Perpetual protocol + * @return buyAssetOnPerp Asset that would be purchased during a rebalance on Perpetual protocol + * @return sellAssetOnDex Asset that would be sold during a rebalance on decentralized exchange + * @return buyAssetOnDex Asset that would be purchased during a rebalance on decentralized exchange + */ + function getChunkRebalanceNotional() + external + view + returns(int256 size, address sellAssetOnPerp, address buyAssetOnPerp, address sellAssetOnDex, address buyAssetOnDex) + { + + int256 newLeverageRatio; + int256 currentLeverageRatio = getCurrentLeverageRatio(); + bool isRipcord = false; + + // if over incentivized leverage ratio, always ripcord + if (currentLeverageRatio.abs() > incentive.incentivizedLeverageRatio.abs()) { + newLeverageRatio = methodology.maxLeverageRatio; + isRipcord = true; + // if we are in an ongoing twap, use the cached twapLeverageRatio as our target leverage + } else if (twapLeverageRatio != 0) { + newLeverageRatio = twapLeverageRatio; + // if all else is false, then we would just use the normal rebalance new leverage ratio calculation + } else { + newLeverageRatio = _calculateNewLeverageRatio(currentLeverageRatio); + } + + ActionInfo memory actionInfo = _createActionInfo(); + + LeverageInfo memory leverageInfo = LeverageInfo({ + action: actionInfo, + currentLeverageRatio: currentLeverageRatio, + slippageTolerance: isRipcord ? + incentive.incentivizedSlippageTolerance + : execution.slippageTolerance, + twapMaxTradeSize: isRipcord ? + exchange.incentivizedTwapMaxTradeSize + : exchange.twapMaxTradeSize + }); + + (size, ) = _calculateChunkRebalanceNotional(leverageInfo, newLeverageRatio); + + bool increaseLeverage = newLeverageRatio.abs() > currentLeverageRatio.abs(); + + /* + ------------------------------------------------------------------------------------------------------------------------ + | New LR | increaseLeverage | sellAssetOnPerp | buyAssetOnPerp | sellAssetOnDex | buyAssetOnDex | + ------------------------------------------------------------------------------------------------------------------------ + | = 0 (not possible) | x | x | x | x | x | + | > 0 (not possible) | x | x | x | x | x | + | < 0 (short) | true | base (vETH) | quote (vUSD) | collateral (USDC) | spot (WETH) | + | < 0 (short) | false | quote (vUSD) | base (vETH) | spot (WETH) | collateral (USDC) | + ------------------------------------------------------------------------------------------------------------------------ + */ + + sellAssetOnPerp = increaseLeverage ? strategy.virtualBaseAddress : strategy.virtualQuoteAddress; + buyAssetOnPerp = increaseLeverage ? strategy.virtualQuoteAddress : strategy.virtualBaseAddress; + sellAssetOnDex = increaseLeverage ? address(collateralToken): strategy.spotAssetAddress; + buyAssetOnDex = increaseLeverage ? strategy.spotAssetAddress : address(collateralToken); + } + + /** + * Get current Ether incentive for when current leverage ratio exceeds incentivized leverage ratio and ripcord can be called. If ETH balance on the contract + * is below the etherReward, then return the balance of ETH instead. + * + * return etherReward Quantity of ETH reward in base units (10e18) + */ + function getCurrentEtherIncentive() external view returns(uint256) { + int256 currentLeverageRatio = getCurrentLeverageRatio(); + + if (currentLeverageRatio.abs() >= incentive.incentivizedLeverageRatio.abs()) { + // If ETH reward is below the balance on this contract, then return ETH balance on contract instead + return incentive.etherReward < address(this).balance ? incentive.etherReward : address(this).balance; + } else { + return 0; + } + } + + /** + * Helper that checks if conditions are met for rebalance or ripcord. Returns an enum with 0 = no rebalance, 1 = call rebalance(), 2 = call iterateRebalance() + * 3 = call ripcord() and 4 = call reinvest() + * + * @return ShouldRebalance Enum representing whether should rebalance + */ + function shouldRebalance() external view returns(ShouldRebalance) { + int256 currentLeverageRatio = getCurrentLeverageRatio(); + + return _shouldRebalance(currentLeverageRatio, methodology.minLeverageRatio, methodology.maxLeverageRatio); + } + + /** + * Helper that checks if conditions are met for rebalance or ripcord with custom max and min bounds specified by caller. This function simplifies the + * logic for off-chain keeper bots to determine what threshold to call rebalance when leverage exceeds max or drops below min. Returns an enum with + * 0 = no rebalance, 1 = call rebalance(), 2 = call iterateRebalance(), 3 = call ripcord() and 4 = call reinvest() + * + * @param _customMinLeverageRatio Min leverage ratio passed in by caller + * @param _customMaxLeverageRatio Max leverage ratio passed in by caller + * + * @return ShouldRebalance Enum representing whether should rebalance + */ + function shouldRebalanceWithBounds( + int256 _customMinLeverageRatio, + int256 _customMaxLeverageRatio + ) + external + view + returns(ShouldRebalance) + { + require ( + _customMinLeverageRatio.abs() <= methodology.minLeverageRatio.abs() + && _customMaxLeverageRatio.abs() >= methodology.maxLeverageRatio.abs(), + "Custom bounds must be valid" + ); + + int256 currentLeverageRatio = getCurrentLeverageRatio(); + + return _shouldRebalance(currentLeverageRatio, _customMinLeverageRatio, _customMaxLeverageRatio); + } + + /** + * Explicit getter functions for parameter structs are defined as workaround to issues fetching structs that have dynamic types. + */ + function getStrategy() external view returns (ContractSettings memory) { return strategy; } + function getMethodology() external view returns (MethodologySettings memory) { return methodology; } + function getExecution() external view returns (ExecutionSettings memory) { return execution; } + function getIncentive() external view returns (IncentiveSettings memory) { return incentive; } + function getExchangeSettings() external view returns (ExchangeSettings memory) { return exchange; } + + + /* ============ Internal Functions ============ */ + + /** + * Deposits specified units of current USDC tokens not already being used as collateral into Perpetual Protocol. + * + * @param _collateralUnits Collateral to deposit in position units + */ + function _deposit(uint256 _collateralUnits) internal { + bytes memory depositCalldata = abi.encodeWithSelector( + IPerpV2LeverageModuleV2.deposit.selector, + address(strategy.setToken), + _collateralUnits + ); + + invokeManager(address(strategy.basisTradingModule), depositCalldata); + } + + /** + * Deposits all current USDC tokens held in the Set to Perpetual Protocol. + */ + function _depositAll() internal { + uint256 defaultUsdcUnits = strategy.setToken.getDefaultPositionRealUnit(address(collateralToken)).toUint256(); + _deposit(defaultUsdcUnits); + } + + /** + * Withdraws specified units of USDC tokens from Perpetual Protocol and adds it as default position on the SetToken. + * + * @param _collateralUnits Collateral to withdraw in position units + */ + function _withdraw(uint256 _collateralUnits) internal { + bytes memory withdrawCalldata = abi.encodeWithSelector( + IPerpV2LeverageModuleV2.withdraw.selector, + address(strategy.setToken), + _collateralUnits + ); + + invokeManager(address(strategy.basisTradingModule), withdrawCalldata); + } + + /** + * Calculates chunk rebalance notional and calls `_executeEngageTrades` to open required positions. Used in the rebalance() and iterateRebalance() functions + * + * return uint256 Calculated notional to trade + * return uint256 Total notional to rebalance over TWAP + */ + function _handleRebalance(LeverageInfo memory _leverageInfo, int256 _newLeverageRatio) internal returns(int256, int256) { + ( + int256 chunkRebalanceNotional, + int256 totalRebalanceNotional + ) = _calculateChunkRebalanceNotional(_leverageInfo, _newLeverageRatio); + + _executeRebalanceTrades(_leverageInfo, chunkRebalanceNotional); + + return (chunkRebalanceNotional, totalRebalanceNotional); + } + + /** + * Calculate base rebalance units and opposite bound units. Invoke trade on TradeModule to acquire spot asset. Deposit rest of the collateral token to PerpV2 + * and invoke trade on PerpV2BasisTradingModule to open short perp position. + */ + function _executeEngageTrades( + LeverageInfo memory _leverageInfo, + int256 _chunkRebalanceNotional + ) + internal + { + int256 baseRebalanceUnits = _chunkRebalanceNotional.preciseDiv(_leverageInfo.action.setTotalSupply.toInt256()); + + uint256 oppositeBoundUnits = _calculateOppositeBoundUnits( + baseRebalanceUnits.neg(), + _leverageInfo.action, + _leverageInfo.slippageTolerance + ).fromPreciseUnitToDecimals(collateralDecimals); + + _executeDexTrade(baseRebalanceUnits.abs(), oppositeBoundUnits, true); + + _depositAll(); + + _executePerpTrade(baseRebalanceUnits, _leverageInfo); + } + + /** + * Calculate base rebalance units and opposite bound units. Invoke trade on PerpV2BasisTradingModule to lever/delever perp short position. If levering, withdraw + * collateral token and invoke trade on TradeModule to acquire more spot assets. If delevering, invoke trade on TradeModule to sell spot assets and deposit the + * recieved collateral token to PerpV2. + */ + function _executeRebalanceTrades( + LeverageInfo memory _leverageInfo, + int256 _chunkRebalanceNotional + ) + internal + { + int256 baseRebalanceUnits = _chunkRebalanceNotional.preciseDiv(_leverageInfo.action.setTotalSupply.toInt256()); + uint256 oppositeBoundUnits = _calculateOppositeBoundUnits( + baseRebalanceUnits.neg(), + _leverageInfo.action, + _leverageInfo.slippageTolerance + ).fromPreciseUnitToDecimals(collateralDecimals); + + _executePerpTrade(baseRebalanceUnits, _leverageInfo); + + if (baseRebalanceUnits < 0) { + _withdraw(oppositeBoundUnits); + + _executeDexTrade(baseRebalanceUnits.abs(), oppositeBoundUnits, true); + } else { + _executeDexTrade(baseRebalanceUnits.abs(), oppositeBoundUnits, false); + } + + // Deposit unused USDC during lever; Deposit received USDC during delever + _depositAll(); + } + + /** + * Executes trades on PerpV2. + */ + function _executePerpTrade(int256 _baseRebalanceUnits, LeverageInfo memory _leverageInfo) internal { + uint256 oppositeBoundUnits = _calculateOppositeBoundUnits(_baseRebalanceUnits, _leverageInfo.action, _leverageInfo.slippageTolerance); + + bytes memory perpTradeCallData = abi.encodeWithSelector( + IPerpV2BasisTradingModule.tradeAndTrackFunding.selector, // tradeAndTrackFunding + address(strategy.setToken), + strategy.virtualBaseAddress, + _baseRebalanceUnits, + oppositeBoundUnits + ); + + invokeManager(address(strategy.basisTradingModule), perpTradeCallData); + } + + /** + * Executes trades on Dex. + * Note: Only supports Uniswap V3. + */ + function _executeDexTrade(uint256 _baseUnits, uint256 _usdcUnits, bool _buy) internal { + bytes memory dexTradeCallData = _buy + ? abi.encodeWithSelector( + ITradeModule.trade.selector, + address(strategy.setToken), + exchange.exchangeName, + address(collateralToken), + _usdcUnits, + address(strategy.spotAssetAddress), + _baseUnits, + exchange.buyExactSpotTradeData // buy exact amount + ) + : abi.encodeWithSelector( + ITradeModule.trade.selector, + address(strategy.setToken), + exchange.exchangeName, + address(strategy.spotAssetAddress), + _baseUnits, + address(collateralToken), + _usdcUnits, + exchange.sellExactSpotTradeData // sell exact amount + ); + + invokeManager(address(strategy.tradeModule), dexTradeCallData); + } + + /** + * Invokes PerpV2BasisTradingModule to withdraw funding to be reinvested. Pass MAX_UINT_256 to withdraw all funding. + */ + function _withdrawFunding(uint256 _fundingNotional) internal { + bytes memory withdrawCallData = abi.encodeWithSelector( + IPerpV2BasisTradingModule.withdrawFundingAndAccrueFees.selector, + strategy.setToken, + _fundingNotional + ); + + invokeManager(address(strategy.basisTradingModule), withdrawCallData); + } + + /** + * Reinvests default USDC position to spot asset and deposits the rest to PerpV2 to increase the short perp position. + * Used in the reinvest() function. + */ + function _executeReinvestTrades( + LeverageInfo memory _leverageInfo, + uint256 _spotReinvestNotional, + uint256 _spotBuyNotional + ) internal { + + uint256 setTotalSupply = strategy.setToken.totalSupply(); + uint256 baseUnits = _spotBuyNotional.preciseDiv(setTotalSupply); + uint256 spotReinvestUnits = _spotReinvestNotional.preciseDivCeil(setTotalSupply); + + // Increase spot position + _executeDexTrade(baseUnits, spotReinvestUnits, true); + + // Deposit rest + _depositAll(); + + // Increase perp position + _executePerpTrade(baseUnits.toInt256().neg(), _leverageInfo); + } + + /* ============ Calculation functions ============ */ + + /** + * Calculate the current leverage ratio. + * + * return int256 Current leverage ratio + */ + function _calculateCurrentLeverageRatio(ActionInfo memory _actionInfo) internal pure returns(int256) { + /* + Account Specs: + ------------- + collateral:= balance of USDC in vault + owedRealizedPnl:= realized PnL (in USD) that hasn't been settled + pendingFundingPayment := funding payment (in USD) that hasn't been settled + + settling collateral (on withdraw) + collateral <- collateral + owedRealizedPnL + owedRealizedPnL <- 0 + + settling funding (on every trade) + owedRealizedPnL <- owedRealizedPnL + pendingFundingPayment + pendingFundingPayment <- 0 + + Note: Collateral balance, owedRealizedPnl and pendingFundingPayments belong to the entire account and + NOT just the single market managed by this contract. So, while managing multiple positions across multiple + markets via multiple separate extension contracts, `totalCollateralValue` should be counted only once. + */ + int256 totalCollateralValue = _actionInfo.accountInfo.collateralBalance + .add(_actionInfo.accountInfo.owedRealizedPnl) + .add(_actionInfo.accountInfo.pendingFundingPayments); + + // Note: Both basePositionValue and quoteValue are values that belong to the single market managed by this contract. + int256 unrealizedPnl = _actionInfo.basePositionValue.add(_actionInfo.quoteValue); + + int256 accountValue = totalCollateralValue.add(unrealizedPnl); + + if (accountValue <= 0) { + return 0; + } + + // `accountValue` is always positive. Do not use absolute value of basePositionValue in the below equation, + // to keep the sign of CLR same as that of basePositionValue. + return _actionInfo.basePositionValue.preciseDiv(accountValue); + } + + /** + * Calculate the new leverage ratio. The methodology reduces the size of each rebalance by weighting + * the current leverage ratio against the target leverage ratio by the recentering speed percentage. The lower the recentering speed, the slower + * the leverage token will move towards the target leverage each rebalance. + * + * return int256 New leverage ratio + */ + function _calculateNewLeverageRatio(int256 _currentLeverageRatio) internal view returns(int256) { + // Convert int256 variables to uint256 prior to passing through methodology + uint256 currentLeverageRatioAbs = _currentLeverageRatio.abs(); + uint256 targetLeverageRatioAbs = methodology.targetLeverageRatio.abs(); + uint256 maxLeverageRatioAbs = methodology.maxLeverageRatio.abs(); + uint256 minLeverageRatioAbs = methodology.minLeverageRatio.abs(); + + // CLRt+1 = max(MINLR, min(MAXLR, CLRt * (1 - RS) + TLR * RS)) + // a: TLR * RS + // b: (1- RS) * CLRt + // c: (1- RS) * CLRt + TLR * RS + // d: min(MAXLR, CLRt * (1 - RS) + TLR * RS) + uint256 a = targetLeverageRatioAbs.preciseMul(methodology.recenteringSpeed); + uint256 b = PreciseUnitMath.preciseUnit().sub(methodology.recenteringSpeed).preciseMul(currentLeverageRatioAbs); + uint256 c = a.add(b); + uint256 d = Math.min(c, maxLeverageRatioAbs); + uint256 newLeverageRatio = Math.max(minLeverageRatioAbs, d); + + return _currentLeverageRatio > 0 ? newLeverageRatio.toInt256() : newLeverageRatio.toInt256().neg(); + } + + /** + * Calculate total notional rebalance quantity and chunked rebalance quantity in base asset units for engaging the SetToken. Used in engage(). + * Leverage ratio (for the base asset) is zero before engage. We open a new base asset position with size equals to + * (collateralBalance/2) * targetLeverageRatio / baseAssetPrice) to gain (targetLeverageRatio * collateralBalance/2) worth of exposure to the base asset. + * Note: We can't use `_calculateChunkRebalanceNotional` function because CLR is 0 during engage and it would lead to a divison by zero error. + * + * return int256 Chunked rebalance notional in base asset units + * return int256 Total rebalance notional in base asset units + */ + function _calculateEngageRebalanceSize( + LeverageInfo memory _leverageInfo, + int256 _targetLeverageRatio + ) + internal + view + returns (int256, int256) + { + // Let C be the total collateral available. Let c be the amount of collateral deposited to Perp to open perp position. + // Then we acquire, (C - c) worth of spot position. To maintain delta neutrality, we short same amount on PerpV2. + // So, TLR = (C - c) / c, or c = C / (1 + TLR) + int256 collateralAmount = collateralToken.balanceOf(address(strategy.setToken)) + .preciseDiv(PreciseUnitMath.preciseUnit().add(methodology.targetLeverageRatio.abs())) + .toPreciseUnitsFromDecimals(collateralDecimals) + .toInt256(); + int256 totalRebalanceNotional = collateralAmount.preciseMul(_targetLeverageRatio).preciseDiv(_leverageInfo.action.basePrice); + + uint256 chunkRebalanceNotionalAbs = Math.min(totalRebalanceNotional.abs(), _leverageInfo.twapMaxTradeSize); + + return ( + // Return int256 chunkRebalanceNotional + totalRebalanceNotional >= 0 ? chunkRebalanceNotionalAbs.toInt256() : chunkRebalanceNotionalAbs.toInt256().neg(), + totalRebalanceNotional + ); + } + + /** + * Calculate total notional funding to be reinvested (in USD), notional funding to be reinvested into spot position + * and notional amount of spot asset to be bought during reinvestment. + * + * return uint256 Total notional funding to be reinvested + * return uint256 Notional funding to be reinvested into spot position + * return uint256 Notional amount of spot asset to be bought during reinvestment + */ + function _calculateReinvestNotional(LeverageInfo memory _leverageInfo) internal returns (uint256, uint256, uint256) { + + uint256 defaultUsdcUnits = strategy.setToken.getDefaultPositionRealUnit(address(collateralToken)).toUint256(); + + if (defaultUsdcUnits == 0) { return (0, 0, 0); } + + uint256 setTotalSupply = strategy.setToken.totalSupply(); + uint256 totalReinvestNotional = defaultUsdcUnits.preciseMul(setTotalSupply); + + // Let C be the total collateral available. Let c be the amount of collateral deposited to Perp to open perp position. + // Then we acquire, (C - c) worth of spot position. To maintain delta neutrality, we short same amount on PerpV2. + // Also, we need to maintiain the same leverage ratio as CLR. + // So, CLR = (C - c) / c, or c = C / (1 + CLR). And amount used to acquire spot, (C - c) = C * CLR / (1 + CLR) + uint256 multiplicationFactor = _leverageInfo.currentLeverageRatio.abs() + .preciseDiv(PreciseUnitMath.preciseUnit().add(_leverageInfo.currentLeverageRatio.abs())); + + uint256 spotReinvestNotional = totalReinvestNotional.preciseMul(multiplicationFactor); + + uint256 spotBuyNotional = strategy.quoter.quoteExactInput(exchange.buySpotQuoteExactInputPath, spotReinvestNotional); + + return (totalReinvestNotional, spotReinvestNotional, spotBuyNotional); + } + + /** + * Calculate total notional rebalance quantity and chunked rebalance quantity in base asset units. + * + * return int256 Chunked rebalance notional in base asset units + * return int256 Total rebalance notional in base asset units + */ + function _calculateChunkRebalanceNotional( + LeverageInfo memory _leverageInfo, + int256 _newLeverageRatio + ) + internal + pure + returns (int256, int256) + { + // Calculate difference between new and current leverage ratio + int256 leverageRatioDifference = _newLeverageRatio.sub(_leverageInfo.currentLeverageRatio); + int256 denominator = _leverageInfo.currentLeverageRatio.preciseMul(PreciseUnitMath.preciseUnitInt().sub(_newLeverageRatio)); + int256 totalRebalanceNotional = leverageRatioDifference.preciseMul(_leverageInfo.action.baseBalance).preciseDiv(denominator); + + uint256 chunkRebalanceNotionalAbs = Math.min(totalRebalanceNotional.abs(), _leverageInfo.twapMaxTradeSize); + return ( + // Return int256 chunkRebalanceNotional + totalRebalanceNotional >= 0 ? chunkRebalanceNotionalAbs.toInt256() : chunkRebalanceNotionalAbs.toInt256().neg(), + totalRebalanceNotional + ); + } + + /** + * Derive the quote token units for slippage tolerance. The units are calculated by the base token units multiplied by base asset price divided by quote + * asset price. Output is measured to precise units (1e18). + * + * return int256 Position units to quote + */ + function _calculateOppositeBoundUnits( + int256 _baseRebalanceUnits, + ActionInfo memory _actionInfo, + uint256 _slippageTolerance + ) + internal pure returns (uint256) + { + uint256 oppositeBoundUnits; + if (_baseRebalanceUnits > 0) { + oppositeBoundUnits = _baseRebalanceUnits + .preciseMul(_actionInfo.basePrice) + .preciseDiv(_actionInfo.quotePrice) + .preciseMul(PreciseUnitMath.preciseUnit().add(_slippageTolerance).toInt256()).toUint256(); + } else { + oppositeBoundUnits = _baseRebalanceUnits + .neg() + .preciseMul(_actionInfo.basePrice) + .preciseDiv(_actionInfo.quotePrice) + .preciseMul(PreciseUnitMath.preciseUnit().sub(_slippageTolerance).toInt256()).toUint256(); + } + return oppositeBoundUnits; + } + + /* ========== Action Info functions ============ */ + + /** + * Validate there are no deposits on Perpetual protocol and the Set is not already engaged. Create the leverage info struct to be used in engage. + */ + function _getAndValidateEngageInfo() internal view returns(LeverageInfo memory) { + ActionInfo memory engageInfo = _createActionInfo(); + + require(engageInfo.accountInfo.collateralBalance == 0, "PerpV2 collateral balance must be 0"); + + return LeverageInfo({ + action: engageInfo, + currentLeverageRatio: 0, // 0 position leverage + slippageTolerance: execution.slippageTolerance, + twapMaxTradeSize: exchange.twapMaxTradeSize + }); + } + + /** + * Create the leverage info struct to be used in internal functions. + * + * return LeverageInfo Struct containing ActionInfo and other data + */ + function _getAndValidateLeveragedInfo(uint256 _slippageTolerance, uint256 _maxTradeSize) internal view returns(LeverageInfo memory) { + ActionInfo memory actionInfo = _createActionInfo(); + + require(actionInfo.setTotalSupply > 0, "SetToken must have > 0 supply"); + + // Get current leverage ratio + int256 currentLeverageRatio = _calculateCurrentLeverageRatio(actionInfo); + + // This function is called during rebalance, iterateRebalance, ripcord and disengage. + // Assert currentLeverageRatio is 0 as the set should be engaged before this function is called. + require(currentLeverageRatio.abs() > 0, "Current leverage ratio must NOT be 0"); + + return LeverageInfo({ + action: actionInfo, + currentLeverageRatio: currentLeverageRatio, + slippageTolerance: _slippageTolerance, + twapMaxTradeSize: _maxTradeSize + }); + } + + /** + * Create the action info struct to be used in internal functions + * + * return ActionInfo Struct containing data used by internal lever and delever functions + */ + function _createActionInfo() internal view returns(ActionInfo memory) { + ActionInfo memory rebalanceInfo; + + // Fetch base token prices from PerpV2 oracles and adjust them to 18 decimal places. + // NOTE: The same basePrice is used for both the virtual and the spot asset. + int256 rawBasePrice = strategy.baseUSDPriceOracle.getPrice(strategy.twapInterval).toInt256(); + rebalanceInfo.basePrice = rawBasePrice.mul((10 ** strategy.basePriceDecimalAdjustment).toInt256()); + + // vUSD price is fixed to 1$ + rebalanceInfo.quotePrice = PreciseUnitMath.preciseUnit().toInt256(); + + // Note: getTakerPositionSize returns zero if base balance is less than 10 wei + rebalanceInfo.baseBalance = strategy.perpV2AccountBalance.getTakerPositionSize(address(strategy.setToken), strategy.virtualBaseAddress); + + // Note: Fetching quote balance associated with a single position and not the net quote balance + rebalanceInfo.quoteBalance = strategy.perpV2AccountBalance.getTakerOpenNotional(address(strategy.setToken), strategy.virtualBaseAddress); + + rebalanceInfo.accountInfo = strategy.basisTradingModule.getAccountInfo(strategy.setToken); + + // In Perp v2, all virtual tokens have 18 decimals, therefore we do not need to make further adjustments to determine base valuation. + rebalanceInfo.basePositionValue = rebalanceInfo.basePrice.preciseMul(rebalanceInfo.baseBalance); + rebalanceInfo.quoteValue = rebalanceInfo.quoteBalance; + + rebalanceInfo.setTotalSupply = strategy.setToken.totalSupply(); + + return rebalanceInfo; + } + + /* =========== Udpate state functions ============= */ + + /** + * Update last trade timestamp and if chunk rebalance size is less than total rebalance notional, store new leverage ratio to kick off TWAP. Used in + * the engage() and rebalance() functions + */ + function _updateRebalanceState( + int256 _chunkRebalanceNotional, + int256 _totalRebalanceNotional, + int256 _newLeverageRatio + ) + internal + { + _updateLastTradeTimestamp(); + + if (_chunkRebalanceNotional.abs() < _totalRebalanceNotional.abs()) { + twapLeverageRatio = _newLeverageRatio; + } + } + + /** + * Update last trade timestamp and if chunk rebalance size is equal to the total rebalance notional, end TWAP by clearing state. This function is used + * in iterateRebalance() + */ + function _updateIterateState(int256 _chunkRebalanceNotional, int256 _totalRebalanceNotional) internal { + + _updateLastTradeTimestamp(); + + // If the chunk size is equal to the total notional meaning that rebalances are not chunked, then clear TWAP state. + if (_chunkRebalanceNotional == _totalRebalanceNotional) { + delete twapLeverageRatio; + } + } + + /** + * Update last trade timestamp and if currently in a TWAP, delete the TWAP state. Used in the ripcord() function. + */ + function _updateRipcordState() internal { + + _updateLastTradeTimestamp(); + + // If TWAP leverage ratio is stored, then clear state. This may happen if we are currently in a TWAP rebalance, and the leverage ratio moves above the + // incentivized threshold for ripcord. + if (twapLeverageRatio != 0) { + delete twapLeverageRatio; + } + } + + /** + * Update last trade timestamp. Used in the disengage() function. + */ + function _updateDisengageState() internal { + _updateLastTradeTimestamp(); + } + + /** + * Update last reinvest timestamp. Used in the reinvest() function. + */ + function _updateReinvestState() internal { + _updateLastReinvestTimestamp(); + } + + /** + * Update lastTradeTimestamp value. This function updates the global trade timestamp so that the epoch rebalance can use the global timestamp. + */ + function _updateLastTradeTimestamp() internal { + lastTradeTimestamp = block.timestamp; + } + + /** + * Update lastReinvestTimestamp value. + */ + function _updateLastReinvestTimestamp() internal { + lastReinvestTimestamp = block.timestamp; + } + + /* =========== Miscallaneous functions ============ */ + + /** + * Check if price has moved advantageously while in the midst of the TWAP rebalance. This means the current leverage ratio has moved over/under + * the stored TWAP leverage ratio on lever/delever so there is no need to execute a rebalance. Used in iterateRebalance() + * + * return bool True if price has moved advantageously, false otherwise + */ + function _isAdvantageousTWAP(int256 _currentLeverageRatio) internal view returns (bool) { + uint256 twapLeverageRatioAbs = twapLeverageRatio.abs(); + uint256 targetLeverageRatioAbs = methodology.targetLeverageRatio.abs(); + uint256 currentLeverageRatioAbs = _currentLeverageRatio.abs(); + + return ( + (twapLeverageRatioAbs < targetLeverageRatioAbs && currentLeverageRatioAbs >= twapLeverageRatioAbs) + || (twapLeverageRatioAbs > targetLeverageRatioAbs && currentLeverageRatioAbs <= twapLeverageRatioAbs) + ); + } + + /** + * Transfer ETH reward to caller of the ripcord function. If the ETH balance on this contract is less than required + * incentive quantity, then transfer contract balance instead to prevent reverts. + * + * return uint256 Amount of ETH transferred to caller + */ + function _transferEtherRewardToCaller(uint256 _etherReward) internal returns(uint256) { + uint256 etherToTransfer = _etherReward < address(this).balance ? _etherReward : address(this).balance; + + msg.sender.transfer(etherToTransfer); + + return etherToTransfer; + } + + /** + * Internal function returning the ShouldRebalance enum used in shouldRebalance and shouldRebalanceWithBounds external getter functions + * + * return ShouldRebalance Enum detailing whether to rebalance, iterateRebalance, ripcord or no action + */ + function _shouldRebalance( + int256 _currentLeverageRatio, + int256 _minLeverageRatio, + int256 _maxLeverageRatio + ) + internal + view + returns(ShouldRebalance) + { + // Get absolute value of current leverage ratio + uint256 currentLeverageRatioAbs = _currentLeverageRatio.abs(); + + // If above ripcord threshold, then check if incentivized cooldown period has elapsed + if (currentLeverageRatioAbs >= incentive.incentivizedLeverageRatio.abs()) { + if (lastTradeTimestamp.add(incentive.incentivizedTwapCooldownPeriod) < block.timestamp) { + return ShouldRebalance.RIPCORD; + } + return ShouldRebalance.NONE; + } + + // If TWAP, then check if the cooldown period has elapsed + if (twapLeverageRatio != 0) { + if (lastTradeTimestamp.add(execution.twapCooldownPeriod) < block.timestamp) { + return ShouldRebalance.ITERATE_REBALANCE; + } + return ShouldRebalance.NONE; + } + + // If not TWAP, then check if the rebalance interval has elapsed OR current leverage is above max leverage OR current leverage is below + // min leverage + if ( + block.timestamp.sub(lastTradeTimestamp) > methodology.rebalanceInterval + || currentLeverageRatioAbs > _maxLeverageRatio.abs() + || currentLeverageRatioAbs < _minLeverageRatio.abs() + ) { + return ShouldRebalance.REBALANCE; + } + + // Rebalancing is given priority over reinvestment. This might lead to scenarios where this function returns `ShouldRebalance.REINVEST` in + // the current block and `ShouldRebalance.REBALANCE` in the next blocks. This might be due to two reasons + // 1. The leverage ratio moves out of bounds in the next block. + // - In this case, the `reinvest()` transaction sent by the keeper would revert with "Invalid leverage ratio". The keeper can send a new + // `rebalance()` transaction in the next blocks. + // 2. The rebalance interval elapses in the next block. + // - In this case, the `reinvest()` transaction would not revert. The keeper can SAFELY send the `rebalance()` transaction after the + // `reinvest()` transaction is mined. + if (block.timestamp.sub(lastReinvestTimestamp) > methodology.reinvestInterval) { + uint256 reinvestmentNotional = strategy.basisTradingModule.getUpdatedSettledFunding(strategy.setToken); + + // Reinvest only if reinvestment amount is greater than 1 wei worth of USDC (to account for rounding errors) + if (reinvestmentNotional.fromPreciseUnitToDecimals(collateralDecimals) > 1) { + return ShouldRebalance.REINVEST; + } + } + + return ShouldRebalance.NONE; + } + + /* =========== Validation Functions =========== */ + + /** + * Validate non-exchange settings in constructor and setters when updating. + */ + function _validateNonExchangeSettings( + MethodologySettings memory _methodology, + ExecutionSettings memory _execution, + IncentiveSettings memory _incentive + ) + internal + pure + { + uint256 minLeverageRatioAbs = _methodology.minLeverageRatio.abs(); + uint256 targetLeverageRatioAbs = _methodology.targetLeverageRatio.abs(); + uint256 maxLeverageRatioAbs = _methodology.maxLeverageRatio.abs(); + uint256 incentivizedLeverageRatioAbs = _incentive.incentivizedLeverageRatio.abs(); + + require ( + _methodology.minLeverageRatio < 0 && minLeverageRatioAbs <= targetLeverageRatioAbs && minLeverageRatioAbs > 0, + "Must be valid min leverage" + ); + require ( + _methodology.maxLeverageRatio < 0 && maxLeverageRatioAbs >= targetLeverageRatioAbs, + "Must be valid max leverage" + ); + require(_methodology.targetLeverageRatio < 0, "Must be valid target leverage"); + require ( + _methodology.recenteringSpeed <= PreciseUnitMath.preciseUnit() && _methodology.recenteringSpeed > 0, + "Must be valid recentering speed" + ); + require ( + _execution.slippageTolerance <= PreciseUnitMath.preciseUnit(), + "Slippage tolerance must be <100%" + ); + require ( + _incentive.incentivizedSlippageTolerance <= PreciseUnitMath.preciseUnit(), + "Incentivized slippage tolerance must be <100%" + ); + require(_incentive.incentivizedLeverageRatio < 0, "Must be valid incentivized leverage ratio"); + require ( + incentivizedLeverageRatioAbs >= maxLeverageRatioAbs, + "Incentivized leverage ratio must be > max leverage ratio" + ); + require ( + _methodology.rebalanceInterval >= _execution.twapCooldownPeriod, + "Rebalance interval must be greater than TWAP cooldown period" + ); + require ( + _execution.twapCooldownPeriod >= _incentive.incentivizedTwapCooldownPeriod, + "TWAP cooldown must be greater than incentivized TWAP cooldown" + ); + } + + /** + * Validate an ExchangeSettings struct settings. + */ + function _validateExchangeSettings(ExchangeSettings memory _settings) internal view { + require( + keccak256(abi.encodePacked((_settings.exchangeName))) == keccak256(abi.encodePacked(("UniswapV3ExchangeAdapterV2"))), + "Invalid exchange name" + ); + require(_settings.twapMaxTradeSize != 0, "Max TWAP trade size must not be 0"); + require( + _settings.twapMaxTradeSize <= _settings.incentivizedTwapMaxTradeSize, + "Max TWAP trade size must not be greater than incentivized max TWAP trade size" + ); + + bytes memory data; + + // For a single hop trade, trade data bytes length is 44. 20 source/destination token address + 3 fees + 20 source/destination token + // address + 1 fixInput bool. For multi-hop trades, trade data bytes length is greater than 44. + // `buyExactSpotTradeData` is trade data for an exact output trade. And exact output paths are reversed in UniswapV3. + data = _settings.buyExactSpotTradeData; + require( + data.length >= 44 + && data.toAddress(0) == strategy.spotAssetAddress + && data.toAddress(data.length - 21) == address(collateralToken) + && !data.toBool(data.length - 1), // FixIn is false; since exactOutput + "Invalid buyExactSpotTradeData data" + ); + + // `sellExactSpotTradeData` is trade data for an exact input trade. + data = _settings.sellExactSpotTradeData; + require( + data.length >= 44 + && data.toAddress(0) == strategy.spotAssetAddress + && data.toAddress(data.length - 21) == address(collateralToken) + && data.toBool(data.length - 1), // FixIn is true; since exactInput + "Invalid sellExactSpotTradeData data" + ); + + data = _settings.buySpotQuoteExactInputPath; + require( + data.length >= 43 // No FixIn bool at end + && data.toAddress(0) == address(collateralToken) + && data.toAddress(data.length - 20) == strategy.spotAssetAddress, + "Invalid buySpotQuoteExactInputPath data" + ); + } + + /** + * Validate that current leverage is below incentivized leverage ratio and cooldown / rebalance period has elapsed or outsize max/min bounds. Used + * in rebalance() and iterateRebalance() functions + */ + function _validateNormalRebalance(LeverageInfo memory _leverageInfo, uint256 _coolDown, uint256 _lastTradeTimestamp) internal view { + uint256 currentLeverageRatioAbs = _leverageInfo.currentLeverageRatio.abs(); + require(currentLeverageRatioAbs < incentive.incentivizedLeverageRatio.abs(), "Must be below incentivized leverage ratio"); + require( + block.timestamp.sub(_lastTradeTimestamp) > _coolDown + || currentLeverageRatioAbs > methodology.maxLeverageRatio.abs() + || currentLeverageRatioAbs < methodology.minLeverageRatio.abs(), + "Cooldown not elapsed or not valid leverage ratio" + ); + } + + /** + * Validate that current leverage is above incentivized leverage ratio and incentivized cooldown period has elapsed in ripcord() + */ + function _validateRipcord(LeverageInfo memory _leverageInfo, uint256 _lastTradeTimestamp) internal view { + require(_leverageInfo.currentLeverageRatio.abs() >= incentive.incentivizedLeverageRatio.abs(), "Must be above incentivized leverage ratio"); + // If currently in the midst of a TWAP rebalance, ensure that the cooldown period has elapsed + require(_lastTradeTimestamp.add(incentive.incentivizedTwapCooldownPeriod) < block.timestamp, "TWAP cooldown must have elapsed"); + } + + /** + * Validate cooldown period has elapsed in disengage() + */ + function _validateDisengage(uint256 _lastTradeTimestamp) internal view { + require(_lastTradeTimestamp.add(execution.twapCooldownPeriod) < block.timestamp, "TWAP cooldown must have elapsed"); + } + + /** + * Validate reinvest interval has elapsed and valid leverage ratio. Called in the reinvest() function + */ + function _validateReinvest(LeverageInfo memory _leverageInfo) internal view { + uint256 currentLeverageRatioAbs = _leverageInfo.currentLeverageRatio.abs(); + require(block.timestamp.sub(methodology.reinvestInterval) > lastReinvestTimestamp, "Reinvestment interval not elapsed"); + require( + currentLeverageRatioAbs < methodology.maxLeverageRatio.abs() + && currentLeverageRatioAbs > methodology.minLeverageRatio.abs(), + "Invalid leverage ratio" + ); + } + + /** + * Validate TWAP in the iterateRebalance() function + */ + function _validateTWAP() internal view { + require(twapLeverageRatio != 0, "Not in TWAP state"); + } + + /** + * Validate not TWAP in the rebalance() function + */ + function _validateNonTWAP() internal view { + require(twapLeverageRatio == 0, "Must call iterate"); + } + +} \ No newline at end of file diff --git a/contracts/extensions/PerpV2LeverageStrategyExtension.sol b/contracts/extensions/PerpV2LeverageStrategyExtension.sol index d3f020b..9eeef80 100644 --- a/contracts/extensions/PerpV2LeverageStrategyExtension.sol +++ b/contracts/extensions/PerpV2LeverageStrategyExtension.sol @@ -27,7 +27,7 @@ import { SafeMath } from "@openzeppelin/contracts/math/SafeMath.sol"; import { SignedSafeMath } from "@openzeppelin/contracts/math/SignedSafeMath.sol"; import { IAccountBalance } from "@setprotocol/set-protocol-v2/contracts/interfaces/external/perp-v2/IAccountBalance.sol"; -import { IPerpV2LeverageModule } from "@setprotocol/set-protocol-v2/contracts/interfaces/IPerpV2LeverageModule.sol"; +import { IPerpV2LeverageModuleV2 } from "@setprotocol/set-protocol-v2/contracts/interfaces/IPerpV2LeverageModuleV2.sol"; import { ISetToken } from "@setprotocol/set-protocol-v2/contracts/interfaces/ISetToken.sol"; import { IVault } from "@setprotocol/set-protocol-v2/contracts/interfaces/external/perp-v2/IVault.sol"; import { PreciseUnitMath } from "@setprotocol/set-protocol-v2/contracts/lib/PreciseUnitMath.sol"; @@ -71,7 +71,7 @@ contract PerpV2LeverageStrategyExtension is BaseExtension { struct ActionInfo { int256 baseBalance; // Balance of virtual base asset from Perp in precise units (10e18). E.g. vWBTC = 10e18 int256 quoteBalance; // Balance of virtual quote asset from Perp in precise units (10e18). E.g. vUSD = 10e18 - IPerpV2LeverageModule.AccountInfo accountInfo; // Info on perpetual account including, collateral balance, owedRealizedPnl and pendingFunding + IPerpV2LeverageModuleV2.AccountInfo accountInfo; // Info on perpetual account including, collateral balance, owedRealizedPnl and pendingFunding int256 basePositionValue; // Valuation in USD adjusted for decimals in precise units (10e18) int256 quoteValue; // Valuation in USD adjusted for decimals in precise units (10e18) int256 basePrice; // Price of base asset in precise units (10e18) from PerpV2 Oracle @@ -88,7 +88,7 @@ contract PerpV2LeverageStrategyExtension is BaseExtension { struct ContractSettings { ISetToken setToken; // Instance of leverage token - IPerpV2LeverageModule perpV2LeverageModule; // Instance of Perp V2 leverage module + IPerpV2LeverageModuleV2 perpV2LeverageModule; // Instance of Perp V2 leverage module IAccountBalance perpV2AccountBalance; // Instance of Perp V2 AccountBalance contract used to fetch position balances IPriceFeed baseUSDPriceOracle; // PerpV2 oracle that returns TWAP price for base asset in USD. IPriceFeed is a PerpV2 specific interface // to interact with differnt oracle providers, e.g. Band Protocol and Chainlink, for different assets @@ -399,7 +399,7 @@ contract PerpV2LeverageStrategyExtension is BaseExtension { */ function deposit(uint256 _collateralUnits) external onlyOperator { bytes memory depositCalldata = abi.encodeWithSelector( - IPerpV2LeverageModule.deposit.selector, + IPerpV2LeverageModuleV2.deposit.selector, address(strategy.setToken), _collateralUnits ); @@ -414,7 +414,7 @@ contract PerpV2LeverageStrategyExtension is BaseExtension { */ function withdraw(uint256 _collateralUnits) external onlyOperator { bytes memory withdrawCalldata = abi.encodeWithSelector( - IPerpV2LeverageModule.withdraw.selector, + IPerpV2LeverageModuleV2.withdraw.selector, address(strategy.setToken), _collateralUnits ); @@ -599,7 +599,7 @@ contract PerpV2LeverageStrategyExtension is BaseExtension { function getCurrentEtherIncentive() external view returns(uint256) { int256 currentLeverageRatio = getCurrentLeverageRatio(); - if (currentLeverageRatio >= incentive.incentivizedLeverageRatio) { + if (currentLeverageRatio.abs() >= incentive.incentivizedLeverageRatio.abs()) { // If ETH reward is below the balance on this contract, then return ETH balance on contract instead return incentive.etherReward < address(this).balance ? incentive.etherReward : address(this).balance; } else { @@ -673,7 +673,7 @@ contract PerpV2LeverageStrategyExtension is BaseExtension { uint256 oppositeBoundUnits = _calculateOppositeBoundUnits(baseRebalanceUnits, _leverageInfo.action, _leverageInfo.slippageTolerance); bytes memory tradeCallData = abi.encodeWithSelector( - IPerpV2LeverageModule.trade.selector, + IPerpV2LeverageModuleV2.trade.selector, address(strategy.setToken), strategy.virtualBaseAddress, baseRebalanceUnits, @@ -754,7 +754,6 @@ contract PerpV2LeverageStrategyExtension is BaseExtension { // Fetch base token prices from PerpV2 oracles and adjust them to 18 decimal places. int256 rawBasePrice = strategy.baseUSDPriceOracle.getPrice(strategy.twapInterval).toInt256(); - uint256 decimals = strategy.baseUSDPriceOracle.decimals(); rebalanceInfo.basePrice = rawBasePrice.mul((10 ** strategy.basePriceDecimalAdjustment).toInt256()); // vUSD price is fixed to 1$ diff --git a/contracts/interfaces/IUniswapV3Quoter.sol b/contracts/interfaces/IUniswapV3Quoter.sol new file mode 100644 index 0000000..5dacef3 --- /dev/null +++ b/contracts/interfaces/IUniswapV3Quoter.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity 0.6.10; +pragma experimental ABIEncoderV2; + +/// @title Quoter Interface +/// @notice Supports quoting the calculated amounts from exact input or exact output swaps +/// @dev These functions are not marked view because they rely on calling non-view functions and reverting +/// to compute the result. They are also not gas efficient and should not be called on-chain. +interface IUniswapV3Quoter { + /// @notice Returns the amount out received for a given exact input swap without executing the swap + /// @param path The path of the swap, i.e. each token pair and the pool fee + /// @param amountIn The amount of the first token to swap + /// @return amountOut The amount of the last token that would be received + function quoteExactInput(bytes memory path, uint256 amountIn) external returns (uint256 amountOut); + + /// @notice Returns the amount out received for a given exact input but for a swap of a single pool + /// @param tokenIn The token being swapped in + /// @param tokenOut The token being swapped out + /// @param fee The fee of the token pool to consider for the pair + /// @param amountIn The desired input amount + /// @param sqrtPriceLimitX96 The price limit of the pool that cannot be exceeded by the swap + /// @return amountOut The amount of `tokenOut` that would be received + function quoteExactInputSingle( + address tokenIn, + address tokenOut, + uint24 fee, + uint256 amountIn, + uint160 sqrtPriceLimitX96 + ) external returns (uint256 amountOut); + + /// @notice Returns the amount in required for a given exact output swap without executing the swap + /// @param path The path of the swap, i.e. each token pair and the pool fee. Path must be provided in reverse order + /// @param amountOut The amount of the last token to receive + /// @return amountIn The amount of first token required to be paid + function quoteExactOutput(bytes memory path, uint256 amountOut) external returns (uint256 amountIn); + + /// @notice Returns the amount in required to receive the given exact output amount but for a swap of a single pool + /// @param tokenIn The token being swapped in + /// @param tokenOut The token being swapped out + /// @param fee The fee of the token pool to consider for the pair + /// @param amountOut The desired output amount + /// @param sqrtPriceLimitX96 The price limit of the pool that cannot be exceeded by the swap + /// @return amountIn The amount required as the input for the swap in order to receive `amountOut` + function quoteExactOutputSingle( + address tokenIn, + address tokenOut, + uint24 fee, + uint256 amountOut, + uint160 sqrtPriceLimitX96 + ) external returns (uint256 amountIn); +} diff --git a/package.json b/package.json index c8a5a50..922f813 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@setprotocol/set-v2-strategies", - "version": "0.0.7", + "version": "0.0.8", "description": "", "main": "dist", "types": "dist/types", @@ -90,7 +90,7 @@ "web3": "^1.2.9" }, "dependencies": { - "@setprotocol/set-protocol-v2": "^0.4.0-hhat.1", + "@setprotocol/set-protocol-v2": "0.10.0-hhat.1", "@uniswap/v3-sdk": "^3.5.1", "ethers": "5.5.2", "fs-extra": "^5.0.0", diff --git a/test/extensions/deltaNeutralBasisTradingStrategyExtension.spec.ts b/test/extensions/deltaNeutralBasisTradingStrategyExtension.spec.ts new file mode 100644 index 0000000..d1ff256 --- /dev/null +++ b/test/extensions/deltaNeutralBasisTradingStrategyExtension.spec.ts @@ -0,0 +1,4158 @@ +import "module-alias/register"; +import { BigNumber, ContractTransaction } from "ethers"; +import { ethers } from "hardhat"; +import { solidityPack } from "ethers/lib/utils"; + +import { + Address, + Account, + PerpV2BasisContractSettings, + PerpV2BasisMethodologySettings, + PerpV2BasisExecutionSettings, + PerpV2BasisIncentiveSettings, + PerpV2BasisExchangeSettings +} from "@utils/types"; + +import { ADDRESS_ZERO, ZERO, ONE_DAY_IN_SECONDS, TWO } from "../../utils/constants"; +import { + PerpV2BasisTradingModule, + SetToken, + PositionV2, + PerpV2LibraryV2, + PerpV2Positions, + SlippageIssuanceModule, + TradeModule, + UniswapV3ExchangeAdapterV2, + WETH9 +} from "@setprotocol/set-protocol-v2/utils/contracts"; +import DeployHelper from "../../utils/deploys"; +import { + cacheBeforeEach, + ether, + getEthBalance, + getAccounts, + getLastBlockTimestamp, + getRandomAccount, + getWaffleExpect, + preciseDiv, + preciseMul, + usdc, + increaseTimeAsync, + calculateNewLeverageRatioPerpV2Basis, + getRandomAddress +} from "../../utils/index"; +import { PerpV2PriceFeedMock } from "@utils/contracts"; + +import { PerpV2Fixture, SystemFixture, UniswapV3Fixture } from "@setprotocol/set-protocol-v2/dist/utils/fixtures"; +import { getPerpV2Fixture, getSystemFixture, getUniswapV3Fixture } from "@setprotocol/set-protocol-v2/dist/utils/test"; + +import { BaseManager, DeltaNeutralBasisTradingStrategyExtension } from "@utils/contracts/index"; +import { toUSDCDecimals } from "@setprotocol/set-protocol-v2/dist/utils/common"; + +const expect = getWaffleExpect(); +const provider = ethers.provider; + +describe("DeltaNeutralBasisTradingStrategyExtension", () => { + let owner: Account; + let methodologist: Account; + let maker: Account; + let taker: Account; + let systemSetup: SystemFixture; + let perpV2Setup: PerpV2Fixture; + let uniV3Setup: UniswapV3Fixture; + + let deployer: DeployHelper; + let setToken: SetToken; + + let strategy: PerpV2BasisContractSettings; + let methodology: PerpV2BasisMethodologySettings; + let execution: PerpV2BasisExecutionSettings; + let incentive: PerpV2BasisIncentiveSettings; + let exchange: PerpV2BasisExchangeSettings; + let customTargetLeverageRatio: any; + let customMinLeverageRatio: any; + let basePriceDecimalAdjustment: BigNumber; + + let tradeModule: TradeModule; + let uniswapV3ExchangeAdapter: UniswapV3ExchangeAdapterV2; + let leverageStrategyExtension: DeltaNeutralBasisTradingStrategyExtension; + let perpBasisTradingModule: PerpV2BasisTradingModule; + let positionLib: PositionV2; + let perpLib: PerpV2LibraryV2; + let perpPositionsLib: PerpV2Positions; + let issuanceModule: SlippageIssuanceModule; + let baseManager: BaseManager; + + let perpV2PriceFeedMock: PerpV2PriceFeedMock; + let spotAsset: WETH9; + + cacheBeforeEach(async () => { + [ + owner, + methodologist, + maker, + taker + ] = await getAccounts(); + + deployer = new DeployHelper(owner.wallet); + + systemSetup = getSystemFixture(owner.address); + await systemSetup.initialize(); + uniV3Setup = getUniswapV3Fixture(owner.address); + await uniV3Setup.initialize( + owner, + systemSetup.weth, + 1000, + systemSetup.wbtc, + 60000, + systemSetup.dai + ); + perpV2Setup = getPerpV2Fixture(owner.address); + await perpV2Setup.initialize(maker, taker); + + // set funding rate to zero; allows us to avoid calculating small amounts of funding + // accrued in our test cases + await perpV2Setup.clearingHouseConfig.setMaxFundingRate(ZERO); + + await perpV2Setup.usdc.mint(perpV2Setup.maker.address, usdc(500000000000)); + await perpV2Setup.deposit(perpV2Setup.maker, BigNumber.from(500000000000), perpV2Setup.usdc); + + // Create PerpV2 liquidity + await perpV2Setup.setBaseTokenOraclePrice(perpV2Setup.vETH, usdc(1000)); + await perpV2Setup.initializePoolWithLiquidityWide( + perpV2Setup.vETH, + ether(1000000000), + ether(1000000000000) + ); + + await perpV2Setup.setBaseTokenOraclePrice(perpV2Setup.vBTC, BigNumber.from(60000)); + await perpV2Setup.initializePoolWithLiquidityWide( + perpV2Setup.vBTC, + ether(10000), + ether(600000) + ); + + // Create Dex liquidity + await systemSetup.weth.connect(owner.wallet).approve(uniV3Setup.nftPositionManager.address, ether(1000)); + await perpV2Setup.usdc.connect(owner.wallet).approve(uniV3Setup.nftPositionManager.address, usdc(1000_000)); + await uniV3Setup.createNewPair(systemSetup.weth, perpV2Setup.usdc, 3000, 1000); + await uniV3Setup.addLiquidityWide( + systemSetup.weth, + perpV2Setup.usdc, + 3000, // 0.3% + ether(1000), + usdc(1000_000), + owner.address + ); + + issuanceModule = await deployer.setDeployer.modules.deploySlippageIssuanceModule( + systemSetup.controller.address + ); + + tradeModule = await deployer.setDeployer.modules.deployTradeModule( + systemSetup.controller.address + ); + + positionLib = await deployer.setDeployer.libraries.deployPositionV2(); + perpLib = await deployer.setDeployer.libraries.deployPerpV2LibraryV2(); + perpPositionsLib = await deployer.setDeployer.libraries.deployPerpV2Positions(); + + perpBasisTradingModule = await deployer.setDeployer.modules.deployPerpV2BasisTradingModule( + systemSetup.controller.address, + perpV2Setup.vault.address, + perpV2Setup.quoter.address, + perpV2Setup.marketRegistry.address, + TWO, + "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 + ); + + uniswapV3ExchangeAdapter = await deployer.setDeployer.adapters.deployUniswapV3ExchangeAdapterV2(uniV3Setup.swapRouter.address); + + await systemSetup.controller.addModule(tradeModule.address); + await systemSetup.controller.addModule(issuanceModule.address); + await systemSetup.controller.addModule(perpBasisTradingModule.address); + + await systemSetup.integrationRegistry.addIntegration( + perpBasisTradingModule.address, + "DefaultIssuanceModule", + issuanceModule.address + ); + + await systemSetup.integrationRegistry.addIntegration( + tradeModule.address, + "UniswapV3ExchangeAdapterV2", + uniswapV3ExchangeAdapter.address + ); + + // Deploy Chainlink mocks + perpV2PriceFeedMock = await deployer.mocks.deployPerpV2PriceFeedMock(8); + await perpV2PriceFeedMock.setPrice(BigNumber.from(1000).mul(10 ** 8)); + }); + + const initializeRootScopeContracts = async () => { + setToken = await systemSetup.createSetToken( + [perpV2Setup.usdc.address], + [usdc(100)], + [ + systemSetup.streamingFeeModule.address, + perpBasisTradingModule.address, + issuanceModule.address, + tradeModule.address + ] + ); + await perpBasisTradingModule.updateAnySetAllowed(true); + + // Initialize modules + await issuanceModule.initialize(setToken.address, ether(1), ZERO, ZERO, owner.address, ADDRESS_ZERO); + const streamingFeeSettings = { + feeRecipient: owner.address, + maxStreamingFeePercentage: ether(.1), + streamingFeePercentage: ether(.02), + lastStreamingFeeTimestamp: ZERO, + }; + await systemSetup.streamingFeeModule.initialize(setToken.address, streamingFeeSettings); + await perpBasisTradingModule["initialize(address,(address,uint256,uint256))"]( + setToken.address, + { + feeRecipient: owner.address, + maxPerformanceFeePercentage: ether(.2), + performanceFeePercentage: ether(.1) + } + ); + await tradeModule.connect(owner.wallet).initialize(setToken.address); + + baseManager = await deployer.manager.deployBaseManager( + setToken.address, + owner.address, + methodologist.address, + ); + + // Transfer ownership to base manager + if ((await setToken.manager()) == owner.address) { + await setToken.connect(owner.wallet).setManager(baseManager.address); + } + + spotAsset = systemSetup.weth; + + // Deploy adapter + const vBaseAssetDecimals = await perpV2Setup.vETH.decimals(); + const priceFeedDecimals = await perpV2PriceFeedMock.decimals(); + basePriceDecimalAdjustment = BigNumber.from(vBaseAssetDecimals).sub(priceFeedDecimals); + + const targetLeverageRatio = customTargetLeverageRatio || ether(-1); + const minLeverageRatio = customMinLeverageRatio || ether(-0.9); + const maxLeverageRatio = ether(-1.1); + const recenteringSpeed = ether(0.05); + const rebalanceInterval = ONE_DAY_IN_SECONDS; + const reinvestInterval = ONE_DAY_IN_SECONDS.mul(7); + + const exchangeName = "UniswapV3ExchangeAdapterV2"; + const buyExactSpotTradeData = await uniswapV3ExchangeAdapter.generateDataParam( + [systemSetup.weth.address, perpV2Setup.usdc.address], // exactOutput paths are reversed in Uniswap V3 + [3000], + false + ); + const sellExactSpotTradeData = await uniswapV3ExchangeAdapter.generateDataParam( + [systemSetup.weth.address, perpV2Setup.usdc.address], + [3000], + true + ); + const buySpotQuoteExactInputPath = solidityPack( + ["address", "uint24", "address"], + [perpV2Setup.usdc.address, BigNumber.from(3000), systemSetup.weth.address] + ); + const twapMaxTradeSize = ether(20); + const twapCooldownPeriod = BigNumber.from(3000); + const slippageTolerance = ether(0.15); + + const incentivizedTwapMaxTradeSize = ether(25); + const incentivizedTwapCooldownPeriod = BigNumber.from(60); + const incentivizedSlippageTolerance = ether(0.15); + const etherReward = ether(1); + const incentivizedLeverageRatio = ether(-1.3); + + strategy = { + setToken: setToken.address, + basisTradingModule: perpBasisTradingModule.address, + tradeModule: tradeModule.address, + quoter: uniV3Setup.quoter.address, + perpV2AccountBalance: perpV2Setup.accountBalance.address, + baseUSDPriceOracle: perpV2PriceFeedMock.address, + twapInterval: ZERO, + basePriceDecimalAdjustment: basePriceDecimalAdjustment, + virtualBaseAddress: perpV2Setup.vETH.address, + virtualQuoteAddress: perpV2Setup.vQuote.address, + spotAssetAddress: systemSetup.weth.address + }; + methodology = { + targetLeverageRatio: targetLeverageRatio, + minLeverageRatio: minLeverageRatio, + maxLeverageRatio: maxLeverageRatio, + recenteringSpeed: recenteringSpeed, + rebalanceInterval: rebalanceInterval, + reinvestInterval: reinvestInterval + }; + execution = { + twapCooldownPeriod: twapCooldownPeriod, + slippageTolerance: slippageTolerance, + }; + incentive = { + incentivizedTwapCooldownPeriod: incentivizedTwapCooldownPeriod, + incentivizedSlippageTolerance: incentivizedSlippageTolerance, + etherReward: etherReward, + incentivizedLeverageRatio: incentivizedLeverageRatio, + }; + exchange = { + exchangeName: exchangeName, + buyExactSpotTradeData: buyExactSpotTradeData, + sellExactSpotTradeData: sellExactSpotTradeData, + buySpotQuoteExactInputPath: buySpotQuoteExactInputPath, + twapMaxTradeSize: twapMaxTradeSize, + incentivizedTwapMaxTradeSize: incentivizedTwapMaxTradeSize, + }; + + leverageStrategyExtension = await deployer.extensions.deployDeltaNeutralBasisTradingStrategyExtension( + baseManager.address, + strategy, + methodology, + execution, + incentive, + exchange + ); + // Add adapter + await baseManager.connect(owner.wallet).addAdapter(leverageStrategyExtension.address); + + await perpV2Setup.usdc.approve(issuanceModule.address, usdc(10000)); + await issuanceModule.connect(owner.wallet).issue(setToken.address, ether(100), owner.address); + + // Make owner an approved caller + await leverageStrategyExtension.updateCallerStatus([owner.wallet.address], [true]); + }; + + describe("#constructor", async () => { + let subjectManagerAddress: Address; + let subjectContractSettings: PerpV2BasisContractSettings; + let subjectPerpV2MethodologySettings: PerpV2BasisMethodologySettings; + let subjectExecutionSettings: PerpV2BasisExecutionSettings; + let subjectIncentiveSettings: PerpV2BasisIncentiveSettings; + let subjectPerpV2BasisExchangeSettings: PerpV2BasisExchangeSettings; + + cacheBeforeEach(initializeRootScopeContracts); + + beforeEach(async () => { + subjectManagerAddress = baseManager.address; + subjectContractSettings = { + setToken: setToken.address, + tradeModule: tradeModule.address, + quoter: uniV3Setup.quoter.address, + basisTradingModule: perpBasisTradingModule.address, + perpV2AccountBalance: perpV2Setup.accountBalance.address, + baseUSDPriceOracle: perpV2PriceFeedMock.address, + twapInterval: ZERO, + basePriceDecimalAdjustment: basePriceDecimalAdjustment, + virtualBaseAddress: perpV2Setup.vETH.address, + virtualQuoteAddress: perpV2Setup.vQuote.address, + spotAssetAddress: systemSetup.weth.address + }; + subjectPerpV2MethodologySettings = { + targetLeverageRatio: ether(-1), + minLeverageRatio: ether(-0.7), + maxLeverageRatio: ether(-1.3), + recenteringSpeed: ether(0.05), + rebalanceInterval: BigNumber.from(86400), + reinvestInterval: ONE_DAY_IN_SECONDS.mul(7) + }; + subjectExecutionSettings = { + twapCooldownPeriod: BigNumber.from(120), + slippageTolerance: ether(0.01), + }; + subjectIncentiveSettings = { + incentivizedTwapCooldownPeriod: BigNumber.from(60), + incentivizedSlippageTolerance: ether(0.05), + etherReward: ether(1), + incentivizedLeverageRatio: ether(-2), + }; + subjectPerpV2BasisExchangeSettings = { + exchangeName: "UniswapV3ExchangeAdapterV2", + buyExactSpotTradeData: await uniswapV3ExchangeAdapter.generateDataParam( + [systemSetup.weth.address, perpV2Setup.usdc.address], // reversed + [3000], + false + ), + sellExactSpotTradeData: await uniswapV3ExchangeAdapter.generateDataParam( + [systemSetup.weth.address, perpV2Setup.usdc.address], + [3000], + true + ), + buySpotQuoteExactInputPath: solidityPack( + ["address", "uint24", "address"], + [perpV2Setup.usdc.address, BigNumber.from(3000), systemSetup.weth.address] + ), + twapMaxTradeSize: ether(5), + incentivizedTwapMaxTradeSize: ether(10), + }; + }); + + async function subject(): Promise { + return await deployer.extensions.deployDeltaNeutralBasisTradingStrategyExtension( + subjectManagerAddress, + subjectContractSettings, + subjectPerpV2MethodologySettings, + subjectExecutionSettings, + subjectIncentiveSettings, + subjectPerpV2BasisExchangeSettings + ); + } + + it("should set the manager address", async () => { + const retrievedAdapter = await subject(); + + const manager = await retrievedAdapter.manager(); + + expect(manager).to.eq(subjectManagerAddress); + }); + + it("should set the contract addresses", async () => { + const retrievedAdapter = await subject(); + const strategy: PerpV2BasisContractSettings = await retrievedAdapter.getStrategy(); + + expect(strategy.setToken).to.eq(subjectContractSettings.setToken); + expect(strategy.tradeModule).to.eq(subjectContractSettings.tradeModule); + expect(strategy.basisTradingModule).to.eq(subjectContractSettings.basisTradingModule); + expect(strategy.perpV2AccountBalance).to.eq(subjectContractSettings.perpV2AccountBalance); + expect(strategy.baseUSDPriceOracle).to.eq(subjectContractSettings.baseUSDPriceOracle); + expect(strategy.twapInterval).to.eq(subjectContractSettings.twapInterval); + expect(strategy.basePriceDecimalAdjustment).to.eq(subjectContractSettings.basePriceDecimalAdjustment); + expect(strategy.virtualBaseAddress).to.eq(subjectContractSettings.virtualBaseAddress); + expect(strategy.virtualQuoteAddress).to.eq(subjectContractSettings.virtualQuoteAddress); + expect(strategy.spotAssetAddress).to.eq(subjectContractSettings.spotAssetAddress); + }); + + it("should set the correct methodology parameters", async () => { + const retrievedAdapter = await subject(); + const methodology = await retrievedAdapter.getMethodology(); + + expect(methodology.targetLeverageRatio).to.eq(subjectPerpV2MethodologySettings.targetLeverageRatio); + expect(methodology.minLeverageRatio).to.eq(subjectPerpV2MethodologySettings.minLeverageRatio); + expect(methodology.maxLeverageRatio).to.eq(subjectPerpV2MethodologySettings.maxLeverageRatio); + expect(methodology.recenteringSpeed).to.eq(subjectPerpV2MethodologySettings.recenteringSpeed); + expect(methodology.rebalanceInterval).to.eq(subjectPerpV2MethodologySettings.rebalanceInterval); + expect(methodology.reinvestInterval).to.eq(subjectPerpV2MethodologySettings.reinvestInterval); + }); + + it("should set the correct execution parameters", async () => { + const retrievedAdapter = await subject(); + const execution = await retrievedAdapter.getExecution(); + + expect(execution.twapCooldownPeriod).to.eq(subjectExecutionSettings.twapCooldownPeriod); + expect(execution.slippageTolerance).to.eq(subjectExecutionSettings.slippageTolerance); + }); + + it("should set the correct incentive parameters", async () => { + const retrievedAdapter = await subject(); + const incentive = await retrievedAdapter.getIncentive(); + + expect(incentive.incentivizedTwapCooldownPeriod).to.eq(subjectIncentiveSettings.incentivizedTwapCooldownPeriod); + expect(incentive.incentivizedSlippageTolerance).to.eq(subjectIncentiveSettings.incentivizedSlippageTolerance); + expect(incentive.etherReward).to.eq(subjectIncentiveSettings.etherReward); + expect(incentive.incentivizedLeverageRatio).to.eq(subjectIncentiveSettings.incentivizedLeverageRatio); + }); + + it("should set the correct exchange settings for the initial exchange", async () => { + const retrievedAdapter = await subject(); + const exchange = await retrievedAdapter.getExchangeSettings(); + + expect(exchange.exchangeName).to.eq(subjectPerpV2BasisExchangeSettings.exchangeName); + expect(exchange.buyExactSpotTradeData).to.eq(subjectPerpV2BasisExchangeSettings.buyExactSpotTradeData); + expect(exchange.twapMaxTradeSize).to.eq(subjectPerpV2BasisExchangeSettings.twapMaxTradeSize); + expect(exchange.incentivizedTwapMaxTradeSize).to.eq(subjectPerpV2BasisExchangeSettings.incentivizedTwapMaxTradeSize); + }); + + describe("when min leverage ratio is 0", async () => { + beforeEach(async () => { + subjectPerpV2MethodologySettings.minLeverageRatio = ZERO; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be valid min leverage"); + }); + }); + + describe("when min leverage ratio is positive", async () => { + beforeEach(async () => { + subjectPerpV2MethodologySettings.minLeverageRatio = ether(0.7); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be valid min leverage"); + }); + }); + + describe("when max leverage ratio is positive", async () => { + beforeEach(async () => { + subjectPerpV2MethodologySettings.maxLeverageRatio = ether(1.3); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be valid max leverage"); + }); + }); + + describe("when target leverage ratio is positive", async () => { + beforeEach(async () => { + subjectPerpV2MethodologySettings.targetLeverageRatio = ether(1); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be valid target leverage"); + }); + }); + + describe("when min leverage ratio is above target", async () => { + beforeEach(async () => { + subjectPerpV2MethodologySettings.minLeverageRatio = ether(-1.1); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be valid min leverage"); + }); + }); + + describe("when max leverage ratio is below target", async () => { + beforeEach(async () => { + subjectPerpV2MethodologySettings.maxLeverageRatio = ether(-0.9); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be valid max leverage"); + }); + }); + + describe("when recentering speed is >100%", async () => { + beforeEach(async () => { + subjectPerpV2MethodologySettings.recenteringSpeed = ether(1.1); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be valid recentering speed"); + }); + }); + + describe("when recentering speed is 0%", async () => { + beforeEach(async () => { + subjectPerpV2MethodologySettings.recenteringSpeed = ZERO; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be valid recentering speed"); + }); + }); + + describe("when slippage tolerance is >100%", async () => { + beforeEach(async () => { + subjectExecutionSettings.slippageTolerance = ether(1.1); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Slippage tolerance must be <100%"); + }); + }); + + describe("when incentivized slippage tolerance is >100%", async () => { + beforeEach(async () => { + subjectIncentiveSettings.incentivizedSlippageTolerance = ether(1.1); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Incentivized slippage tolerance must be <100%"); + }); + }); + + describe("when incentivized leverage ratio is positive", async () => { + beforeEach(async () => { + subjectIncentiveSettings.incentivizedLeverageRatio = ether(2); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be valid incentivized leverage ratio"); + }); + }); + + describe("when incentivize leverage ratio is less than max leverage ratio", async () => { + beforeEach(async () => { + subjectIncentiveSettings.incentivizedLeverageRatio = ether(-1.2); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Incentivized leverage ratio must be > max leverage ratio"); + }); + }); + + describe("when rebalance interval is shorter than TWAP cooldown period", async () => { + beforeEach(async () => { + subjectPerpV2MethodologySettings.rebalanceInterval = ZERO; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Rebalance interval must be greater than TWAP cooldown period"); + }); + }); + + describe("when TWAP cooldown period is shorter than incentivized TWAP cooldown period", async () => { + beforeEach(async () => { + subjectExecutionSettings.twapCooldownPeriod = ZERO; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("TWAP cooldown must be greater than incentivized TWAP cooldown"); + }); + }); + + describe("when an exchange has a twapMaxTradeSize of 0", async () => { + beforeEach(async () => { + subjectPerpV2BasisExchangeSettings.twapMaxTradeSize = ZERO; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Max TWAP trade size must not be 0"); + }); + }); + + describe("when TWAP max trade size is greater than incentivized max trade size", async () => { + beforeEach(async () => { + subjectPerpV2BasisExchangeSettings.twapMaxTradeSize = subjectPerpV2BasisExchangeSettings.incentivizedTwapMaxTradeSize.mul(2); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Max TWAP trade size must not be greater than incentivized max TWAP trade size"); + }); + }); + }); + + context("SetToken has been issued", async () => { + + cacheBeforeEach(initializeRootScopeContracts); + + describe("#deposit", async () => { + let subjectCaller: Account; + let subjectCollateralUnits: BigNumber; + + beforeEach(async () => { + const collateralUnits = await setToken.getDefaultPositionRealUnit(perpV2Setup.usdc.address); + + subjectCaller = owner; + subjectCollateralUnits = collateralUnits; + }); + + async function subject(): Promise { + return await leverageStrategyExtension.connect(subjectCaller.wallet).deposit(subjectCollateralUnits); + } + + it("should deposit assets USDC into Perpetual Protocol", async () => { + const preUsdcDefaultUnit = await setToken.getDefaultPositionRealUnit(perpV2Setup.usdc.address); + + await subject(); + + const postUsdcDefaultUnit = await setToken.getDefaultPositionRealUnit(perpV2Setup.usdc.address); + const postUsdcExternalUnit = await setToken.getExternalPositionRealUnit(perpV2Setup.usdc.address, perpBasisTradingModule.address); + + expect(postUsdcExternalUnit).to.eq(preUsdcDefaultUnit); + expect(postUsdcDefaultUnit).to.eq(ZERO); + }); + }); + + describe("#withdraw", async () => { + let subjectCaller: Account; + let subjectCollateralUnits: BigNumber; + + beforeEach(async () => { + const depositUnits = await setToken.getDefaultPositionRealUnit(perpV2Setup.usdc.address); + await leverageStrategyExtension.deposit(depositUnits); + + const totalSupply = await setToken.totalSupply(); + const collateralAmount = await perpV2Setup.vault.getBalance(setToken.address); + const collateralUnits = preciseDiv(collateralAmount, totalSupply); + + subjectCaller = owner; + subjectCollateralUnits = collateralUnits; + }); + + async function subject(): Promise { + return await leverageStrategyExtension.connect(subjectCaller.wallet).withdraw(subjectCollateralUnits); + } + + it("should withdraw USDC from Perpetual Protocol", async () => { + const preUsdcBalance = await perpV2Setup.vault.getBalance(setToken.address); + + await subject(); + + const postUsdcExternalUnit = await setToken.getExternalPositionRealUnit(perpV2Setup.usdc.address, perpBasisTradingModule.address); + const postUsdcDefaultUnit = await setToken.getDefaultPositionRealUnit(perpV2Setup.usdc.address); + + const totalSupply = await setToken.totalSupply(); + const expectedPostUsdcDefaultUnit = preciseDiv(preUsdcBalance, totalSupply); + + expect(postUsdcDefaultUnit).to.eq(expectedPostUsdcDefaultUnit); + expect(postUsdcExternalUnit).to.eq(ZERO); + }); + }); + + describe("#engage", async () => { + let subjectCaller: Account; + + beforeEach(async () => { + subjectCaller = owner; + }); + + async function subject(): Promise { + return leverageStrategyExtension.connect(subjectCaller.wallet).engage(); + } + + async function getTotalEngageRebalanceNotional(_setToken: SetToken): Promise { + const collateralBalanceToBeUsedForOpeningPerpPosition = (await perpV2Setup.usdc.balanceOf(_setToken.address)) + .div(2).mul(BigNumber.from(10 ** 12)); + const targetLeverageRatio = (await leverageStrategyExtension.getMethodology()).targetLeverageRatio; + const basePrice = (await perpV2Setup.ethPriceFeed.latestAnswer()).div(usdc(1)); + const totalRebalanceNotional = preciseMul(collateralBalanceToBeUsedForOpeningPerpPosition, targetLeverageRatio).div(basePrice); + return totalRebalanceNotional; + } + + context("when rebalance notional is less than max trade size", async () => { + it("should trade USDC for spot asset on UniswapV3 and deposit the rest to Perpetual protocol", async () => { + const initialPositions = await setToken.getPositions(); + + // Determine expected spot asset unit + const totalSupply = await setToken.totalSupply(); + const totalRebalanceNotional = await getTotalEngageRebalanceNotional(setToken); + const expectedSpotAssetUnit = preciseDiv(totalRebalanceNotional.abs(), totalSupply); + + // Determine expected USDC unit + const usdcBalanceBefore = await perpV2Setup.usdc.balanceOf(setToken.address); + const amountIn = await uniV3Setup.quoter.callStatic.quoteExactOutputSingle( + perpV2Setup.usdc.address, + systemSetup.weth.address, + 3000, + totalRebalanceNotional.abs(), + 0 + ); + const expectedUsdcDeposited = usdcBalanceBefore.sub(amountIn); + + await subject(); + + const finalPositions = await setToken.getPositions(); + const currentUsdcBalanceInPerp = await perpV2Setup.vault.getBalance(setToken.address); + + // One default USDC position before engage + expect(initialPositions.length).to.eq(1); + expect(initialPositions[0].component).to.eq(perpV2Setup.usdc.address); + expect(initialPositions[0].positionState).to.eq(ZERO); // Deafult + + // One default WETH position and one external USDC position after engage + expect(finalPositions.length).to.eq(2); + expect(finalPositions[0].component).to.eq(systemSetup.weth.address); + expect(finalPositions[0].positionState).to.eq(ZERO); // Deafult + expect(finalPositions[0].unit).to.eq(expectedSpotAssetUnit); + + // Verify deposit + expect(currentUsdcBalanceInPerp).to.closeTo(expectedUsdcDeposited, 100); // occours due to dust amounts + }); + + it("should open a base token position on Perpetual Protocol", async () => { + const initialPositions = await perpBasisTradingModule.getPositionNotionalInfo(setToken.address); + const totalRebalanceNotional = await getTotalEngageRebalanceNotional(setToken); + + await subject(); + + const finalPositions = await perpBasisTradingModule.getPositionNotionalInfo(setToken.address); + + expect(initialPositions.length).to.eq(0); + expect(finalPositions.length).to.eq(1); + expect(finalPositions[0].baseBalance).to.eq(totalRebalanceNotional); + expect(finalPositions[0].baseToken).to.eq(strategy.virtualBaseAddress); + }); + + it("should NOT set the TWAP leverage ratio", async () => { + await subject(); + + const twapLeverageRatio = await leverageStrategyExtension.twapLeverageRatio(); + + expect(twapLeverageRatio).to.eq(ZERO); + }); + + it("should deposit all USDC to PerpV2", async () => { + await subject(); + + const usdcPositionUnit = await setToken.getDefaultPositionRealUnit(perpV2Setup.usdc.address); + expect(usdcPositionUnit).to.eq(ZERO); + }); + + it("should emit Engaged event", async () => { + const totalRebalanceNotional = await getTotalEngageRebalanceNotional(setToken); + await expect(subject()).to.emit(leverageStrategyExtension, "Engaged").withArgs( + ZERO, + methodology.targetLeverageRatio, + totalRebalanceNotional, + totalRebalanceNotional, + ); + }); + }); + + context("when rebalance notional is greater than max trade size", async () => { + let newExchangeSettings: PerpV2BasisExchangeSettings; + + beforeEach(async () => { + newExchangeSettings = { + exchangeName: exchange.exchangeName, + buyExactSpotTradeData: exchange.buyExactSpotTradeData, + sellExactSpotTradeData: exchange.sellExactSpotTradeData, + buySpotQuoteExactInputPath: exchange.buySpotQuoteExactInputPath, + twapMaxTradeSize: ether(1), + incentivizedTwapMaxTradeSize: ether(1), + }; + await leverageStrategyExtension.setExchangeSettings(newExchangeSettings); + }); + + it("should set the last trade timestamp", async () => { + await subject(); + + const lastTradeTimestamp = await leverageStrategyExtension.lastTradeTimestamp(); + + expect(lastTradeTimestamp).to.eq(await getLastBlockTimestamp()); + }); + + it("should set the TWAP leverage ratio", async () => { + await subject(); + + const twapLeverageRatio = await leverageStrategyExtension.twapLeverageRatio(); + + expect(twapLeverageRatio).to.eq(methodology.targetLeverageRatio); + }); + + it("should trade USDC for spot asset on UniswapV3 and deposit the rest to Perpetual protocol", async () => { + const initialPositions = await setToken.getPositions(); + + // Determine expected spot asset unit + const totalSupply = await setToken.totalSupply(); + const totalRebalanceNotional = newExchangeSettings.twapMaxTradeSize.mul(-1); + const expectedSpotAssetUnit = preciseDiv(totalRebalanceNotional.abs(), totalSupply); + + // Determine expected USDC unit + const usdcBalanceBefore = await perpV2Setup.usdc.balanceOf(setToken.address); + const amountIn = await uniV3Setup.quoter.callStatic.quoteExactOutputSingle( + perpV2Setup.usdc.address, + systemSetup.weth.address, + 3000, + totalRebalanceNotional.abs(), + 0 + ); + const expectedUsdcDeposited = usdcBalanceBefore.sub(amountIn); + + await subject(); + + const finalPositions = await setToken.getPositions(); + const currentUsdcBalanceInPerp = await perpV2Setup.vault.getBalance(setToken.address); + + // One default USDC position before engage + expect(initialPositions.length).to.eq(1); + expect(initialPositions[0].component).to.eq(perpV2Setup.usdc.address); + expect(initialPositions[0].positionState).to.eq(ZERO); // Deafult + + // One default WETH position and one external USDC position after engage + expect(finalPositions.length).to.eq(2); + expect(finalPositions[0].component).to.eq(systemSetup.weth.address); + expect(finalPositions[0].positionState).to.eq(ZERO); // Deafult + expect(finalPositions[0].unit).to.eq(expectedSpotAssetUnit); + + // Verify deposit + expect(currentUsdcBalanceInPerp).to.closeTo(expectedUsdcDeposited, 100); // occours due to dust amounts + }); + + it("should open a base token position on Perpetual Protocol", async () => { + const initialPositions = await perpBasisTradingModule.getPositionNotionalInfo(setToken.address); + const totalRebalanceNotional = newExchangeSettings.twapMaxTradeSize.mul(-1); + + await subject(); + + const finalPositions = await perpBasisTradingModule.getPositionNotionalInfo(setToken.address); + + expect(initialPositions.length).to.eq(0); + expect(finalPositions.length).to.eq(1); + expect(finalPositions[0].baseBalance).to.eq(totalRebalanceNotional); + expect(finalPositions[0].baseToken).to.eq(strategy.virtualBaseAddress); + }); + + it("should emit Engaged event", async () => { + const chunkRebalanceNotional = newExchangeSettings.twapMaxTradeSize.mul(-1); + const totalRebalanceNotional = await getTotalEngageRebalanceNotional(setToken); + + await expect(subject()).to.emit(leverageStrategyExtension, "Engaged").withArgs( + ZERO, + methodology.targetLeverageRatio, + chunkRebalanceNotional, + totalRebalanceNotional, + ); + }); + + it("should deposit all USDC to PerpV2", async () => { + await subject(); + + const usdcPositionUnit = await setToken.getDefaultPositionRealUnit(perpV2Setup.usdc.address); + expect(usdcPositionUnit).to.eq(ZERO); + }); + + describe("when the caller is not the operator", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be operator"); + }); + }); + }); + + describe("when collateral balance is non-zero", async () => { + beforeEach(async () => { + await leverageStrategyExtension.deposit(usdc(1)); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("PerpV2 collateral balance must be 0"); + }); + }); + }); + + describe("#rebalance", async () => { + let subjectCaller: Account; + + beforeEach(async () => { + subjectCaller = owner; + }); + + async function subject(): Promise { + return leverageStrategyExtension.connect(subjectCaller.wallet).rebalance(); + } + + describe("when engaged", async () => { + cacheBeforeEach(async () => { + // Engage short position + await leverageStrategyExtension.engage(); + }); + + describe("when current leverage ratio is below target (lever), does not need a TWAP, and is inside bounds", async () => { + cacheBeforeEach(async () => { + await perpV2Setup.setBaseTokenOraclePrice(perpV2Setup.vETH, usdc(950)); + await perpV2PriceFeedMock.setPrice(BigNumber.from(950).mul(10 ** 8)); + + await increaseTimeAsync(ONE_DAY_IN_SECONDS); + }); + + it("should verify initial leverage conditions", async () => { + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + expect(currentLeverageRatio.abs()).to.be.gt(methodology.minLeverageRatio.abs()); + expect(currentLeverageRatio.abs()).to.be.lt(methodology.targetLeverageRatio.abs()); + }); + + it("should set the global last trade timestamp", async () => { + await subject(); + + const lastTradeTimestamp = await leverageStrategyExtension.lastTradeTimestamp(); + + expect(lastTradeTimestamp).to.eq(await getLastBlockTimestamp()); + }); + + it("should not set the TWAP leverage ratio", async () => { + await subject(); + + const twapLeverageRatio = await leverageStrategyExtension.twapLeverageRatio(); + + expect(twapLeverageRatio).to.eq(ZERO); + }); + + it.skip("should update the baseToken position (PerpV2) on the SetToken correctly", async () => { + const initialPositions = await perpBasisTradingModule.getPositionNotionalInfo(setToken.address); + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + + const expectedNewLeverageRatio = calculateNewLeverageRatioPerpV2Basis( + currentLeverageRatio, + methodology + ); + + const newPositions = await perpBasisTradingModule.getPositionUnitInfo(setToken.address); + const updatedPosition = newPositions[0]; + + const totalRebalanceNotional = preciseDiv( + preciseMul(initialPositions[0].baseBalance, expectedNewLeverageRatio.sub(currentLeverageRatio)), // numerator + preciseMul(currentLeverageRatio, ether(1).sub(expectedNewLeverageRatio)) // denominator + ); + + const totalSupply = await setToken.totalSupply(); + const expectedNewPositionUnit = preciseDiv(initialPositions[0].baseBalance.add(totalRebalanceNotional), totalSupply); + + expect(initialPositions.length).to.eq(1); + expect(newPositions.length).to.eq(1); + expect(updatedPosition.baseToken).to.eq(perpV2Setup.vETH.address); + expect(updatedPosition.baseUnit).to.eq(expectedNewPositionUnit); + }); + + it("should update the spot asset positoin unit on the SetToken correctly", async () => { + const initialPositions = await perpBasisTradingModule.getPositionNotionalInfo(setToken.address); + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + + const expectedNewLeverageRatio = calculateNewLeverageRatioPerpV2Basis( + currentLeverageRatio, + methodology + ); + const spotAssetUnitBefore = await setToken.getDefaultPositionRealUnit(spotAsset.address); + const totalSupply = await setToken.totalSupply(); + + await subject(); + + const totalRebalanceNotional = preciseDiv( + preciseMul(initialPositions[0].baseBalance, expectedNewLeverageRatio.sub(currentLeverageRatio)), // numerator + preciseMul(currentLeverageRatio, ether(1).sub(expectedNewLeverageRatio)) // denominator + ); + + const expectedNewPositionUnit = preciseDiv( + preciseMul(spotAssetUnitBefore, totalSupply).add(totalRebalanceNotional.abs()), + totalSupply + ); + const actualNewPositionUnit = await setToken.getDefaultPositionRealUnit(spotAsset.address); + + expect(actualNewPositionUnit).to.eq(expectedNewPositionUnit); + }); + + it("should deposit all USDC to PerpV2", async () => { + await subject(); + + const usdcPositionUnit = await setToken.getDefaultPositionRealUnit(perpV2Setup.usdc.address); + expect(usdcPositionUnit).to.eq(ZERO); + }); + + it("should emit Rebalanced event", async () => { + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + const expectedNewLeverageRatio = calculateNewLeverageRatioPerpV2Basis( + currentLeverageRatio, + methodology + ); + + const initialPositions = await perpBasisTradingModule.getPositionNotionalInfo(setToken.address); + const totalRebalanceNotional = preciseDiv( + preciseMul(initialPositions[0].baseBalance, expectedNewLeverageRatio.sub(currentLeverageRatio)), // numerator + preciseMul(currentLeverageRatio, ether(1).sub(expectedNewLeverageRatio)) // denominator + ); + await expect(subject()).to.emit(leverageStrategyExtension, "Rebalanced").withArgs( + currentLeverageRatio, + expectedNewLeverageRatio, + totalRebalanceNotional, + totalRebalanceNotional, + ); + }); + }); + + describe("when rebalance interval has not elapsed but is below min leverage ratio and lower than max trade size", async () => { + beforeEach(async () => { + await perpV2Setup.setBaseTokenOraclePrice(perpV2Setup.vETH, usdc(900)); + await perpV2PriceFeedMock.setPrice(BigNumber.from(900).mul(10 ** 8)); + }); + + it("should verify initial leverage conditions", async () => { + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + expect(currentLeverageRatio.abs()).to.be.lt(methodology.minLeverageRatio.abs()); + }); + + it("should set the last trade timestamp", async () => { + await subject(); + + const lastTradeTimestamp = await leverageStrategyExtension.lastTradeTimestamp(); + + expect(lastTradeTimestamp).to.eq(await getLastBlockTimestamp()); + }); + + it("should not set the TWAP leverage ratio", async () => { + await subject(); + + const twapLeverageRatio = await leverageStrategyExtension.twapLeverageRatio(); + + expect(twapLeverageRatio).to.eq(ZERO); + }); + + it("should update the baseToken position (PerpV2) on the SetToken correctly", async () => { + const initialPositions = await perpBasisTradingModule.getPositionNotionalInfo(setToken.address); + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + + const expectedNewLeverageRatio = calculateNewLeverageRatioPerpV2Basis( + currentLeverageRatio, + methodology + ); + + await subject(); + + const newPositions = await perpBasisTradingModule.getPositionUnitInfo(setToken.address); + const updatedPosition = newPositions[0]; + + const totalRebalanceNotional = preciseDiv( + preciseMul(initialPositions[0].baseBalance, expectedNewLeverageRatio.sub(currentLeverageRatio)), // numerator + preciseMul(currentLeverageRatio, ether(1).sub(expectedNewLeverageRatio)) // denominator + ); + + const totalSupply = await setToken.totalSupply(); + const expectedNewPositionUnit = preciseDiv(initialPositions[0].baseBalance.add(totalRebalanceNotional), totalSupply); + + expect(initialPositions.length).to.eq(1); + expect(newPositions.length).to.eq(1); + expect(updatedPosition.baseToken).to.eq(perpV2Setup.vETH.address); + expect(updatedPosition.baseUnit).to.eq(expectedNewPositionUnit); + }); + + it("should update the spot asset position unit on the SetToken correctly", async () => { + const initialPositions = await perpBasisTradingModule.getPositionNotionalInfo(setToken.address); + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + + const expectedNewLeverageRatio = calculateNewLeverageRatioPerpV2Basis( + currentLeverageRatio, + methodology + ); + const spotAssetUnitBefore = await setToken.getDefaultPositionRealUnit(spotAsset.address); + const totalSupply = await setToken.totalSupply(); + + await subject(); + + const totalRebalanceNotional = preciseDiv( + preciseMul(initialPositions[0].baseBalance, expectedNewLeverageRatio.sub(currentLeverageRatio)), // numerator + preciseMul(currentLeverageRatio, ether(1).sub(expectedNewLeverageRatio)) // denominator + ); + + const expectedNewPositionUnit = preciseDiv( + preciseMul(spotAssetUnitBefore, totalSupply).add(totalRebalanceNotional.abs()), + totalSupply + ); + const actualNewPositionUnit = await setToken.getDefaultPositionRealUnit(spotAsset.address); + + expect(actualNewPositionUnit).to.eq(expectedNewPositionUnit); + }); + + it("should deposit all USDC to PerpV2", async () => { + await subject(); + + const usdcPositionUnit = await setToken.getDefaultPositionRealUnit(perpV2Setup.usdc.address); + expect(usdcPositionUnit).to.eq(ZERO); + }); + }); + + describe("when rebalance interval has not elapsed below min leverage ratio and greater than max trade size", async () => { + let newExchangeSettings: PerpV2BasisExchangeSettings; + + cacheBeforeEach(async () => { + await perpV2Setup.setBaseTokenOraclePrice(perpV2Setup.vETH, usdc(900)); + await perpV2PriceFeedMock.setPrice(BigNumber.from(900).mul(10 ** 8)); + + newExchangeSettings = { + ...exchange, + twapMaxTradeSize: ether(.1), + incentivizedTwapMaxTradeSize: ether(2), + }; + await leverageStrategyExtension.setExchangeSettings(newExchangeSettings); + }); + + it("should verify initial leverage conditions", async () => { + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + expect(currentLeverageRatio.abs()).to.be.lt(methodology.minLeverageRatio.abs()); + }); + + it("should set the last trade timestamp", async () => { + await subject(); + + const lastTradeTimestamp = await leverageStrategyExtension.lastTradeTimestamp(); + + expect(lastTradeTimestamp).to.eq(await getLastBlockTimestamp()); + }); + + it("should set the TWAP leverage ratio", async () => { + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + const previousTwapLeverageRatio = await leverageStrategyExtension.twapLeverageRatio(); + + await subject(); + + const currentTwapLeverageRatio = await leverageStrategyExtension.twapLeverageRatio(); + + const expectedNewLeverageRatio = calculateNewLeverageRatioPerpV2Basis( + currentLeverageRatio, + methodology + ); + expect(previousTwapLeverageRatio).to.eq(ZERO); + expect(currentTwapLeverageRatio).to.eq(expectedNewLeverageRatio); + }); + + it("should update the baseToken position (PerpV2) on the SetToken correctly", async () => { + const initialPositions = await perpBasisTradingModule.getPositionNotionalInfo(setToken.address); + + await subject(); + + const newPositions = await perpBasisTradingModule.getPositionUnitInfo(setToken.address); + const updatedPosition = newPositions[0]; + + + const totalSupply = await setToken.totalSupply(); + const expectedNewPositionUnit = preciseDiv(initialPositions[0].baseBalance + .add(newExchangeSettings.twapMaxTradeSize.mul(-1)), totalSupply); + + expect(initialPositions.length).to.eq(1); + expect(newPositions.length).to.eq(1); + expect(updatedPosition.baseToken).to.eq(perpV2Setup.vETH.address); + expect(updatedPosition.baseUnit).to.eq(expectedNewPositionUnit); + }); + + it("should update the spot asset positoin unit on the SetToken correctly", async () => { + const spotAssetUnitBefore = await setToken.getDefaultPositionRealUnit(spotAsset.address); + const totalSupply = await setToken.totalSupply(); + + await subject(); + + const expectedNewPositionUnit = preciseDiv( + preciseMul(spotAssetUnitBefore, totalSupply).add(newExchangeSettings.twapMaxTradeSize.abs()), + totalSupply + ); + const actualNewPositionUnit = await setToken.getDefaultPositionRealUnit(spotAsset.address); + + expect(actualNewPositionUnit).to.eq(expectedNewPositionUnit); + }); + + it("should deposit all USDC to PerpV2", async () => { + await subject(); + + const usdcPositionUnit = await setToken.getDefaultPositionRealUnit(perpV2Setup.usdc.address); + expect(usdcPositionUnit).to.eq(ZERO); + }); + }); + + describe("when rebalance interval has not elapsed and within bounds", async () => { + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Cooldown not elapsed or not valid leverage ratio"); + }); + }); + + context("when current leverage ratio is above target (delever)", async () => { + cacheBeforeEach(async () => { + await perpV2PriceFeedMock.setPrice(BigNumber.from(1030).mul(10 ** 8)); + await perpV2Setup.setBaseTokenOraclePrice(perpV2Setup.vETH, usdc(1030)); + + await increaseTimeAsync(ONE_DAY_IN_SECONDS); + }); + + it("should verify initial leverage conditions", async () => { + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + expect(currentLeverageRatio.abs()).to.be.gt(methodology.targetLeverageRatio.abs()); + }); + + it("should set the last trade timestamp", async () => { + await subject(); + + const lastTradeTimestamp = await leverageStrategyExtension.lastTradeTimestamp(); + + expect(lastTradeTimestamp).to.eq(await getLastBlockTimestamp()); + }); + + it("should not set the TWAP leverage ratio", async () => { + await subject(); + + const twapLeverageRatio = await leverageStrategyExtension.twapLeverageRatio(); + + expect(twapLeverageRatio).to.eq(ZERO); + }); + + it("should update the baseToken position on the SetToken correctly", async () => { + const totalSupply = await setToken.totalSupply(); + const initialPositions = await perpBasisTradingModule.getPositionNotionalInfo(setToken.address); + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + const expectedNewLeverageRatio = calculateNewLeverageRatioPerpV2Basis( + currentLeverageRatio, + methodology + ); + + await subject(); + + const newPositions = await perpBasisTradingModule.getPositionUnitInfo(setToken.address); + const updatedPosition = newPositions[0]; + + const totalRebalanceNotional = preciseDiv( + preciseMul(initialPositions[0].baseBalance, expectedNewLeverageRatio.sub(currentLeverageRatio)), // numerator + preciseMul(currentLeverageRatio, ether(1).sub(expectedNewLeverageRatio)) // denominator + ); + + const expectedNewPositionUnit = preciseDiv(initialPositions[0].baseBalance.add(totalRebalanceNotional), totalSupply); + + expect(initialPositions.length).to.eq(1); + expect(newPositions.length).to.eq(1); + expect(updatedPosition.baseToken).to.eq(perpV2Setup.vETH.address); + expect(updatedPosition.baseUnit).to.closeTo(expectedNewPositionUnit, 1); + }); + + it("should update the spot asset position unit on the SetToken correctly", async () => { + const initialPositions = await perpBasisTradingModule.getPositionNotionalInfo(setToken.address); + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + + const expectedNewLeverageRatio = calculateNewLeverageRatioPerpV2Basis( + currentLeverageRatio, + methodology + ); + const spotAssetUnitBefore = await setToken.getDefaultPositionRealUnit(spotAsset.address); + const totalSupply = await setToken.totalSupply(); + + await subject(); + + const totalRebalanceNotional = preciseDiv( + preciseMul(initialPositions[0].baseBalance, expectedNewLeverageRatio.sub(currentLeverageRatio)), // numerator + preciseMul(currentLeverageRatio, ether(1).sub(expectedNewLeverageRatio)) // denominator + ); + + const expectedNewPositionUnit = preciseDiv( + preciseMul(spotAssetUnitBefore, totalSupply).sub(totalRebalanceNotional.abs()), + totalSupply + ); + const actualNewPositionUnit = await setToken.getDefaultPositionRealUnit(spotAsset.address); + + expect(actualNewPositionUnit).to.closeTo(expectedNewPositionUnit, 1); + }); + + it("should deposit all USDC to PerpV2", async () => { + await subject(); + + const usdcPositionUnit = await setToken.getDefaultPositionRealUnit(perpV2Setup.usdc.address); + expect(usdcPositionUnit).to.eq(ZERO); + }); + }); + + describe("when rebalance interval has not elapsed, above max leverage ratio and lower than max trade size", async () => { + cacheBeforeEach(async () => { + await perpV2Setup.setBaseTokenOraclePrice(perpV2Setup.vETH, usdc(1060)); + await perpV2PriceFeedMock.setPrice(BigNumber.from(1060).mul(10 ** 8)); + }); + + it("should verify initial leverage conditions", async () => { + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + expect(currentLeverageRatio.abs()).to.be.gt(methodology.maxLeverageRatio.abs()); + }); + + it("should set the last trade timestamp", async () => { + await subject(); + + const lastTradeTimestamp = await leverageStrategyExtension.lastTradeTimestamp(); + + expect(lastTradeTimestamp).to.eq(await getLastBlockTimestamp()); + }); + + it("should not set the TWAP leverage ratio", async () => { + await subject(); + + const twapLeverageRatio = await leverageStrategyExtension.twapLeverageRatio(); + + expect(twapLeverageRatio).to.eq(ZERO); + }); + + it("should update the baseToken position on the SetToken correctly", async () => { + const totalSupply = await setToken.totalSupply(); + const initialPositions = await perpBasisTradingModule.getPositionNotionalInfo(setToken.address); + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + const expectedNewLeverageRatio = calculateNewLeverageRatioPerpV2Basis( + currentLeverageRatio, + methodology + ); + + await subject(); + + const newPositions = await perpBasisTradingModule.getPositionUnitInfo(setToken.address); + const updatedPosition = newPositions[0]; + + const totalRebalanceNotional = preciseDiv( + preciseMul(initialPositions[0].baseBalance, expectedNewLeverageRatio.sub(currentLeverageRatio)), // numerator + preciseMul(currentLeverageRatio, ether(1).sub(expectedNewLeverageRatio)) // denominator + ); + + const expectedNewPositionUnit = preciseDiv(initialPositions[0].baseBalance.add(totalRebalanceNotional), totalSupply); + + expect(initialPositions.length).to.eq(1); + expect(newPositions.length).to.eq(1); + expect(updatedPosition.baseToken).to.eq(perpV2Setup.vETH.address); + expect(updatedPosition.baseUnit).to.closeTo(expectedNewPositionUnit, 1); + }); + + it("should update the spot asset position unit on the SetToken correctly", async () => { + const initialPositions = await perpBasisTradingModule.getPositionNotionalInfo(setToken.address); + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + + const expectedNewLeverageRatio = calculateNewLeverageRatioPerpV2Basis( + currentLeverageRatio, + methodology + ); + const spotAssetUnitBefore = await setToken.getDefaultPositionRealUnit(spotAsset.address); + const totalSupply = await setToken.totalSupply(); + + await subject(); + + const totalRebalanceNotional = preciseDiv( + preciseMul(initialPositions[0].baseBalance, expectedNewLeverageRatio.sub(currentLeverageRatio)), // numerator + preciseMul(currentLeverageRatio, ether(1).sub(expectedNewLeverageRatio)) // denominator + ); + + const expectedNewPositionUnit = preciseDiv( + preciseMul(spotAssetUnitBefore, totalSupply).sub(totalRebalanceNotional.abs()), + totalSupply + ); + const actualNewPositionUnit = await setToken.getDefaultPositionRealUnit(spotAsset.address); + + expect(actualNewPositionUnit).to.closeTo(expectedNewPositionUnit, 1); + }); + + it("should deposit all USDC to PerpV2", async () => { + await subject(); + + const usdcPositionUnit = await setToken.getDefaultPositionRealUnit(perpV2Setup.usdc.address); + expect(usdcPositionUnit).to.eq(ZERO); + }); + }); + + describe("when rebalance interval has not elapsed, above max leverage ratio and greater than max trade size", async () => { + let newExchangeSettings: PerpV2BasisExchangeSettings; + + cacheBeforeEach(async () => { + await perpV2Setup.setBaseTokenOraclePrice(perpV2Setup.vETH, usdc(1060)); + await perpV2PriceFeedMock.setPrice(BigNumber.from(1060).mul(10 ** 8)); + + newExchangeSettings = { + ...exchange, + twapMaxTradeSize: ether(.1), + incentivizedTwapMaxTradeSize: ether(2), + }; + await leverageStrategyExtension.setExchangeSettings(newExchangeSettings); + }); + + it("should verify initial leverage conditions", async () => { + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + expect(currentLeverageRatio.abs()).to.be.gt(methodology.maxLeverageRatio.abs()); + }); + + it("should set the last trade timestamp", async () => { + await subject(); + + const lastTradeTimestamp = await leverageStrategyExtension.lastTradeTimestamp(); + + expect(lastTradeTimestamp).to.eq(await getLastBlockTimestamp()); + }); + + it("should set the TWAP leverage ratio", async () => { + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + const previousTwapLeverageRatio = await leverageStrategyExtension.twapLeverageRatio(); + + await subject(); + + const currentTwapLeverageRatio = await leverageStrategyExtension.twapLeverageRatio(); + + const expectedNewLeverageRatio = calculateNewLeverageRatioPerpV2Basis( + currentLeverageRatio, + methodology + ); + expect(ZERO).to.eq(previousTwapLeverageRatio); + expect(expectedNewLeverageRatio).to.eq(currentTwapLeverageRatio); + }); + + it("should update the baseToken position on the SetToken correctly", async () => { + const totalSupply = await setToken.totalSupply(); + const initialPositions = await perpBasisTradingModule.getPositionNotionalInfo(setToken.address); + + await subject(); + + const newPositions = await perpBasisTradingModule.getPositionUnitInfo(setToken.address); + const updatedPosition = newPositions[0]; + const expectedNewPositionUnit = preciseDiv(initialPositions[0].baseBalance.add(newExchangeSettings.twapMaxTradeSize), totalSupply); + + expect(initialPositions.length).to.eq(1); + expect(newPositions.length).to.eq(1); + expect(updatedPosition.baseToken).to.eq(perpV2Setup.vETH.address); + expect(updatedPosition.baseUnit).to.closeTo(expectedNewPositionUnit, 1); + }); + + it("should update the spot asset position unit on the SetToken correctly", async () => { + const spotAssetUnitBefore = await setToken.getDefaultPositionRealUnit(spotAsset.address); + const totalSupply = await setToken.totalSupply(); + + await subject(); + + const expectedNewPositionUnit = preciseDiv( + preciseMul(spotAssetUnitBefore, totalSupply).sub(newExchangeSettings.twapMaxTradeSize.abs()), + totalSupply + ); + const actualNewPositionUnit = await setToken.getDefaultPositionRealUnit(spotAsset.address); + + expect(actualNewPositionUnit).to.closeTo(expectedNewPositionUnit, 1); + }); + + it("should deposit all USDC to PerpV2", async () => { + await subject(); + + const usdcPositionUnit = await setToken.getDefaultPositionRealUnit(perpV2Setup.usdc.address); + expect(usdcPositionUnit).to.eq(ZERO); + }); + + describe("when in a TWAP rebalance", async () => { + beforeEach(async () => { + await subject(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must call iterate"); + }); + }); + }); + }); + + describe("when not engaged", async () => { + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Current leverage ratio must NOT be 0"); + }); + }); + }); + + describe("#iterateRebalance", async () => { + let subjectCaller: Account; + + beforeEach(async () => { + subjectCaller = owner; + }); + + async function subject(): Promise { + return leverageStrategyExtension.connect(subjectCaller.wallet).iterateRebalance(); + } + + describe("when engaged", async () => { + cacheBeforeEach(async () => { + // Engage short position + await leverageStrategyExtension.engage(); + }); + + context("when currently in the last chunk of a TWAP rebalance", async () => { + let newExchangeSettings: PerpV2BasisExchangeSettings; + let preTwapLeverageRatio: BigNumber; + + cacheBeforeEach(async () => { + await increaseTimeAsync(ONE_DAY_IN_SECONDS); + await perpV2Setup.setBaseTokenOraclePrice(perpV2Setup.vETH, usdc(920)); + await perpV2PriceFeedMock.setPrice(BigNumber.from(920).mul(10 ** 8)); + + newExchangeSettings = { + ...exchange, + twapMaxTradeSize: ether(.06), + incentivizedTwapMaxTradeSize: ether(1) + }; + preTwapLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + await leverageStrategyExtension.setExchangeSettings(newExchangeSettings); + await leverageStrategyExtension.connect(owner.wallet).rebalance(); + await increaseTimeAsync(BigNumber.from(4000)); // >3s (twapCoolDown period) + }); + + it("should verify initial leverage conditions", async () => { + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + const twapLeverageRatio = await leverageStrategyExtension.twapLeverageRatio(); + expect(currentLeverageRatio.abs()).to.be.lt(methodology.minLeverageRatio.abs()); + expect(twapLeverageRatio.abs()).to.be.eq(methodology.minLeverageRatio.abs()); + }); + + it("should set the global last trade timestamp", async () => { + await subject(); + + const lastTradeTimestamp = await leverageStrategyExtension.lastTradeTimestamp(); + + expect(lastTradeTimestamp).to.eq(await getLastBlockTimestamp()); + }); + + it("should remove the TWAP leverage ratio", async () => { + await subject(); + + const twapLeverageRatio = await leverageStrategyExtension.twapLeverageRatio(); + + expect(twapLeverageRatio).to.eq(ZERO); + }); + + it("should update the baseToken position on the SetToken correctly", async () => { + const initialPositions = await perpBasisTradingModule.getPositionNotionalInfo(setToken.address); + + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + + const expectedNewLeverageRatio = calculateNewLeverageRatioPerpV2Basis( + preTwapLeverageRatio, + methodology + ); + + await subject(); + + const newPositions = await perpBasisTradingModule.getPositionUnitInfo(setToken.address); + const newPosition = newPositions[0]; + + const totalRebalanceNotional = preciseDiv( + preciseMul(initialPositions[0].baseBalance, expectedNewLeverageRatio.sub(currentLeverageRatio)), // numerator + preciseMul(currentLeverageRatio, ether(1).sub(expectedNewLeverageRatio)) // denominator + ); + + const chunkRebalanceNotional = totalRebalanceNotional.abs().gt(newExchangeSettings.twapMaxTradeSize) + ? (totalRebalanceNotional.gt(ZERO) ? newExchangeSettings.twapMaxTradeSize : newExchangeSettings.twapMaxTradeSize.mul(-1)) + : totalRebalanceNotional; + + const totalSupply = await setToken.totalSupply(); + const expectedNewPositionUnit = preciseDiv(initialPositions[0].baseBalance.add(chunkRebalanceNotional), totalSupply); + + expect(initialPositions.length).to.eq(1); + expect(newPositions.length).to.eq(1); + expect(newPosition.baseToken).to.eq(perpV2Setup.vETH.address); + expect(newPosition.baseUnit).to.eq(expectedNewPositionUnit); + }); + + it("should update the spot asset position unit on the SetToken correctly", async () => { + const initialPositions = await perpBasisTradingModule.getPositionNotionalInfo(setToken.address); + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + + const expectedNewLeverageRatio = calculateNewLeverageRatioPerpV2Basis( + currentLeverageRatio, + methodology + ); + const spotAssetUnitBefore = await setToken.getDefaultPositionRealUnit(spotAsset.address); + const totalSupply = await setToken.totalSupply(); + + await subject(); + + const totalRebalanceNotional = preciseDiv( + preciseMul(initialPositions[0].baseBalance, expectedNewLeverageRatio.sub(currentLeverageRatio)), // numerator + preciseMul(currentLeverageRatio, ether(1).sub(expectedNewLeverageRatio)) // denominator + ); + + const chunkRebalanceNotional = totalRebalanceNotional.abs().gt(newExchangeSettings.twapMaxTradeSize) + ? (totalRebalanceNotional.gt(ZERO) ? newExchangeSettings.twapMaxTradeSize : newExchangeSettings.twapMaxTradeSize.mul(-1)) + : totalRebalanceNotional; + + const expectedNewPositionUnit = preciseDiv( + preciseMul(spotAssetUnitBefore, totalSupply).add(chunkRebalanceNotional.abs()), + totalSupply + ); + const actualNewPositionUnit = await setToken.getDefaultPositionRealUnit(spotAsset.address); + + expect(actualNewPositionUnit).to.eq(expectedNewPositionUnit); + }); + + it("should deposit all USDC to PerpV2", async () => { + await subject(); + + const usdcPositionUnit = await setToken.getDefaultPositionRealUnit(perpV2Setup.usdc.address); + expect(usdcPositionUnit).to.eq(ZERO); + }); + }); + + context("when current leverage ratio is below target and in the middle of a TWAP", async () => { + let newExchangeSettings: PerpV2BasisExchangeSettings; + let preTwapLeverageRatio: BigNumber; + + cacheBeforeEach(async () => { + await increaseTimeAsync(ONE_DAY_IN_SECONDS); + await perpV2Setup.setBaseTokenOraclePrice(perpV2Setup.vETH, usdc(900)); + await perpV2PriceFeedMock.setPrice(BigNumber.from(900).mul(10 ** 8)); + + newExchangeSettings = { + ...exchange, + twapMaxTradeSize: ether(.01), + // -.205422934289947960 + incentivizedTwapMaxTradeSize: ether(1) + }; + await leverageStrategyExtension.setExchangeSettings(newExchangeSettings); + + preTwapLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + await leverageStrategyExtension.connect(owner.wallet).rebalance(); + await increaseTimeAsync(BigNumber.from(4000)); // >3s (twapCoolDown period) + }); + + it("should verify initial leverage conditions", async () => { + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + const twapLeverageRatio = await leverageStrategyExtension.twapLeverageRatio(); + expect(currentLeverageRatio.abs()).to.be.lt(methodology.targetLeverageRatio.abs()); + expect(twapLeverageRatio.abs()).to.be.gt(ZERO); + }); + + it("should set the global last trade timestamp", async () => { + await subject(); + + const lastTradeTimestamp = await leverageStrategyExtension.lastTradeTimestamp(); + + expect(lastTradeTimestamp).to.eq(await getLastBlockTimestamp()); + }); + + it("should set the TWAP leverage ratio", async () => { + const previousTwapLeverageRatio = await leverageStrategyExtension.twapLeverageRatio(); + + await subject(); + + const currentTwapLeverageRatio = await leverageStrategyExtension.twapLeverageRatio(); + + const expectedNewLeverageRatio = calculateNewLeverageRatioPerpV2Basis( + preTwapLeverageRatio, + methodology + ); + expect(previousTwapLeverageRatio).to.eq(expectedNewLeverageRatio); + expect(currentTwapLeverageRatio).to.eq(expectedNewLeverageRatio); + }); + + it("should update the baseToken position on the SetToken correctly", async () => { + const initialPositions = await perpBasisTradingModule.getPositionNotionalInfo(setToken.address); + + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + + const expectedNewLeverageRatio = calculateNewLeverageRatioPerpV2Basis( + preTwapLeverageRatio, + methodology + ); + + await subject(); + + const newPositions = await perpBasisTradingModule.getPositionUnitInfo(setToken.address); + const newPosition = newPositions[0]; + + const totalRebalanceNotional = preciseDiv( + preciseMul(initialPositions[0].baseBalance, expectedNewLeverageRatio.sub(currentLeverageRatio)), // numerator + preciseMul(currentLeverageRatio, ether(1).sub(expectedNewLeverageRatio)) // denominator + ); + + const chunkRebalanceNotional = totalRebalanceNotional.abs().gt(newExchangeSettings.twapMaxTradeSize) + ? (totalRebalanceNotional.gt(ZERO) ? newExchangeSettings.twapMaxTradeSize : newExchangeSettings.twapMaxTradeSize.mul(-1)) + : totalRebalanceNotional; + + + const totalSupply = await setToken.totalSupply(); + const expectedNewPositionUnit = preciseDiv(initialPositions[0].baseBalance.add(chunkRebalanceNotional), totalSupply); + + expect(initialPositions.length).to.eq(1); + expect(newPositions.length).to.eq(1); + expect(newPosition.baseToken).to.eq(perpV2Setup.vETH.address); + expect(newPosition.baseUnit).to.closeTo(expectedNewPositionUnit, 1); + }); + + it("should update the spot asset position unit on the SetToken correctly", async () => { + const initialPositions = await perpBasisTradingModule.getPositionNotionalInfo(setToken.address); + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + + const expectedNewLeverageRatio = calculateNewLeverageRatioPerpV2Basis( + currentLeverageRatio, + methodology + ); + const spotAssetUnitBefore = await setToken.getDefaultPositionRealUnit(spotAsset.address); + const totalSupply = await setToken.totalSupply(); + + await subject(); + + const totalRebalanceNotional = preciseDiv( + preciseMul(initialPositions[0].baseBalance, expectedNewLeverageRatio.sub(currentLeverageRatio)), // numerator + preciseMul(currentLeverageRatio, ether(1).sub(expectedNewLeverageRatio)) // denominator + ); + + const chunkRebalanceNotional = totalRebalanceNotional.abs().gt(newExchangeSettings.twapMaxTradeSize) + ? (totalRebalanceNotional.gt(ZERO) ? newExchangeSettings.twapMaxTradeSize : newExchangeSettings.twapMaxTradeSize.mul(-1)) + : totalRebalanceNotional; + + const expectedNewPositionUnit = preciseDiv( + preciseMul(spotAssetUnitBefore, totalSupply).add(chunkRebalanceNotional.abs()), + totalSupply + ); + const actualNewPositionUnit = await setToken.getDefaultPositionRealUnit(spotAsset.address); + + expect(actualNewPositionUnit).to.eq(expectedNewPositionUnit); + }); + + it("should deposit all USDC to PerpV2", async () => { + await subject(); + + const usdcPositionUnit = await setToken.getDefaultPositionRealUnit(perpV2Setup.usdc.address); + expect(usdcPositionUnit).to.eq(ZERO); + }); + }); + + context("when current leverage ratio is above target and in the middle of a TWAP", async () => { + let newExchangeSettings: PerpV2BasisExchangeSettings; + let preTwapLeverageRatio: BigNumber; + + cacheBeforeEach(async () => { + await increaseTimeAsync(ONE_DAY_IN_SECONDS); + await perpV2Setup.setBaseTokenOraclePrice(perpV2Setup.vETH, usdc(1100)); + await perpV2PriceFeedMock.setPrice(BigNumber.from(1100).mul(10 ** 8)); + + newExchangeSettings = { + ...exchange, + twapMaxTradeSize: ether(.02), + incentivizedTwapMaxTradeSize: ether(1) + }; + await leverageStrategyExtension.setExchangeSettings(newExchangeSettings); + + preTwapLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + await leverageStrategyExtension.connect(owner.wallet).rebalance(); + + await increaseTimeAsync(BigNumber.from(4000)); // >3s (twapCoolDown period) + }); + + it("should verify initial leverage conditions", async () => { + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + const twapLeverageRatio = await leverageStrategyExtension.twapLeverageRatio(); + expect(currentLeverageRatio.abs()).to.be.gt(methodology.targetLeverageRatio.abs()); + expect(twapLeverageRatio.abs()).to.be.gt(ZERO); + }); + + it("should set the global last trade timestamp", async () => { + await subject(); + + const lastTradeTimestamp = await leverageStrategyExtension.lastTradeTimestamp(); + + expect(lastTradeTimestamp).to.eq(await getLastBlockTimestamp()); + }); + + it("should set the TWAP leverage ratio", async () => { + const previousTwapLeverageRatio = await leverageStrategyExtension.twapLeverageRatio(); + + await subject(); + + const currentTwapLeverageRatio = await leverageStrategyExtension.twapLeverageRatio(); + + const expectedNewLeverageRatio = calculateNewLeverageRatioPerpV2Basis( + preTwapLeverageRatio, + methodology + ); + expect(previousTwapLeverageRatio).to.eq(expectedNewLeverageRatio); + expect(currentTwapLeverageRatio).to.eq(expectedNewLeverageRatio); + }); + + it("should update the baseToken position on the SetToken correctly", async () => { + const initialPositions = await perpBasisTradingModule.getPositionNotionalInfo(setToken.address); + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + const expectedNewLeverageRatio = calculateNewLeverageRatioPerpV2Basis( + preTwapLeverageRatio, + methodology + ); + + await subject(); + + const newPositions = await perpBasisTradingModule.getPositionUnitInfo(setToken.address); + const newPosition = newPositions[0]; + + const totalRebalanceNotional = preciseDiv( + preciseMul(initialPositions[0].baseBalance, expectedNewLeverageRatio.sub(currentLeverageRatio)), // numerator + preciseMul(currentLeverageRatio, ether(1).sub(expectedNewLeverageRatio)) // denominator + ); + + const chunkRebalanceNotional = totalRebalanceNotional.abs().gt(newExchangeSettings.twapMaxTradeSize) + ? (totalRebalanceNotional.gt(ZERO) ? newExchangeSettings.twapMaxTradeSize : newExchangeSettings.twapMaxTradeSize.mul(-1)) + : totalRebalanceNotional; + + const totalSupply = await setToken.totalSupply(); + const expectedNewPositionUnit = preciseDiv(initialPositions[0].baseBalance.add(chunkRebalanceNotional), totalSupply); + + expect(initialPositions.length).to.eq(1); + expect(newPositions.length).to.eq(1); + expect(newPosition.baseToken).to.eq(perpV2Setup.vETH.address); + expect(newPosition.baseUnit).to.closeTo(expectedNewPositionUnit, 1); + }); + + it("should update the spot asset position unit on the SetToken correctly", async () => { + const initialPositions = await perpBasisTradingModule.getPositionNotionalInfo(setToken.address); + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + + const expectedNewLeverageRatio = calculateNewLeverageRatioPerpV2Basis( + currentLeverageRatio, + methodology + ); + const spotAssetUnitBefore = await setToken.getDefaultPositionRealUnit(spotAsset.address); + const totalSupply = await setToken.totalSupply(); + + await subject(); + + const totalRebalanceNotional = preciseDiv( + preciseMul(initialPositions[0].baseBalance, expectedNewLeverageRatio.sub(currentLeverageRatio)), // numerator + preciseMul(currentLeverageRatio, ether(1).sub(expectedNewLeverageRatio)) // denominator + ); + + const chunkRebalanceNotional = totalRebalanceNotional.abs().gt(newExchangeSettings.twapMaxTradeSize) + ? (totalRebalanceNotional.gt(ZERO) ? newExchangeSettings.twapMaxTradeSize : newExchangeSettings.twapMaxTradeSize.mul(-1)) + : totalRebalanceNotional; + + const expectedNewPositionUnit = preciseDiv( + preciseMul(spotAssetUnitBefore, totalSupply).sub(chunkRebalanceNotional.abs()), + totalSupply + ); + const actualNewPositionUnit = await setToken.getDefaultPositionRealUnit(spotAsset.address); + + expect(actualNewPositionUnit).to.eq(expectedNewPositionUnit); + }); + + it("should deposit all USDC to PerpV2", async () => { + await subject(); + + const usdcPositionUnit = await setToken.getDefaultPositionRealUnit(perpV2Setup.usdc.address); + expect(usdcPositionUnit).to.eq(ZERO); + }); + }); + + describe("when price has moved advantageously towards target leverage ratio", async () => { + let newExchangeSettings: PerpV2BasisExchangeSettings; + + cacheBeforeEach(async () => { + await increaseTimeAsync(ONE_DAY_IN_SECONDS); + await perpV2Setup.setBaseTokenOraclePrice(perpV2Setup.vETH, usdc(900)); + await perpV2PriceFeedMock.setPrice(BigNumber.from(900).mul(10 ** 8)); + + newExchangeSettings = { + ...exchange, + twapMaxTradeSize: ether(.01), + incentivizedTwapMaxTradeSize: ether(1) + }; + await leverageStrategyExtension.setExchangeSettings(newExchangeSettings); + await leverageStrategyExtension.connect(owner.wallet).rebalance(); + + await increaseTimeAsync(BigNumber.from(4000)); // >3s (twapCoolDown period) + + // Move price advantageously towards TLR; increase price, so leverage increases towards TLR + await perpV2Setup.setBaseTokenOraclePrice(perpV2Setup.vETH, usdc(950)); + await perpV2PriceFeedMock.setPrice(BigNumber.from(950).mul(10 ** 8)); + }); + + it("should verify initial leverage conditions", async () => { + const twapLeverageRatio = await leverageStrategyExtension.twapLeverageRatio(); + expect(twapLeverageRatio.abs()).to.be.gt(ZERO); + }); + + it("should set the global last trade timestamp", async () => { + await subject(); + + const lastTradeTimestamp = await leverageStrategyExtension.lastTradeTimestamp(); + + expect(lastTradeTimestamp).to.eq(await getLastBlockTimestamp()); + }); + + it("should remove the TWAP leverage ratio", async () => { + await subject(); + + const twapLeverageRatio = await leverageStrategyExtension.twapLeverageRatio(); + + expect(twapLeverageRatio).to.eq(ZERO); + }); + + it("should not update Perp positions on the SetToken", async () => { + const initialPositions = await perpBasisTradingModule.getPositionUnitInfo(setToken.address); + await subject(); + const currentPositions = await perpBasisTradingModule.getPositionUnitInfo(setToken.address); + + expect(currentPositions[0].baseToken).to.eq(initialPositions[0].baseToken); + expect(currentPositions[0].baseUnit).to.eq(initialPositions[0].baseUnit); + }); + + it("should not update spot positions on the SetToken", async () => { + const spotAssetUnitBefore = await setToken.getDefaultPositionRealUnit(strategy.spotAssetAddress); + await subject(); + const spotAssetUnitAfter = await setToken.getDefaultPositionRealUnit(strategy.spotAssetAddress); + + expect(spotAssetUnitBefore).to.eq(spotAssetUnitAfter); + }); + + it("should deposit all USDC to PerpV2", async () => { + await subject(); + + const usdcPositionUnit = await setToken.getDefaultPositionRealUnit(perpV2Setup.usdc.address); + expect(usdcPositionUnit).to.eq(ZERO); + }); + }); + + describe("when above incentivized leverage ratio threshold", async () => { + beforeEach(async () => { + await increaseTimeAsync(ONE_DAY_IN_SECONDS); + await perpV2Setup.setBaseTokenOraclePrice(perpV2Setup.vETH, usdc(1200)); + await perpV2PriceFeedMock.setPrice(BigNumber.from(1200).mul(10 ** 8)); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be below incentivized leverage ratio"); + }); + }); + + context("when not in TWAP state", async () => { + beforeEach(async () => { + await increaseTimeAsync(ONE_DAY_IN_SECONDS); + await perpV2Setup.setBaseTokenOraclePrice(perpV2Setup.vETH, usdc(950)); + await perpV2PriceFeedMock.setPrice(BigNumber.from(950).mul(10 ** 8)); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Not in TWAP state"); + }); + }); + }); + + describe("when not engaged", async () => { + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Current leverage ratio must NOT be 0"); + }); + }); + }); + + describe("#ripcord", async () => { + let transferredEth: BigNumber; + let subjectCaller: Account; + + beforeEach(async () => { + subjectCaller = owner; + }); + + async function subject(): Promise { + return leverageStrategyExtension.connect(subjectCaller.wallet).ripcord(); + } + + context("when engaged", async () => { + cacheBeforeEach(async () => { + // Engage short position + await leverageStrategyExtension.engage(); + await increaseTimeAsync(BigNumber.from(100000)); + }); + + context("when not in a TWAP rebalance", async () => { + cacheBeforeEach(async () => { + // Set to above incentivized ratio + await perpV2Setup.setBaseTokenOraclePrice(perpV2Setup.vETH, usdc(1150)); + await perpV2PriceFeedMock.setPrice(BigNumber.from(1150).mul(10 ** 8)); + + // Deposit ETH to incentivize calling ripcord + transferredEth = ether(1); + await owner.wallet.sendTransaction({ to: leverageStrategyExtension.address, value: transferredEth }); + }); + + it("should validate leverage ratio and NOT in TWAP", async () => { + const twapLeverageRatio = await leverageStrategyExtension.twapLeverageRatio(); + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + + expect(currentLeverageRatio.abs()).to.be.gt(incentive.incentivizedLeverageRatio.abs()); + expect(twapLeverageRatio).to.be.eq(ZERO); + }); + + it("should set the last trade timestamp", async () => { + await subject(); + + const lastTradeTimestamp = await leverageStrategyExtension.lastTradeTimestamp(); + + expect(lastTradeTimestamp).to.eq(await getLastBlockTimestamp()); + }); + + it("should not set the TWAP leverage ratio", async () => { + await subject(); + + const twapLeverageRatio = await leverageStrategyExtension.twapLeverageRatio(); + + expect(twapLeverageRatio).to.eq(ZERO); + }); + + it("should update the baseToken position on the SetToken correctly", async () => { + const initialPositions = await perpBasisTradingModule.getPositionNotionalInfo(setToken.address); + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + const expectedNewLeverageRatio = methodology.maxLeverageRatio; + + await subject(); + + const newPositions = await perpBasisTradingModule.getPositionUnitInfo(setToken.address); + const newPosition = newPositions[0]; + + const totalRebalanceNotional = preciseDiv( + preciseMul(initialPositions[0].baseBalance, expectedNewLeverageRatio.sub(currentLeverageRatio)), // numerator + preciseMul(currentLeverageRatio, ether(1).sub(expectedNewLeverageRatio)) // denominator + ); + + const chunkRebalanceNotional = totalRebalanceNotional.abs().gt(exchange.incentivizedTwapMaxTradeSize) + ? (totalRebalanceNotional.gt(ZERO) ? exchange.incentivizedTwapMaxTradeSize : exchange.incentivizedTwapMaxTradeSize.mul(-1)) + : totalRebalanceNotional; + + const totalSupply = await setToken.totalSupply(); + const expectedNewPositionUnit = preciseDiv(initialPositions[0].baseBalance.add(chunkRebalanceNotional), totalSupply); + + expect(initialPositions.length).to.eq(1); + expect(newPositions.length).to.eq(1); + expect(newPosition.baseToken).to.eq(perpV2Setup.vETH.address); + expect(newPosition.baseUnit).to.closeTo(expectedNewPositionUnit, 1); + }); + + it("should update the spot asset position on the SetToken correctly", async () => { + const totalSupply = await setToken.totalSupply(); + const initialPositions = await perpBasisTradingModule.getPositionNotionalInfo(setToken.address); + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + const expectedNewLeverageRatio = methodology.maxLeverageRatio; + const initialSpotPositionUnit = await setToken.getDefaultPositionRealUnit(spotAsset.address); + + await subject(); + + const totalRebalanceNotional = preciseDiv( + preciseMul(initialPositions[0].baseBalance, expectedNewLeverageRatio.sub(currentLeverageRatio)), // numerator + preciseMul(currentLeverageRatio, ether(1).sub(expectedNewLeverageRatio)) // denominator + ); + + const chunkRebalanceNotional = totalRebalanceNotional.abs().gt(exchange.incentivizedTwapMaxTradeSize) + ? (totalRebalanceNotional.gt(ZERO) ? exchange.incentivizedTwapMaxTradeSize : exchange.incentivizedTwapMaxTradeSize.mul(-1)) + : totalRebalanceNotional; + + const newSpotPositionUnit = await setToken.getDefaultPositionRealUnit(spotAsset.address); + const expectedNewPositionUnit = initialSpotPositionUnit.sub(preciseDiv(chunkRebalanceNotional.abs(), totalSupply)); + + expect(newSpotPositionUnit).to.eq(expectedNewPositionUnit); + }); + + it("should deposit all USDC to PerpV2", async () => { + await subject(); + + const usdcPositionUnit = await setToken.getDefaultPositionRealUnit(perpV2Setup.usdc.address); + expect(usdcPositionUnit).to.eq(ZERO); + }); + + it("should transfer incentive", async () => { + const previousContractEthBalance = await getEthBalance(leverageStrategyExtension.address); + const previousOwnerEthBalance = await getEthBalance(owner.address); + + const txHash = await subject(); + const txReceipt = await provider.getTransactionReceipt(txHash.hash); + const currentContractEthBalance = await getEthBalance(leverageStrategyExtension.address); + const currentOwnerEthBalance = await getEthBalance(owner.address); + const expectedOwnerEthBalance = previousOwnerEthBalance.add(incentive.etherReward).sub(txReceipt.gasUsed.mul(txHash.gasPrice)); + + expect(previousContractEthBalance).to.eq(transferredEth); + expect(currentContractEthBalance).to.eq(transferredEth.sub(incentive.etherReward)); + expect(expectedOwnerEthBalance).to.eq(currentOwnerEthBalance); + }); + + it("should emit RipcordCalled event", async () => { + const initialPositions = await perpBasisTradingModule.getPositionNotionalInfo(setToken.address); + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + const expectedNewLeverageRatio = methodology.maxLeverageRatio; + const totalRebalanceNotional = preciseDiv( + preciseMul(initialPositions[0].baseBalance, expectedNewLeverageRatio.sub(currentLeverageRatio)), // numerator + preciseMul(currentLeverageRatio, ether(1).sub(expectedNewLeverageRatio)) // denominator + ); + + const chunkRebalanceNotional = totalRebalanceNotional.abs().gt(exchange.incentivizedTwapMaxTradeSize) + ? (totalRebalanceNotional.gt(ZERO) ? exchange.incentivizedTwapMaxTradeSize : exchange.incentivizedTwapMaxTradeSize.mul(-1)) + : totalRebalanceNotional; + + + await expect(subject()).to.emit(leverageStrategyExtension, "RipcordCalled").withArgs( + currentLeverageRatio, + methodology.maxLeverageRatio, + chunkRebalanceNotional, + incentive.etherReward, + ); + }); + + describe("when greater than incentivized max trade size", async () => { + let newIncentivizedMaxTradeSize: BigNumber; + + cacheBeforeEach(async () => { + newIncentivizedMaxTradeSize = ether(0.01); + const newPerpV2BasisExchangeSettings: PerpV2BasisExchangeSettings = { + ...exchange, + twapMaxTradeSize: ether(0.001), + incentivizedTwapMaxTradeSize: newIncentivizedMaxTradeSize + }; + await leverageStrategyExtension.setExchangeSettings(newPerpV2BasisExchangeSettings); + }); + + it("should set the global last trade timestamp", async () => { + await subject(); + + const lastTradeTimestamp = await leverageStrategyExtension.lastTradeTimestamp(); + + expect(lastTradeTimestamp).to.eq(await getLastBlockTimestamp()); + }); + + it("should update the baseToken position on the SetToken correctly", async () => { + const initialPositions = await perpBasisTradingModule.getPositionNotionalInfo(setToken.address); + + await subject(); + + const newPositions = await perpBasisTradingModule.getPositionUnitInfo(setToken.address); + const newPosition = newPositions[0]; + + const totalSupply = await setToken.totalSupply(); + const expectedNewPositionUnit = preciseDiv(initialPositions[0].baseBalance.add(newIncentivizedMaxTradeSize), totalSupply); + + expect(initialPositions.length).to.eq(1); + expect(newPositions.length).to.eq(1); + expect(newPosition.baseToken).to.eq(perpV2Setup.vETH.address); + expect(newPosition.baseUnit).to.closeTo(expectedNewPositionUnit, 1); + }); + + it("should update the spot asset position on the SetToken correctly", async () => { + const totalSupply = await setToken.totalSupply(); + const initialSpotPositionUnit = await setToken.getDefaultPositionRealUnit(spotAsset.address); + + await subject(); + + const newSpotPositionUnit = await setToken.getDefaultPositionRealUnit(spotAsset.address); + const expectedNewPositionUnit = initialSpotPositionUnit.sub(preciseDiv(newIncentivizedMaxTradeSize.abs(), totalSupply)); + + expect(newSpotPositionUnit).to.eq(expectedNewPositionUnit); + }); + + it("should deposit all USDC to PerpV2", async () => { + await subject(); + + const usdcPositionUnit = await setToken.getDefaultPositionRealUnit(perpV2Setup.usdc.address); + expect(usdcPositionUnit).to.eq(ZERO); + }); + }); + + describe("when incentivized cooldown period has not elapsed", async () => { + let newIncentivizedMaxTradeSize: BigNumber; + + cacheBeforeEach(async () => { + newIncentivizedMaxTradeSize = ether(0.01); + const newPerpV2BasisExchangeSettings: PerpV2BasisExchangeSettings = { + ...exchange, + twapMaxTradeSize: ether(0.001), + incentivizedTwapMaxTradeSize: newIncentivizedMaxTradeSize + }; + await leverageStrategyExtension.setExchangeSettings(newPerpV2BasisExchangeSettings); + }); + + beforeEach(async () => { + await subject(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("TWAP cooldown must have elapsed"); + }); + }); + + describe("when below incentivized leverage ratio threshold", async () => { + beforeEach(async () => { + await perpV2Setup.setBaseTokenOraclePrice(perpV2Setup.vETH, usdc(1010)); + await perpV2PriceFeedMock.setPrice(BigNumber.from(1010).mul(10 ** 8)); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be above incentivized leverage ratio"); + }); + }); + }); + + context("when in the midst of a TWAP rebalance", async () => { + let newIncentivizedMaxTradeSize: BigNumber; + + cacheBeforeEach(async () => { + transferredEth = ether(1); + await owner.wallet.sendTransaction({ to: leverageStrategyExtension.address, value: transferredEth }); + + newIncentivizedMaxTradeSize = ether(0.001); + const newPerpV2BasisExchangeSettings: PerpV2BasisExchangeSettings = { + ...exchange, + twapMaxTradeSize: ether(0.001), + incentivizedTwapMaxTradeSize: newIncentivizedMaxTradeSize + }; + await leverageStrategyExtension.setExchangeSettings(newPerpV2BasisExchangeSettings); + + await perpV2Setup.setBaseTokenOraclePrice(perpV2Setup.vETH, usdc(950)); + await perpV2PriceFeedMock.setPrice(BigNumber.from(950).mul(10 ** 8)); + + // Start TWAP rebalance + await leverageStrategyExtension.rebalance(); + await increaseTimeAsync(BigNumber.from(100)); + + // Set to above incentivized ratio + await perpV2Setup.setBaseTokenOraclePrice(perpV2Setup.vETH, usdc(1150)); + await perpV2PriceFeedMock.setPrice(BigNumber.from(1150).mul(10 ** 8)); + }); + + it("should validate leverage ratio and in TWAP", async () => { + const twapLeverageRatio = await leverageStrategyExtension.twapLeverageRatio(); + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + + expect(currentLeverageRatio.abs()).to.be.gt(incentive.incentivizedLeverageRatio.abs()); + expect(twapLeverageRatio.abs()).to.be.gt(ZERO); + }); + + it("should set the global last trade timestamp", async () => { + await subject(); + + const lastTradeTimestamp = await leverageStrategyExtension.lastTradeTimestamp(); + + expect(lastTradeTimestamp).to.eq(await getLastBlockTimestamp()); + }); + + it("should set the TWAP leverage ratio to 0", async () => { + await subject(); + + const twapLeverageRatio = await leverageStrategyExtension.twapLeverageRatio(); + + expect(twapLeverageRatio).to.eq(ZERO); + }); + }); + }); + + describe("when not engaged", async () => { + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Current leverage ratio must NOT be 0"); + }); + }); + }); + + describe("#disengage", async () => { + let subjectCaller: Account; + + beforeEach(async () => { + subjectCaller = owner; + }); + + async function subject(): Promise { + return leverageStrategyExtension.connect(subjectCaller.wallet).disengage(); + } + + context("when engaged", async () => { + cacheBeforeEach(async () => { + await leverageStrategyExtension.engage(); + await increaseTimeAsync(BigNumber.from(4000)); + }); + + context("when notional is less than max trade size", async () => { + it("should remove the base position from the SetToken", async () => { + const initialPositions = await perpBasisTradingModule.getPositionUnitInfo(setToken.address); + + await subject(); + + const newPositions = await perpBasisTradingModule.getPositionUnitInfo(setToken.address); + + expect(initialPositions.length).to.eq(1); + expect(newPositions.length).to.eq(0); + }); + + it("should sell all the spot assets", async () => { + const initialSpotAssetUnit = await setToken.getDefaultPositionRealUnit(spotAsset.address); + + await subject(); + + const newSpotAssetUnit = await setToken.getDefaultPositionRealUnit(spotAsset.address); + + expect(initialSpotAssetUnit).to.be.gt(ZERO); + expect(newSpotAssetUnit).to.be.eq(ZERO); + }); + + it("should set the last trade timestamp", async () => { + await subject(); + + const lastTradeTimestamp = await leverageStrategyExtension.lastTradeTimestamp(); + + expect(lastTradeTimestamp).to.eq(await getLastBlockTimestamp()); + }); + + it("should deposit all USDC to PerpV2", async () => { + await subject(); + + const usdcPositionUnit = await setToken.getDefaultPositionRealUnit(perpV2Setup.usdc.address); + expect(usdcPositionUnit).to.eq(ZERO); + }); + + describe("when SetToken has 0 supply", async () => { + beforeEach(async () => { + const totalSupply = await setToken.totalSupply(); + await issuanceModule.redeem(setToken.address, totalSupply, owner.address); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("SetToken must have > 0 supply"); + }); + }); + }); + + context("when notional is greater than max trade size", async () => { + let newExchangeSettings: PerpV2BasisExchangeSettings; + + const intializeContracts = async () => { + newExchangeSettings = { + ...exchange, + twapMaxTradeSize: ether(1.9), + incentivizedTwapMaxTradeSize: exchange.incentivizedTwapMaxTradeSize + }; + await leverageStrategyExtension.setExchangeSettings(newExchangeSettings); + }; + + cacheBeforeEach(intializeContracts); + + it("should update the base position on the SetToken correctly", async () => { + const totalSupply = await setToken.totalSupply(); + const initialPositions = await perpBasisTradingModule.getPositionNotionalInfo(setToken.address); + + await subject(); + + const newPositions = await perpBasisTradingModule.getPositionUnitInfo(setToken.address); + const newPosition = newPositions[0]; + + const expectedNewPositionUnit = preciseDiv(initialPositions[0].baseBalance.add(newExchangeSettings.twapMaxTradeSize), totalSupply); + + expect(initialPositions.length).to.eq(1); + expect(newPositions.length).to.eq(1); + expect(newPosition.baseToken).to.eq(perpV2Setup.vETH.address); + expect(newPosition.baseUnit).to.closeTo(expectedNewPositionUnit, 1); + }); + + it("should update the spot position on the SetToken correctly", async () => { + const totalSupply = await setToken.totalSupply(); + const initialPositionUnit = await setToken.getDefaultPositionRealUnit(spotAsset.address); + const expectedNewPositionUnit = initialPositionUnit.sub(preciseDiv(newExchangeSettings.twapMaxTradeSize, totalSupply)); + + await subject(); + + const newPositionUnit = await setToken.getDefaultPositionRealUnit(spotAsset.address); + + expect(newPositionUnit).to.eq(expectedNewPositionUnit); + }); + + it("should set the last trade timestamp", async () => { + await subject(); + + const lastTradeTimestamp = await leverageStrategyExtension.lastTradeTimestamp(); + + expect(lastTradeTimestamp).to.eq(await getLastBlockTimestamp()); + }); + + it("should deposit all USDC to PerpV2", async () => { + await subject(); + + const usdcPositionUnit = await setToken.getDefaultPositionRealUnit(perpV2Setup.usdc.address); + expect(usdcPositionUnit).to.eq(ZERO); + }); + + describe("when cooldown has not elapsed", async () => { + beforeEach(async () => { + await subject(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("TWAP cooldown must have elapsed"); + }); + }); + + describe("when SetToken has 0 supply", async () => { + beforeEach(async () => { + const totalSupply = await setToken.totalSupply(); + await issuanceModule.redeem(setToken.address, totalSupply, owner.address); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("SetToken must have > 0 supply"); + }); + }); + }); + }); + + describe("when not engaged", async () => { + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Current leverage ratio must NOT be 0"); + }); + }); + }); + + describe("#reinvest", async () => { + let subjectCaller: Account; + + cacheBeforeEach(async () => { + // set funding rate to NON-ZERO, to allow funding to accrue which would be reinvested + await perpV2Setup.clearingHouseConfig.setMaxFundingRate(BigNumber.from(0.1e6)); + }); + + beforeEach(async () => { + subjectCaller = owner; + }); + + async function subject(): Promise { + return await leverageStrategyExtension.connect(subjectCaller.wallet).reinvest(); + } + + describe("when engaged", async () => { + let performanceFeePercentage: BigNumber; + + cacheBeforeEach(async () => { + await leverageStrategyExtension.engage(); + + // Set index price below mark price to accrue positive funding to short position + await perpV2Setup.setBaseTokenOraclePrice(perpV2Setup.vETH, usdc(990)); + await perpV2PriceFeedMock.setPrice(BigNumber.from(990).mul(10 ** 8)); + await increaseTimeAsync(ONE_DAY_IN_SECONDS.mul(7)); + + performanceFeePercentage = (await perpBasisTradingModule.feeSettings(setToken.address)).performanceFeePercentage; + }); + + it("verify initial testing state", async () => { + const pendingFunding = await perpV2Setup.exchange.getAllPendingFundingPayment(setToken.address); + expect(pendingFunding.mul(-1)).to.gt(ZERO); + }); + + it("should withdraw tracked settled funding from Perpetual protocol", async () => { + const trackedSettledFunding = await perpBasisTradingModule.settledFunding(setToken.address); + const pendingFunding = await perpV2Setup.exchange.getAllPendingFundingPayment(setToken.address); + const initialTrackedSettledFunding = trackedSettledFunding.add(pendingFunding.mul(-1)); + const fundingWithdrawnNetFees = initialTrackedSettledFunding.sub(preciseMul(initialTrackedSettledFunding, performanceFeePercentage)); + + const currentLeverageRatio = (await leverageStrategyExtension.getCurrentLeverageRatio()).mul(-1); + const multiplicationFactor = preciseDiv(currentLeverageRatio, ether(1).add(currentLeverageRatio)); + const amountInvested = preciseMul(fundingWithdrawnNetFees, multiplicationFactor); + const usdAmountDeposited = toUSDCDecimals(fundingWithdrawnNetFees.sub(amountInvested)); + + // Doesn't contain owedRealizedPnl + const initialVaultCollateralBalance = await perpV2Setup.vault.getBalance(setToken.address); + + await subject(); + + const currentTrackedSettledFunding = await perpBasisTradingModule.settledFunding(setToken.address); + const currentVaultCollateralBalance = await perpV2Setup.vault.getBalance(setToken.address); + + expect(currentTrackedSettledFunding).to.be.lt(ether(0.000001)); + + // Depositing back to PerpV2. + expect( + currentVaultCollateralBalance.sub(initialVaultCollateralBalance) + ).closeTo(usdAmountDeposited, 300); + }); + + it("should reinvest into perp position", async () => { + const initialPositions = await perpBasisTradingModule.getPositionNotionalInfo(setToken.address); + + const trackedSettledFunding = await perpBasisTradingModule.settledFunding(setToken.address); + const pendingFunding = await perpV2Setup.exchange.getAllPendingFundingPayment(setToken.address); + const initialTrackedSettledFunding = trackedSettledFunding.add(pendingFunding.mul(-1)); + const fundingWithdrawnNetFees = initialTrackedSettledFunding.sub(preciseMul(initialTrackedSettledFunding, performanceFeePercentage)); + + const currentLeverageRatio = (await leverageStrategyExtension.getCurrentLeverageRatio()).mul(-1); + const multiplicationFactor = preciseDiv(currentLeverageRatio, ether(1).add(currentLeverageRatio)); + const usdAmountInvested = toUSDCDecimals(preciseMul(fundingWithdrawnNetFees, multiplicationFactor)); + + // .155427105277853193 + const amountOutOnDex = await uniV3Setup.quoter.callStatic.quoteExactInput(exchange.buySpotQuoteExactInputPath, usdAmountInvested); + + await subject(); + + const currentPositions = await perpBasisTradingModule.getPositionNotionalInfo(setToken.address); + const expectedBaseBalance = initialPositions[0].baseBalance.add(amountOutOnDex.mul(-1)); + + expect(currentPositions[0].baseBalance).closeTo(expectedBaseBalance, ether(0.00001).toNumber()); + expect(currentPositions[0].baseToken).eq(strategy.virtualBaseAddress); + }); + + it("should reinvest into spot position", async () => { + const setSupply = await setToken.totalSupply(); + const iniitalSpotPostionUnit = await setToken.getDefaultPositionRealUnit(strategy.spotAssetAddress); + + const trackedSettledFunding = await perpBasisTradingModule.settledFunding(setToken.address); + const pendingFunding = await perpV2Setup.exchange.getAllPendingFundingPayment(setToken.address); + const initialTrackedSettledFunding = trackedSettledFunding.add(pendingFunding.mul(-1)); + const fundingWithdrawnNetFees = initialTrackedSettledFunding.sub(preciseMul(initialTrackedSettledFunding, performanceFeePercentage)); + + const currentLeverageRatio = (await leverageStrategyExtension.getCurrentLeverageRatio()).mul(-1); + const multiplicationFactor = preciseDiv(currentLeverageRatio, ether(1).add(currentLeverageRatio)); + const usdAmountInvested = toUSDCDecimals(preciseMul(fundingWithdrawnNetFees, multiplicationFactor)); + + // .155427105277853193 + const amountOutOnDex = await uniV3Setup.quoter.callStatic.quoteExactInput(exchange.buySpotQuoteExactInputPath, usdAmountInvested); + + await subject(); + + const currentSpotPositionUnit = await setToken.getDefaultPositionRealUnit(strategy.spotAssetAddress); + const expectedNewPositionUnit = iniitalSpotPostionUnit.add(preciseDiv(amountOutOnDex, setSupply)); + + expect(currentSpotPositionUnit).to.closeTo(expectedNewPositionUnit, ether(0.000001).toNumber()); + }); + + describe("when reinvest interval has NOT elapsed", async () => { + beforeEach(async () => { + await subject(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Reinvestment interval not elapsed"); + }); + }); + + describe("when leverage ratio is out of bounds", async () => { + beforeEach(async () => { + // Set oracle prices to increase LR + await perpV2Setup.setBaseTokenOraclePrice(perpV2Setup.vETH, usdc(1150)); + await perpV2PriceFeedMock.setPrice(BigNumber.from(1150).mul(10 ** 8)); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Invalid leverage ratio"); + }); + }); + }); + + describe("when funding has not accrued", async () => { + cacheBeforeEach(async () => { + await leverageStrategyExtension.engage(); + await increaseTimeAsync(ONE_DAY_IN_SECONDS.mul(7)); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Zero accrued funding"); + }); + }); + + describe("when not engaged", async () => { + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Current leverage ratio must NOT be 0"); + }); + }); + }); + + describe("#setMethodologySettings", async () => { + let subjectMethodologySettings: PerpV2BasisMethodologySettings; + let subjectCaller: Account; + + const initializeSubjectVariables = () => { + subjectMethodologySettings = { + targetLeverageRatio: ether(-1), + minLeverageRatio: ether(-0.8), + maxLeverageRatio: ether(-1.1), + recenteringSpeed: ether(0.1), + rebalanceInterval: BigNumber.from(43200), + reinvestInterval: ONE_DAY_IN_SECONDS.mul(7) + }; + subjectCaller = owner; + }; + + async function subject(): Promise { + leverageStrategyExtension = leverageStrategyExtension.connect(subjectCaller.wallet); + return leverageStrategyExtension.setMethodologySettings(subjectMethodologySettings); + } + + describe("when rebalance is not in progress", () => { + cacheBeforeEach(initializeRootScopeContracts); + beforeEach(initializeSubjectVariables); + + it("should set the correct methodology parameters", async () => { + await subject(); + const methodology = await leverageStrategyExtension.getMethodology(); + + expect(methodology.targetLeverageRatio).to.eq(subjectMethodologySettings.targetLeverageRatio); + expect(methodology.minLeverageRatio).to.eq(subjectMethodologySettings.minLeverageRatio); + expect(methodology.maxLeverageRatio).to.eq(subjectMethodologySettings.maxLeverageRatio); + expect(methodology.recenteringSpeed).to.eq(subjectMethodologySettings.recenteringSpeed); + expect(methodology.rebalanceInterval).to.eq(subjectMethodologySettings.rebalanceInterval); + expect(methodology.reinvestInterval).to.eq(subjectMethodologySettings.reinvestInterval); + }); + + it("should emit PerpV2MethodologySettingsUpdated event", async () => { + await expect(subject()).to.emit(leverageStrategyExtension, "MethodologySettingsUpdated").withArgs( + subjectMethodologySettings.targetLeverageRatio, + subjectMethodologySettings.minLeverageRatio, + subjectMethodologySettings.maxLeverageRatio, + subjectMethodologySettings.recenteringSpeed, + subjectMethodologySettings.rebalanceInterval, + subjectMethodologySettings.reinvestInterval + ); + }); + + describe("when the caller is not the operator", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be operator"); + }); + }); + }); + + describe("when rebalance is in progress", async () => { + let newExchangeSettings: PerpV2BasisExchangeSettings; + + beforeEach(async () => { + await initializeRootScopeContracts(); + initializeSubjectVariables(); + + newExchangeSettings = { + ...exchange, + twapMaxTradeSize: ether(.1), + incentivizedTwapMaxTradeSize: ether(1) + }; + await leverageStrategyExtension.setExchangeSettings(newExchangeSettings); + + // Engage to initial leverage + await leverageStrategyExtension.engage(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Rebalance is currently in progress"); + }); + }); + }); + + describe("#setExecutionSettings", async () => { + let subjectExecutionSettings: PerpV2BasisExecutionSettings; + let subjectCaller: Account; + + const initializeSubjectVariables = () => { + subjectExecutionSettings = { + twapCooldownPeriod: BigNumber.from(360), + slippageTolerance: ether(0.02), + }; + subjectCaller = owner; + }; + + async function subject(): Promise { + leverageStrategyExtension = leverageStrategyExtension.connect(subjectCaller.wallet); + return leverageStrategyExtension.setExecutionSettings(subjectExecutionSettings); + } + + describe("when rebalance is not in progress", () => { + cacheBeforeEach(initializeRootScopeContracts); + beforeEach(initializeSubjectVariables); + + it("should set the correct execution parameters", async () => { + await subject(); + const execution = await leverageStrategyExtension.getExecution(); + + expect(execution.twapCooldownPeriod).to.eq(subjectExecutionSettings.twapCooldownPeriod); + expect(execution.slippageTolerance).to.eq(subjectExecutionSettings.slippageTolerance); + }); + + it("should emit ExecutionSettingsUpdated event", async () => { + await expect(subject()).to.emit(leverageStrategyExtension, "ExecutionSettingsUpdated").withArgs( + subjectExecutionSettings.twapCooldownPeriod, + subjectExecutionSettings.slippageTolerance + ); + }); + + describe("when the caller is not the operator", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be operator"); + }); + }); + }); + + describe("when rebalance is in progress", async () => { + let newExchangeSettings: PerpV2BasisExchangeSettings; + + beforeEach(async () => { + await initializeRootScopeContracts(); + initializeSubjectVariables(); + + newExchangeSettings = { + ...exchange, + twapMaxTradeSize: ether(.1), + incentivizedTwapMaxTradeSize: ether(1) + }; + await leverageStrategyExtension.setExchangeSettings(newExchangeSettings); + // Engage to initial leverage + await leverageStrategyExtension.engage(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Rebalance is currently in progress"); + }); + }); + }); + + describe("#setIncentiveSettings", async () => { + let subjectIncentiveSettings: PerpV2BasisIncentiveSettings; + let subjectCaller: Account; + + const initializeSubjectVariables = () => { + subjectIncentiveSettings = { + incentivizedTwapCooldownPeriod: BigNumber.from(30), + incentivizedSlippageTolerance: ether(0.1), + etherReward: ether(5), + incentivizedLeverageRatio: ether(-1.3), + }; + subjectCaller = owner; + }; + + async function subject(): Promise { + leverageStrategyExtension = leverageStrategyExtension.connect(subjectCaller.wallet); + return leverageStrategyExtension.setIncentiveSettings(subjectIncentiveSettings); + } + + describe("when rebalance is not in progress", () => { + cacheBeforeEach(initializeRootScopeContracts); + beforeEach(initializeSubjectVariables); + + it("should set the correct incentive parameters", async () => { + await subject(); + const incentive = await leverageStrategyExtension.getIncentive(); + + expect(incentive.incentivizedTwapCooldownPeriod).to.eq(subjectIncentiveSettings.incentivizedTwapCooldownPeriod); + expect(incentive.incentivizedSlippageTolerance).to.eq(subjectIncentiveSettings.incentivizedSlippageTolerance); + expect(incentive.etherReward).to.eq(subjectIncentiveSettings.etherReward); + expect(incentive.incentivizedLeverageRatio).to.eq(subjectIncentiveSettings.incentivizedLeverageRatio); + }); + + it("should emit IncentiveSettingsUpdated event", async () => { + await expect(subject()).to.emit(leverageStrategyExtension, "IncentiveSettingsUpdated").withArgs( + subjectIncentiveSettings.etherReward, + subjectIncentiveSettings.incentivizedLeverageRatio, + subjectIncentiveSettings.incentivizedSlippageTolerance, + subjectIncentiveSettings.incentivizedTwapCooldownPeriod + ); + }); + + describe("when the caller is not the operator", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be operator"); + }); + }); + }); + + describe("when rebalance is in progress", async () => { + let newExchangeSettings: PerpV2BasisExchangeSettings; + + beforeEach(async () => { + await initializeRootScopeContracts(); + initializeSubjectVariables(); + + newExchangeSettings = { + ...exchange, + twapMaxTradeSize: ether(.1), + incentivizedTwapMaxTradeSize: ether(1) + }; + await leverageStrategyExtension.setExchangeSettings(newExchangeSettings); + // Engage to initial leverage + await leverageStrategyExtension.engage(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Rebalance is currently in progress"); + }); + }); + }); + + describe("#setExchangeSettings", async () => { + let subjectExchangeSettings: PerpV2BasisExchangeSettings; + let subjectCaller: Account; + + cacheBeforeEach(initializeRootScopeContracts); + beforeEach(async () => { + subjectExchangeSettings = { + exchangeName: exchange.exchangeName, + buyExactSpotTradeData: exchange.buyExactSpotTradeData, + sellExactSpotTradeData: exchange.sellExactSpotTradeData, + buySpotQuoteExactInputPath: exchange.buySpotQuoteExactInputPath, + twapMaxTradeSize: ether(10), + incentivizedTwapMaxTradeSize: ether(20) + }; + subjectCaller = owner; + }); + + async function subject(): Promise { + leverageStrategyExtension = leverageStrategyExtension.connect(subjectCaller.wallet); + return leverageStrategyExtension.setExchangeSettings(subjectExchangeSettings); + } + + it("should set the correct exchange parameters", async () => { + await subject(); + const exchange = await leverageStrategyExtension.getExchangeSettings(); + + expect(exchange.exchangeName).to.eq(subjectExchangeSettings.exchangeName); + expect(exchange.buyExactSpotTradeData).to.eq(subjectExchangeSettings.buyExactSpotTradeData); + expect(exchange.sellExactSpotTradeData).to.eq(subjectExchangeSettings.sellExactSpotTradeData); + expect(exchange.buySpotQuoteExactInputPath).to.eq(subjectExchangeSettings.buySpotQuoteExactInputPath); + expect(exchange.twapMaxTradeSize).to.eq(subjectExchangeSettings.twapMaxTradeSize); + expect(exchange.incentivizedTwapMaxTradeSize).to.eq(subjectExchangeSettings.incentivizedTwapMaxTradeSize); + }); + + it("should emit ExchangeSettingsUpdated event", async () => { + await expect(subject()).to.emit(leverageStrategyExtension, "ExchangeSettingsUpdated").withArgs( + subjectExchangeSettings.exchangeName, + subjectExchangeSettings.buyExactSpotTradeData, + subjectExchangeSettings.sellExactSpotTradeData, + subjectExchangeSettings.buySpotQuoteExactInputPath, + subjectExchangeSettings.twapMaxTradeSize, + subjectExchangeSettings.incentivizedTwapMaxTradeSize + ); + }); + + describe("when exchange name is invalid", async () => { + beforeEach(async () => { + subjectExchangeSettings = { + ...exchange, + exchangeName:"UniswapV3ExchangeAdapter" // v1 exchange adapter + }; + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Invalid exchange name"); + }); + }); + + describe("when buyExactSpotTradeData is invalid", async () => { + let buyExactSpotTradeData: string; + + describe("when length is invalid", async () => { + beforeEach(async () => { + buyExactSpotTradeData = await uniswapV3ExchangeAdapter.generateDataParam( + [systemSetup.weth.address], + [], + false + ); + subjectExchangeSettings = { + ...exchange, + buyExactSpotTradeData: buyExactSpotTradeData + }; + }); + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Invalid buyExactSpotTradeData data"); + }); + }); + + describe("when first token is invalid", async () => { + beforeEach(async () => { + const randomAddress = await getRandomAddress(); + buyExactSpotTradeData = await uniswapV3ExchangeAdapter.generateDataParam( + [randomAddress, perpV2Setup.usdc.address], + [3000], + false + ); + subjectExchangeSettings = { + ...exchange, + buyExactSpotTradeData: buyExactSpotTradeData + }; + }); + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Invalid buyExactSpotTradeData data"); + }); + }); + + describe("when last token is invalid", async () => { + beforeEach(async () => { + const randomAddress = await getRandomAddress(); + buyExactSpotTradeData = await uniswapV3ExchangeAdapter.generateDataParam( + [systemSetup.weth.address, randomAddress], + [3000], + false + ); + subjectExchangeSettings = { + ...exchange, + buyExactSpotTradeData: buyExactSpotTradeData + }; + }); + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Invalid buyExactSpotTradeData data"); + }); + }); + + describe("when fix input bool is invalid", async () => { + beforeEach(async () => { + buyExactSpotTradeData = await uniswapV3ExchangeAdapter.generateDataParam( + [systemSetup.weth.address, perpV2Setup.usdc.address], // exactOutput paths are reversed in Uniswap V3 + [3000], + true + ); + subjectExchangeSettings = { + ...exchange, + buyExactSpotTradeData: buyExactSpotTradeData + }; + }); + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Invalid buyExactSpotTradeData data"); + }); + }); + }); + + describe("when sellExactSpotTradeData is invalid", async () => { + let sellExactSpotTradeData: string; + + describe("when length is invalid", async () => { + beforeEach(async () => { + sellExactSpotTradeData = await uniswapV3ExchangeAdapter.generateDataParam( + [systemSetup.weth.address], + [], + true + ); + subjectExchangeSettings = { + ...exchange, + sellExactSpotTradeData: sellExactSpotTradeData + }; + }); + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Invalid sellExactSpotTradeData data"); + }); + }); + + describe("when first token is invalid", async () => { + beforeEach(async () => { + const randomAddress = await getRandomAddress(); + sellExactSpotTradeData = await uniswapV3ExchangeAdapter.generateDataParam( + [randomAddress, perpV2Setup.usdc.address], + [3000], + true + ); + subjectExchangeSettings = { + ...exchange, + sellExactSpotTradeData: sellExactSpotTradeData + }; + }); + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Invalid sellExactSpotTradeData data"); + }); + }); + + describe("when last token is invalid", async () => { + beforeEach(async () => { + const randomAddress = await getRandomAddress(); + sellExactSpotTradeData = await uniswapV3ExchangeAdapter.generateDataParam( + [systemSetup.weth.address, randomAddress], + [3000], + true + ); + subjectExchangeSettings = { + ...exchange, + sellExactSpotTradeData: sellExactSpotTradeData + }; + }); + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Invalid sellExactSpotTradeData data"); + }); + }); + + describe("when fix input bool is invalid", async () => { + beforeEach(async () => { + sellExactSpotTradeData = await uniswapV3ExchangeAdapter.generateDataParam( + [systemSetup.weth.address, perpV2Setup.usdc.address], // exactOutput paths are reversed in Uniswap V3 + [3000], + false + ); + subjectExchangeSettings = { + ...exchange, + sellExactSpotTradeData: sellExactSpotTradeData + }; + }); + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Invalid sellExactSpotTradeData data"); + }); + }); + }); + + describe("when buySpotQuoteExactInputPath is invalid", async () => { + let buySpotQuoteExactInputPath: string; + + describe("when length is invalid", async () => { + beforeEach(async () => { + buySpotQuoteExactInputPath = solidityPack( + ["address"], + [systemSetup.weth.address] + ); + subjectExchangeSettings = { + ...exchange, + buySpotQuoteExactInputPath: buySpotQuoteExactInputPath + }; + }); + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Invalid buySpotQuoteExactInputPath data"); + }); + }); + + describe("when first token is invalid", async () => { + beforeEach(async () => { + const randomAddress = await getRandomAddress(); + buySpotQuoteExactInputPath = solidityPack( + ["address", "uint24", "address"], + [randomAddress, BigNumber.from(3000), systemSetup.weth.address] + ); + subjectExchangeSettings = { + ...exchange, + buySpotQuoteExactInputPath: buySpotQuoteExactInputPath + }; + }); + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Invalid buySpotQuoteExactInputPath data"); + }); + }); + + describe("when last token is invalid", async () => { + beforeEach(async () => { + const randomAddress = await getRandomAddress(); + buySpotQuoteExactInputPath = solidityPack( + ["address", "uint24", "address"], + [perpV2Setup.usdc.address, BigNumber.from(3000), randomAddress] + ); + subjectExchangeSettings = { + ...exchange, + buySpotQuoteExactInputPath: buySpotQuoteExactInputPath + }; + }); + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Invalid buySpotQuoteExactInputPath data"); + }); + }); + }); + + describe("when the caller is not the operator", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be operator"); + }); + }); + }); + + describe("#withdrawEtherBalance", async () => { + let etherReward: BigNumber; + let subjectCaller: Account; + + const initializeSubjectVariables = async () => { + etherReward = ether(0.1); + // Send ETH to contract as reward + await owner.wallet.sendTransaction({ to: leverageStrategyExtension.address, value: etherReward }); + subjectCaller = owner; + }; + + async function subject(): Promise { + leverageStrategyExtension = leverageStrategyExtension.connect(subjectCaller.wallet); + return leverageStrategyExtension.withdrawEtherBalance(); + } + + describe("when rebalance is not in progress", () => { + cacheBeforeEach(initializeRootScopeContracts); + beforeEach(initializeSubjectVariables); + + it("should withdraw ETH balance on contract to operator", async () => { + const previousContractEthBalance = await getEthBalance(leverageStrategyExtension.address); + const previousOwnerEthBalance = await getEthBalance(owner.address); + + const txHash = await subject(); + const txReceipt = await provider.getTransactionReceipt(txHash.hash); + const currentContractEthBalance = await getEthBalance(leverageStrategyExtension.address); + const currentOwnerEthBalance = await getEthBalance(owner.address); + const expectedOwnerEthBalance = previousOwnerEthBalance.add(etherReward).sub(txReceipt.gasUsed.mul(txHash.gasPrice)); + + expect(previousContractEthBalance).to.eq(etherReward); + expect(currentContractEthBalance).to.eq(ZERO); + expect(expectedOwnerEthBalance).to.eq(currentOwnerEthBalance); + }); + + describe("when the caller is not the operator", async () => { + beforeEach(async () => { + subjectCaller = await getRandomAccount(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Must be operator"); + }); + }); + }); + + describe("when rebalance is in progress", async () => { + let newExchangeSettings: PerpV2BasisExchangeSettings; + + beforeEach(async () => { + await initializeRootScopeContracts(); + initializeSubjectVariables(); + + newExchangeSettings = { + ...exchange, + twapMaxTradeSize: ether(.1), + incentivizedTwapMaxTradeSize: ether(1) + }; + await leverageStrategyExtension.setExchangeSettings(newExchangeSettings); + // Engage to initial leverage + await leverageStrategyExtension.engage(); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Rebalance is currently in progress"); + }); + }); + }); + + describe("#getCurrentEtherIncentive", async () => { + cacheBeforeEach(async () => { + await initializeRootScopeContracts(); + // Engage to initial leverage + await leverageStrategyExtension.engage(); + await increaseTimeAsync(BigNumber.from(100000)); + }); + + async function subject(): Promise { + return leverageStrategyExtension.getCurrentEtherIncentive(); + } + + describe("when above incentivized leverage ratio", async () => { + cacheBeforeEach(async () => { + // Send ETHER to contract + await owner.wallet.sendTransaction({ to: leverageStrategyExtension.address, value: ether(1) }); + + // Set oracle prices + await perpV2Setup.setBaseTokenOraclePrice(perpV2Setup.vETH, usdc(1150)); + await perpV2PriceFeedMock.setPrice(BigNumber.from(1150).mul(10 ** 8)); + }); + + it("should return the correct value", async () => { + const etherIncentive = await subject(); + + expect(etherIncentive).to.eq(incentive.etherReward); + }); + + describe("when ETH balance is below ETH reward amount", async () => { + beforeEach(async () => { + await leverageStrategyExtension.withdrawEtherBalance(); + // Transfer 0.01 ETH to contract + await owner.wallet.sendTransaction({ to: leverageStrategyExtension.address, value: ether(0.01) }); + }); + + it("should return the correct value", async () => { + const etherIncentive = await subject(); + + expect(etherIncentive).to.eq(ether(0.01)); + }); + }); + }); + + describe("when below incentivized leverage ratio", async () => { + beforeEach(async () => { + await perpV2Setup.setBaseTokenOraclePrice(perpV2Setup.vETH, usdc(900)); + await perpV2PriceFeedMock.setPrice(BigNumber.from(900).mul(10 ** 8)); + }); + + it("should return the correct value", async () => { + const etherIncentive = await subject(); + + expect(etherIncentive).to.eq(ZERO); + }); + }); + }); + + describe("#shouldRebalance", async () => { + let subjectCaller: Account; + + beforeEach(async () => { + subjectCaller = owner; + }); + + cacheBeforeEach(async () => { + await initializeRootScopeContracts(); + await leverageStrategyExtension.engage(); + }); + + async function subject(): Promise { + return leverageStrategyExtension.connect(subjectCaller.wallet).shouldRebalance(); + } + + context("when in the midst of a TWAP rebalance", async () => { + let newExchangeSettings: PerpV2BasisExchangeSettings; + + cacheBeforeEach(async () => { + // Set up new rebalance TWAP + await increaseTimeAsync(ONE_DAY_IN_SECONDS); + await perpV2PriceFeedMock.setPrice(BigNumber.from(1040).mul(10 ** 8)); + + newExchangeSettings = { + ...exchange, + twapMaxTradeSize: ether(.01), + incentivizedTwapMaxTradeSize: ether(1) + }; + await leverageStrategyExtension.setExchangeSettings(newExchangeSettings); + + await leverageStrategyExtension.connect(owner.wallet).rebalance(); + }); + + it("should verify in TWAP rebalance", async () => { + const twapLeverageRatio = await leverageStrategyExtension.twapLeverageRatio(); + expect(twapLeverageRatio.abs()).to.be.gt(ZERO); + }); + + describe("when above incentivized leverage ratio and incentivized TWAP cooldown has elapsed", async () => { + beforeEach(async () => { + // Set to above incentivized ratio + await perpV2PriceFeedMock.setPrice(BigNumber.from(1200).mul(10 ** 8)); + await increaseTimeAsync(BigNumber.from(100)); // >60 (incentivized cooldown period) + }); + + it("should verify initial leverage conditions", async () => { + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + + expect(currentLeverageRatio.abs()).to.be.gt(incentive.incentivizedLeverageRatio.abs()); + }); + + it("should return ripcord", async () => { + const shouldRebalance = await subject(); + + expect(shouldRebalance).to.eq(BigNumber.from(3)); + }); + }); + + describe("when below incentivized leverage ratio and regular TWAP cooldown has elapsed", async () => { + beforeEach(async () => { + // Set to below incentivized ratio + await perpV2PriceFeedMock.setPrice(BigNumber.from(1050).mul(10 ** 8)); + await increaseTimeAsync(BigNumber.from(4000)); // >3000 (regular cooldown period) + }); + + it("should verify initial leverage conditions", async () => { + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + + expect(currentLeverageRatio.abs()).to.be.lt(incentive.incentivizedLeverageRatio.abs()); + }); + + it("should return iterate rebalance", async () => { + const shouldRebalance = await subject(); + + expect(shouldRebalance).to.eq(TWO); + }); + }); + + describe("when above incentivized leverage ratio and incentivized TWAP cooldown has NOT elapsed", async () => { + beforeEach(async () => { + // Set to above incentivized ratio + await perpV2PriceFeedMock.setPrice(BigNumber.from(1200).mul(10 ** 8)); + await increaseTimeAsync(BigNumber.from(50)); // <60 (incentivized cooldown period) + }); + + it("should verify initial leverage conditions", async () => { + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + + expect(currentLeverageRatio.abs()).to.be.gt(incentive.incentivizedLeverageRatio.abs()); + }); + + it("should not rebalance", async () => { + const shouldRebalance = await subject(); + + expect(shouldRebalance).to.eq(ZERO); + }); + }); + + describe("when below incentivized leverage ratio and regular TWAP cooldown has NOT elapsed", async () => { + beforeEach(async () => { + // Set to below incentivized ratio + await perpV2PriceFeedMock.setPrice(BigNumber.from(1020).mul(10 ** 8)); + await increaseTimeAsync(BigNumber.from(2000)); // <3000 (regular cooldown period) + }); + + it("should verify initial leverage conditions", async () => { + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + + expect(currentLeverageRatio.abs()).to.be.lt(incentive.incentivizedLeverageRatio.abs()); + }); + + it("should not rebalance", async () => { + const shouldRebalance = await subject(); + + expect(shouldRebalance).to.eq(ZERO); + }); + }); + }); + + context("when not in a TWAP rebalance", async () => { + it("should verify NOT in TWAP rebalance", async () => { + const twapLeverageRatio = await leverageStrategyExtension.twapLeverageRatio(); + expect(twapLeverageRatio).to.be.eq(ZERO); + }); + + describe("when above incentivized leverage ratio and cooldown period has elapsed", async () => { + beforeEach(async () => { + // Set to above incentivized ratio + await perpV2PriceFeedMock.setPrice(BigNumber.from(1200).mul(10 ** 8)); + await increaseTimeAsync(BigNumber.from(100)); + }); + + it("should verify initial leverage conditions", async () => { + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + + expect(currentLeverageRatio.abs()).to.be.gt(incentive.incentivizedLeverageRatio.abs()); + }); + + it("should return ripcord", async () => { + const shouldRebalance = await subject(); + + expect(shouldRebalance).to.eq(BigNumber.from(3)); + }); + }); + + describe("when between max and min leverage ratio and rebalance interval has elapsed", async () => { + beforeEach(async () => { + await perpV2PriceFeedMock.setPrice(BigNumber.from(1010).mul(10 ** 8)); + await increaseTimeAsync(BigNumber.from(ONE_DAY_IN_SECONDS)); + }); + + it("should verify initial leverage conditions", async () => { + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + + expect(currentLeverageRatio.abs()).to.be.gt(methodology.minLeverageRatio.abs()); + expect(currentLeverageRatio.abs()).to.be.lt(methodology.maxLeverageRatio.abs()); + }); + + it("should return rebalance", async () => { + const shouldRebalance = await subject(); + + expect(shouldRebalance).to.eq(BigNumber.from(1)); + }); + }); + + describe("when above max leverage ratio but below incentivized leverage ratio", async () => { + beforeEach(async () => { + await perpV2PriceFeedMock.setPrice(BigNumber.from(1050).mul(10 ** 8)); + await increaseTimeAsync(BigNumber.from(ONE_DAY_IN_SECONDS)); + }); + + it("should verify initial leverage conditions", async () => { + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + + expect(currentLeverageRatio.abs()).to.be.gt(methodology.maxLeverageRatio.abs()); + expect(currentLeverageRatio.abs()).to.be.lt(incentive.incentivizedLeverageRatio.abs()); + }); + + it("should return rebalance", async () => { + const shouldRebalance = await subject(); + + expect(shouldRebalance).to.eq(BigNumber.from(1)); + }); + }); + + describe("when below min leverage ratio", async () => { + beforeEach(async () => { + await perpV2PriceFeedMock.setPrice(BigNumber.from(800).mul(10 ** 8)); + }); + + it("should verify initial leverage conditions", async () => { + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + + expect(currentLeverageRatio.abs()).to.be.lt(methodology.minLeverageRatio.abs()); + }); + + it("should return rebalance", async () => { + const shouldRebalance = await subject(); + + expect(shouldRebalance).to.eq(BigNumber.from(1)); + }); + }); + + describe("when above incentivized leverage ratio and incentivized TWAP cooldown has NOT elapsed", async () => { + beforeEach(async () => { + await perpV2PriceFeedMock.setPrice(BigNumber.from(1200).mul(10 ** 8)); + }); + + it("should verify initial leverage conditions", async () => { + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + + expect(currentLeverageRatio.abs()).to.be.gt(incentive.incentivizedLeverageRatio.abs()); + }); + + it("should not rebalance", async () => { + const shouldRebalance = await subject(); + + expect(shouldRebalance).to.eq(ZERO); + }); + }); + + describe("when between max and min leverage ratio and both rebalance and reinvest interval has NOT elapsed", async () => { + beforeEach(async () => { + await perpV2PriceFeedMock.setPrice(BigNumber.from(1010).mul(10 ** 8)); + }); + + it("should verify initial leverage conditions", async () => { + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + + expect(currentLeverageRatio.abs()).to.be.gt(methodology.minLeverageRatio.abs()); + expect(currentLeverageRatio.abs()).to.be.lt(methodology.maxLeverageRatio.abs()); + }); + + it("should not rebalance and nor reinvest", async () => { + const shouldRebalance = await subject(); + + expect(shouldRebalance).to.eq(ZERO); + }); + }); + + describe("when between max and min leverage ratio and rebalance intereval has NOT elapsed but reinvest interval has elapsed", async () => { + let newMethodology: PerpV2BasisMethodologySettings; + + cacheBeforeEach(async () => { + newMethodology = { + ...methodology, + reinvestInterval: ONE_DAY_IN_SECONDS.div(2) // Set reinvest interval < rebalance interval + }; + await leverageStrategyExtension.setMethodologySettings(newMethodology); + + // set funding rate to NON-ZERO, to allow funding to accrue + await perpV2Setup.clearingHouseConfig.setMaxFundingRate(BigNumber.from(0.1e6)); + }); + + describe("when reinvestment amount is non zero", async () => { + beforeEach(async () => { + // Set oracle price below mark price to accrue positive funding + await perpV2Setup.setBaseTokenOraclePrice(perpV2Setup.vETH, usdc(980)); + await perpV2PriceFeedMock.setPrice(BigNumber.from(980).mul(10 ** 8)); + await increaseTimeAsync(ONE_DAY_IN_SECONDS.div(2)); + }); + + it("should verify initial conditions", async () => { + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + const reinvestInterval = (await leverageStrategyExtension.getMethodology()).reinvestInterval; + const lastReinvestTimestamp = await leverageStrategyExtension.lastReinvestTimestamp(); + const lastBlockTimestamp = await getLastBlockTimestamp(); + + expect(lastReinvestTimestamp.add(reinvestInterval)).lt(lastBlockTimestamp); + expect(currentLeverageRatio.abs()).to.be.gt(methodology.minLeverageRatio.abs()); + expect(currentLeverageRatio.abs()).to.be.lt(methodology.maxLeverageRatio.abs()); + }); + + it("should reinvest", async () => { + const shouldRebalance = await subject(); + + expect(shouldRebalance).to.eq(BigNumber.from(4)); + }); + }); + + describe("when reinvestment amount is zero", async () => { + beforeEach(async () => { + // Set oracle price above mark price to accrue negative funding + await perpV2Setup.setBaseTokenOraclePrice(perpV2Setup.vETH, usdc(1020)); + await perpV2PriceFeedMock.setPrice(BigNumber.from(1020).mul(10 ** 8)); + await increaseTimeAsync(ONE_DAY_IN_SECONDS.div(2)); + }); + + it("should verify initial conditions", async () => { + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + const reinvestInterval = (await leverageStrategyExtension.getMethodology()).reinvestInterval; + const lastReinvestTimestamp = await leverageStrategyExtension.lastReinvestTimestamp(); + const lastBlockTimestamp = await getLastBlockTimestamp(); + + expect(lastReinvestTimestamp.add(reinvestInterval)).lt(lastBlockTimestamp); + expect(currentLeverageRatio.abs()).to.be.gt(methodology.minLeverageRatio.abs()); + expect(currentLeverageRatio.abs()).to.be.lt(methodology.maxLeverageRatio.abs()); + }); + + it("should not reinvest", async () => { + const shouldRebalance = await subject(); + + expect(shouldRebalance).to.eq(ZERO); + }); + }); + }); + }); + }); + + describe("#shouldRebalanceWithBounds", async () => { + let subjectMinLeverageRatio: BigNumber; + let subjectMaxLeverageRatio: BigNumber; + + cacheBeforeEach(async () => { + await initializeRootScopeContracts(); + await leverageStrategyExtension.engage(); + }); + + beforeEach(() => { + subjectMinLeverageRatio = ether(-0.85); + subjectMaxLeverageRatio = ether(-1.15); + }); + + async function subject(): Promise { + return leverageStrategyExtension.shouldRebalanceWithBounds( + subjectMinLeverageRatio, + subjectMaxLeverageRatio + ); + } + + context("when in the midst of a TWAP rebalance", async () => { + let newExchangeSettings: PerpV2BasisExchangeSettings; + + cacheBeforeEach(async () => { + // Set up new rebalance TWAP + await increaseTimeAsync(ONE_DAY_IN_SECONDS); + await perpV2PriceFeedMock.setPrice(BigNumber.from(1040).mul(10 ** 8)); + + newExchangeSettings = { + ...exchange, + twapMaxTradeSize: ether(.01), + incentivizedTwapMaxTradeSize: ether(1) + }; + await leverageStrategyExtension.setExchangeSettings(newExchangeSettings); + + await leverageStrategyExtension.connect(owner.wallet).rebalance(); + }); + + it("should verify in TWAP rebalance", async () => { + const twapLeverageRatio = await leverageStrategyExtension.twapLeverageRatio(); + expect(twapLeverageRatio.abs()).to.be.gt(ZERO); + }); + + describe("when above incentivized leverage ratio and incentivized TWAP cooldown has elapsed", async () => { + beforeEach(async () => { + // Set to above incentivized ratio + await perpV2PriceFeedMock.setPrice(BigNumber.from(1200).mul(10 ** 8)); + await increaseTimeAsync(BigNumber.from(100)); // >60 (incentivized cooldown period) + }); + + it("should verify initial leverage conditions", async () => { + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + + expect(currentLeverageRatio.abs()).to.be.gt(incentive.incentivizedLeverageRatio.abs()); + }); + + it("should return ripcord", async () => { + const shouldRebalance = await subject(); + + expect(shouldRebalance).to.eq(BigNumber.from(3)); + }); + }); + + describe("when below incentivized leverage ratio and regular TWAP cooldown has elapsed", async () => { + beforeEach(async () => { + // Set to below incentivized ratio + await perpV2PriceFeedMock.setPrice(BigNumber.from(1050).mul(10 ** 8)); + await increaseTimeAsync(BigNumber.from(4000)); // >3000 (regular cooldown period) + }); + + it("should verify initial leverage conditions", async () => { + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + + expect(currentLeverageRatio.abs()).to.be.lt(incentive.incentivizedLeverageRatio.abs()); + }); + + it("should return iterate rebalance", async () => { + const shouldRebalance = await subject(); + + expect(shouldRebalance).to.eq(TWO); + }); + }); + + describe("when above incentivized leverage ratio and incentivized TWAP cooldown has NOT elapsed", async () => { + beforeEach(async () => { + // Set to above incentivized ratio + await perpV2PriceFeedMock.setPrice(BigNumber.from(1200).mul(10 ** 8)); + await increaseTimeAsync(BigNumber.from(50)); // <60 (incentivized cooldown period) + }); + + it("should verify initial leverage conditions", async () => { + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + + expect(currentLeverageRatio.abs()).to.be.gt(incentive.incentivizedLeverageRatio.abs()); + }); + + it("should not rebalance", async () => { + const shouldRebalance = await subject(); + + expect(shouldRebalance).to.eq(ZERO); + }); + }); + + describe("when below incentivized leverage ratio and regular TWAP cooldown has NOT elapsed", async () => { + beforeEach(async () => { + // Set to below incentivized ratio + await perpV2PriceFeedMock.setPrice(BigNumber.from(1050).mul(10 ** 8)); + await increaseTimeAsync(BigNumber.from(2000)); // <3000 (regular cooldown period) + }); + + it("should verify initial leverage conditions", async () => { + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + + expect(currentLeverageRatio.abs()).to.be.lt(incentive.incentivizedLeverageRatio.abs()); + }); + + it("should not rebalance", async () => { + const shouldRebalance = await subject(); + + expect(shouldRebalance).to.eq(ZERO); + }); + }); + }); + + context("when not in a TWAP rebalance", async () => { + it("should verify NOT in TWAP rebalance", async () => { + const twapLeverageRatio = await leverageStrategyExtension.twapLeverageRatio(); + expect(twapLeverageRatio).to.be.eq(ZERO); + }); + + describe("when above incentivized leverage ratio and cooldown period has elapsed", async () => { + beforeEach(async () => { + // Set to above incentivized ratio + await perpV2PriceFeedMock.setPrice(BigNumber.from(1200).mul(10 ** 8)); + await increaseTimeAsync(BigNumber.from(100)); + }); + + it("should verify initial leverage conditions", async () => { + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + + expect(currentLeverageRatio.abs()).to.be.gt(incentive.incentivizedLeverageRatio.abs()); + }); + + it("should return ripcord", async () => { + const shouldRebalance = await subject(); + + expect(shouldRebalance).to.eq(BigNumber.from(3)); + }); + }); + + describe("when between max and min leverage ratio and rebalance interval has elapsed", async () => { + beforeEach(async () => { + await perpV2PriceFeedMock.setPrice(BigNumber.from(1010).mul(10 ** 8)); + await increaseTimeAsync(BigNumber.from(ONE_DAY_IN_SECONDS)); + }); + + it("should verify initial leverage conditions", async () => { + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + + expect(currentLeverageRatio.abs()).to.be.gt(methodology.minLeverageRatio.abs()); + expect(currentLeverageRatio.abs()).to.be.lt(methodology.maxLeverageRatio.abs()); + }); + + it("should return rebalance", async () => { + const shouldRebalance = await subject(); + + expect(shouldRebalance).to.eq(BigNumber.from(1)); + }); + }); + + describe("when above max leverage ratio but below incentivized leverage ratio", async () => { + beforeEach(async () => { + await perpV2PriceFeedMock.setPrice(BigNumber.from(1050).mul(10 ** 8)); + await increaseTimeAsync(BigNumber.from(ONE_DAY_IN_SECONDS)); + }); + + it("should verify initial leverage conditions", async () => { + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + + expect(currentLeverageRatio.abs()).to.be.gt(methodology.maxLeverageRatio.abs()); + expect(currentLeverageRatio.abs()).to.be.lt(incentive.incentivizedLeverageRatio.abs()); + }); + + it("should return rebalance", async () => { + const shouldRebalance = await subject(); + + expect(shouldRebalance).to.eq(BigNumber.from(1)); + }); + }); + + describe("when below min leverage ratio", async () => { + beforeEach(async () => { + await perpV2PriceFeedMock.setPrice(BigNumber.from(800).mul(10 ** 8)); + }); + + it("should verify initial leverage conditions", async () => { + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + + expect(currentLeverageRatio.abs()).to.be.lt(methodology.minLeverageRatio.abs()); + }); + + it("should return rebalance", async () => { + const shouldRebalance = await subject(); + + expect(shouldRebalance).to.eq(BigNumber.from(1)); + }); + }); + + describe("when above incentivized leverage ratio and incentivized TWAP cooldown has NOT elapsed", async () => { + beforeEach(async () => { + await perpV2PriceFeedMock.setPrice(BigNumber.from(1200).mul(10 ** 8)); + }); + + it("should verify initial leverage conditions", async () => { + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + + expect(currentLeverageRatio.abs()).to.be.gt(incentive.incentivizedLeverageRatio.abs()); + }); + + it("should not ripcord", async () => { + const shouldRebalance = await subject(); + + expect(shouldRebalance).to.eq(ZERO); + }); + }); + + describe("when between max and min leverage ratio and both rebalance and reinvest interval has NOT elapsed", async () => { + beforeEach(async () => { + await perpV2PriceFeedMock.setPrice(BigNumber.from(1010).mul(10 ** 8)); + }); + + it("should verify initial leverage conditions", async () => { + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + + expect(currentLeverageRatio.abs()).to.be.gt(methodology.minLeverageRatio.abs()); + expect(currentLeverageRatio.abs()).to.be.lt(methodology.maxLeverageRatio.abs()); + }); + + it("should not rebalance and nor reinvest", async () => { + const shouldRebalance = await subject(); + + expect(shouldRebalance).to.eq(ZERO); + }); + }); + + describe("when between max and min leverage ratio and rebalance intereval has NOT elapsed but reinvest interval has elapsed", async () => { + let newMethodology: PerpV2BasisMethodologySettings; + + cacheBeforeEach(async () => { + newMethodology = { + ...methodology, + reinvestInterval: ONE_DAY_IN_SECONDS.div(2) // Set reinvest interval < rebalance interval + }; + await leverageStrategyExtension.setMethodologySettings(newMethodology); + + // set funding rate to NON-ZERO, to allow funding to accrue + await perpV2Setup.clearingHouseConfig.setMaxFundingRate(BigNumber.from(0.1e6)); + }); + + describe("when reinvestment amount is non zero", async () => { + beforeEach(async () => { + // Set oracle price below mark price to accrue positive funding + await perpV2Setup.setBaseTokenOraclePrice(perpV2Setup.vETH, usdc(980)); + await perpV2PriceFeedMock.setPrice(BigNumber.from(980).mul(10 ** 8)); + await increaseTimeAsync(ONE_DAY_IN_SECONDS.div(2)); + }); + + it("should verify initial conditions", async () => { + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + const reinvestInterval = (await leverageStrategyExtension.getMethodology()).reinvestInterval; + const lastReinvestTimestamp = await leverageStrategyExtension.lastReinvestTimestamp(); + const lastBlockTimestamp = await getLastBlockTimestamp(); + + expect(lastReinvestTimestamp.add(reinvestInterval)).lt(lastBlockTimestamp); + expect(currentLeverageRatio.abs()).to.be.gt(methodology.minLeverageRatio.abs()); + expect(currentLeverageRatio.abs()).to.be.lt(methodology.maxLeverageRatio.abs()); + }); + + it("should reinvest", async () => { + const shouldRebalance = await subject(); + + expect(shouldRebalance).to.eq(BigNumber.from(4)); + }); + }); + + describe("when reinvestment amount is zero", async () => { + beforeEach(async () => { + // Set oracle price above mark price to accrue negative funding + await perpV2Setup.setBaseTokenOraclePrice(perpV2Setup.vETH, usdc(1020)); + await perpV2PriceFeedMock.setPrice(BigNumber.from(1020).mul(10 ** 8)); + await increaseTimeAsync(ONE_DAY_IN_SECONDS.div(2)); + }); + + it("should verify initial conditions", async () => { + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + const reinvestInterval = (await leverageStrategyExtension.getMethodology()).reinvestInterval; + const lastReinvestTimestamp = await leverageStrategyExtension.lastReinvestTimestamp(); + const lastBlockTimestamp = await getLastBlockTimestamp(); + + expect(lastReinvestTimestamp.add(reinvestInterval)).lt(lastBlockTimestamp); + expect(currentLeverageRatio.abs()).to.be.gt(methodology.minLeverageRatio.abs()); + expect(currentLeverageRatio.abs()).to.be.lt(methodology.maxLeverageRatio.abs()); + }); + + it("should not reinvest", async () => { + const shouldRebalance = await subject(); + + expect(shouldRebalance).to.eq(ZERO); + }); + }); + }); + + describe("when custom min leverage ratio is above methodology min leverage ratio", async () => { + beforeEach(async () => { + subjectMinLeverageRatio = ether(1.9).mul(-1); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Custom bounds must be valid"); + }); + }); + + describe("when custom max leverage ratio is below methodology max leverage ratio", async () => { + beforeEach(async () => { + subjectMinLeverageRatio = ether(2.2).mul(-1); + }); + + it("should revert", async () => { + await expect(subject()).to.be.revertedWith("Custom bounds must be valid"); + }); + }); + }); + }); + + describe("#getChunkRebalanceNotional", async () => { + let collateralToken: Address; + + cacheBeforeEach(async () => { + await initializeRootScopeContracts(); + + collateralToken = await perpBasisTradingModule.collateralToken(); + + await leverageStrategyExtension.engage(); + await increaseTimeAsync(ONE_DAY_IN_SECONDS); + }); + + async function subject(): Promise<[BigNumber, Address, Address, Address, Address]> { + return await leverageStrategyExtension.getChunkRebalanceNotional(); + } + + context("when in the midst of a TWAP rebalance", async () => { + let exchangeSettings: PerpV2BasisExchangeSettings; + let preTwapLeverageRatio: BigNumber; + + cacheBeforeEach(async () => { + // Set up new rebalance TWAP + await perpV2PriceFeedMock.setPrice(BigNumber.from(1040).mul(10 ** 8)); + + exchangeSettings = { + ...exchange, + twapMaxTradeSize: ether(.01), + incentivizedTwapMaxTradeSize: ether(1) + }; + await leverageStrategyExtension.setExchangeSettings(exchangeSettings); + + preTwapLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + + await leverageStrategyExtension.connect(owner.wallet).rebalance(); + }); + + it("should verify in TWAP rebalance", async () => { + const twapLeverageRatio = await leverageStrategyExtension.twapLeverageRatio(); + expect(twapLeverageRatio.abs()).to.be.gt(ZERO); + }); + + describe("when above incentivized leverage ratio", async () => { + beforeEach(async () => { + // Set to above incentivized ratio + await perpV2PriceFeedMock.setPrice(BigNumber.from(1200).mul(10 ** 8)); + }); + + it("should verify initial leverage conditions", async () => { + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + + expect(currentLeverageRatio.abs()).to.be.gt(incentive.incentivizedLeverageRatio.abs()); + }); + + it("should return correct total rebalance size, sell assets and buy assets", async () => { + const initialPositions = await perpBasisTradingModule.getPositionNotionalInfo(setToken.address); + const newLeverageRatio = methodology.maxLeverageRatio; + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + + const [chunkRebalance, sellAssetOnPerp, buyAssetOnPerp, sellAssetOnDex, buyAssetOnDex] = await subject(); + + const totalRebalanceNotional = preciseDiv( + preciseMul(initialPositions[0].baseBalance, newLeverageRatio.sub(currentLeverageRatio)), // numerator + preciseMul(currentLeverageRatio, ether(1).sub(newLeverageRatio)) // denominator + ); + const expectedTotalRebalance = totalRebalanceNotional.abs().gt(exchangeSettings.incentivizedTwapMaxTradeSize) + ? ( + totalRebalanceNotional.lt(ZERO) + ? exchangeSettings.incentivizedTwapMaxTradeSize.mul(-1) + : exchangeSettings.incentivizedTwapMaxTradeSize + ) + : totalRebalanceNotional; + + expect(sellAssetOnPerp).to.eq(strategy.virtualQuoteAddress); + expect(buyAssetOnPerp).to.eq(strategy.virtualBaseAddress); + expect(sellAssetOnDex).to.eq(strategy.spotAssetAddress); + expect(buyAssetOnDex).to.eq(collateralToken); + expect(chunkRebalance).to.eq(expectedTotalRebalance); + }); + }); + + describe("when below incentivized leverage ratio", async () => { + beforeEach(async () => { + // Set to below incentivized ratio + await perpV2PriceFeedMock.setPrice(BigNumber.from(1040).mul(10 ** 8)); + }); + + it("should verify initial leverage conditions", async () => { + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + + expect(currentLeverageRatio.abs()).to.be.lt(incentive.incentivizedLeverageRatio.abs()); + }); + + it("should return correct total rebalance size, sell asset and buy asset", async () => { + const initialPositions = await perpBasisTradingModule.getPositionNotionalInfo(setToken.address); + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + const newLeverageRatio = calculateNewLeverageRatioPerpV2Basis( + preTwapLeverageRatio, + methodology + ); + + const [chunkRebalance, sellAssetOnPerp, buyAssetOnPerp, sellAssetOnDex, buyAssetOnDex] = await subject(); + + const totalRebalanceNotional = preciseDiv( + preciseMul(initialPositions[0].baseBalance, newLeverageRatio.sub(currentLeverageRatio)), // numerator + preciseMul(currentLeverageRatio, ether(1).sub(newLeverageRatio)) // denominator + ); + const expectedTotalRebalance = totalRebalanceNotional.abs().gt(exchangeSettings.twapMaxTradeSize) + ? (totalRebalanceNotional.lt(ZERO) + ? exchangeSettings.twapMaxTradeSize.mul(-1) + : exchangeSettings.twapMaxTradeSize + ) + : totalRebalanceNotional; + + expect(sellAssetOnPerp).to.eq(strategy.virtualQuoteAddress); + expect(buyAssetOnPerp).to.eq(strategy.virtualBaseAddress); + expect(sellAssetOnDex).to.eq(strategy.spotAssetAddress); + expect(buyAssetOnDex).to.eq(collateralToken); + expect(chunkRebalance).to.eq(expectedTotalRebalance); + }); + }); + }); + + context("when not in a TWAP rebalance", async () => { + describe("when above incentivized leverage ratio", async () => { + beforeEach(async () => { + // Set to above incentivized ratio + await perpV2PriceFeedMock.setPrice(BigNumber.from(1200).mul(10 ** 8)); + }); + + it("should verify initial leverage conditions", async () => { + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + + expect(currentLeverageRatio.abs()).to.be.gt(incentive.incentivizedLeverageRatio.abs()); + }); + + it("should return correct total rebalance size, sell asset and buy asset", async () => { + const initialPositions = await perpBasisTradingModule.getPositionNotionalInfo(setToken.address); + const newLeverageRatio = methodology.maxLeverageRatio; + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + + const [chunkRebalance, sellAssetOnPerp, buyAssetOnPerp, sellAssetOnDex, buyAssetOnDex] = await subject(); + + const expectedTotalRebalanceNotional = preciseDiv( + preciseMul(initialPositions[0].baseBalance, newLeverageRatio.sub(currentLeverageRatio)), // numerator + preciseMul(currentLeverageRatio, ether(1).sub(newLeverageRatio)) // denominator + ); + + expect(sellAssetOnPerp).to.eq(strategy.virtualQuoteAddress); + expect(buyAssetOnPerp).to.eq(strategy.virtualBaseAddress); + expect(sellAssetOnDex).to.eq(strategy.spotAssetAddress); + expect(buyAssetOnDex).to.eq(collateralToken); + expect(chunkRebalance).to.eq(expectedTotalRebalanceNotional); + }); + }); + + describe("when between max and min leverage ratio", async () => { + beforeEach(async () => { + await perpV2PriceFeedMock.setPrice(BigNumber.from(990).mul(10 ** 8)); + }); + + it("should verify initial leverage conditions", async () => { + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + + expect(currentLeverageRatio.abs()).to.be.gt(methodology.minLeverageRatio.abs()); + expect(currentLeverageRatio.abs()).to.be.lt(methodology.maxLeverageRatio.abs()); + }); + + it("should return correct total rebalance size, sell asset and buy asset", async () => { + const initialPositions = await perpBasisTradingModule.getPositionNotionalInfo(setToken.address); + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + const newLeverageRatio = calculateNewLeverageRatioPerpV2Basis( + currentLeverageRatio, + methodology + ); + + const [chunkRebalance, sellAssetOnPerp, buyAssetOnPerp, sellAssetOnDex, buyAssetOnDex] = await subject(); + + const expectedTotalRebalanceNotional = preciseDiv( + preciseMul(initialPositions[0].baseBalance, newLeverageRatio.sub(currentLeverageRatio)), // numerator + preciseMul(currentLeverageRatio, ether(1).sub(newLeverageRatio)) // denominator + ); + + expect(buyAssetOnPerp).to.eq(strategy.virtualQuoteAddress); + expect(sellAssetOnPerp).to.eq(strategy.virtualBaseAddress); + expect(buyAssetOnDex).to.eq(strategy.spotAssetAddress); + expect(sellAssetOnDex).to.eq(collateralToken); + expect(chunkRebalance).to.eq(expectedTotalRebalanceNotional); + }); + }); + + describe("when above max leverage ratio but below incentivized leverage ratio", async () => { + beforeEach(async () => { + await perpV2PriceFeedMock.setPrice(BigNumber.from(1050).mul(10 ** 8)); + }); + + it("should verify initial leverage conditions", async () => { + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + + expect(currentLeverageRatio.abs()).to.be.gt(methodology.maxLeverageRatio.abs()); + expect(currentLeverageRatio.abs()).to.be.lt(incentive.incentivizedLeverageRatio.abs()); + }); + + it("should return correct total rebalance size, sell asset and buy asset", async () => { + const initialPositions = await perpBasisTradingModule.getPositionNotionalInfo(setToken.address); + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + const newLeverageRatio = calculateNewLeverageRatioPerpV2Basis( + currentLeverageRatio, + methodology + ); + + const [chunkRebalance, sellAssetOnPerp, buyAssetOnPerp, sellAssetOnDex, buyAssetOnDex] = await subject(); + + const expectedTotalRebalanceNotional = preciseDiv( + preciseMul(initialPositions[0].baseBalance, newLeverageRatio.sub(currentLeverageRatio)), // numerator + preciseMul(currentLeverageRatio, ether(1).sub(newLeverageRatio)) // denominator + ); + + expect(buyAssetOnPerp).to.eq(strategy.virtualBaseAddress); + expect(sellAssetOnPerp).to.eq(strategy.virtualQuoteAddress); + expect(chunkRebalance).to.eq(expectedTotalRebalanceNotional); + expect(sellAssetOnDex).to.eq(strategy.spotAssetAddress); + expect(buyAssetOnDex).to.eq(collateralToken);; + }); + }); + + describe("when below min leverage ratio", async () => { + beforeEach(async () => { + await perpV2PriceFeedMock.setPrice(BigNumber.from(900).mul(10 ** 8)); + }); + + it("should verify initial leverage conditions", async () => { + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + + expect(currentLeverageRatio.abs()).to.be.lt(methodology.minLeverageRatio.abs()); + }); + + it("should return correct total rebalance size, sell asset and buy asset", async () => { + const initialPositions = await perpBasisTradingModule.getPositionNotionalInfo(setToken.address); + const currentLeverageRatio = await leverageStrategyExtension.getCurrentLeverageRatio(); + const newLeverageRatio = calculateNewLeverageRatioPerpV2Basis( + currentLeverageRatio, + methodology + ); + + const [chunkRebalance, sellAssetOnPerp, buyAssetOnPerp, sellAssetOnDex, buyAssetOnDex] = await subject(); + + const expectedTotalRebalanceNotional = preciseDiv( + preciseMul(initialPositions[0].baseBalance, newLeverageRatio.sub(currentLeverageRatio)), // numerator + preciseMul(currentLeverageRatio, ether(1).sub(newLeverageRatio)) // denominator + ); + + expect(buyAssetOnPerp).to.eq(strategy.virtualQuoteAddress); + expect(sellAssetOnPerp).to.eq(strategy.virtualBaseAddress); + expect(buyAssetOnDex).to.eq(strategy.spotAssetAddress); + expect(sellAssetOnDex).to.eq(collateralToken); + expect(chunkRebalance).to.eq(expectedTotalRebalanceNotional); + }); + }); + }); + }); + + describe("#getCurrentLeverageRatio", async () => { + + cacheBeforeEach(initializeRootScopeContracts); + + async function subject(): Promise { + return await leverageStrategyExtension.getCurrentLeverageRatio(); + } + + describe("when account value is zero", async () => { + it("should return zero", async () => { + const leverageRatio = await subject(); + + expect(leverageRatio).to.equal(ZERO); + }); + }); + }); + }); +}); \ No newline at end of file diff --git a/test/extensions/perpV2LeverageStrategyExtension.spec.ts b/test/extensions/perpV2LeverageStrategyExtension.spec.ts index 964e64c..ebba05d 100644 --- a/test/extensions/perpV2LeverageStrategyExtension.spec.ts +++ b/test/extensions/perpV2LeverageStrategyExtension.spec.ts @@ -5,20 +5,20 @@ import { ethers } from "hardhat"; import { Address, Account, - PerpV2ContractSettings, - PerpV2MethodologySettings, - PerpV2ExecutionSettings, - PerpV2IncentiveSettings, - PerpV2ExchangeSettings + PerpV2LeverageContractSettings, + PerpV2LeverageMethodologySettings, + PerpV2LeverageExecutionSettings, + PerpV2LeverageIncentiveSettings, + PerpV2LeverageExchangeSettings } from "@utils/types"; import { ADDRESS_ZERO, ZERO, ONE_DAY_IN_SECONDS, TWO } from "../../utils/constants"; import { + PerpV2LeverageModuleV2, + SetToken, PositionV2, PerpV2LibraryV2, PerpV2Positions, - PerpV2LeverageModuleV2, - SetToken, SlippageIssuanceModule, ContractCallerMock } from "@setprotocol/set-protocol-v2/utils/contracts"; @@ -59,20 +59,20 @@ describe("PerpV2LeverageStrategyExtension", () => { let deployer: DeployHelper; let setToken: SetToken; - let strategy: PerpV2ContractSettings; - let methodology: PerpV2MethodologySettings; - let execution: PerpV2ExecutionSettings; - let incentive: PerpV2IncentiveSettings; - let exchange: PerpV2ExchangeSettings; + let strategy: PerpV2LeverageContractSettings; + let methodology: PerpV2LeverageMethodologySettings; + let execution: PerpV2LeverageExecutionSettings; + let incentive: PerpV2LeverageIncentiveSettings; + let exchange: PerpV2LeverageExchangeSettings; let customTargetLeverageRatio: any; let customMinLeverageRatio: any; let basePriceDecimalAdjustment: BigNumber; let leverageStrategyExtension: PerpV2LeverageStrategyExtension; + let perpV2LeverageModule: PerpV2LeverageModuleV2; let positionLib: PositionV2; let perpLib: PerpV2LibraryV2; let perpPositionsLib: PerpV2Positions; - let perpV2LeverageModule: PerpV2LeverageModuleV2; let issuanceModule: SlippageIssuanceModule; let baseManager: BaseManager; let maxPerpPositionsPerSet: BigNumber; @@ -139,7 +139,7 @@ describe("PerpV2LeverageStrategyExtension", () => { "contracts/protocol/integration/lib/PerpV2LibraryV2.sol:PerpV2LibraryV2", perpLib.address, "contracts/protocol/integration/lib/PerpV2Positions.sol:PerpV2Positions", - perpPositionsLib.address, + perpPositionsLib.address ); await systemSetup.controller.addModule(issuanceModule.address); @@ -264,11 +264,11 @@ describe("PerpV2LeverageStrategyExtension", () => { describe("#constructor", async () => { let subjectManagerAddress: Address; - let subjectContractSettings: PerpV2ContractSettings; - let subjectPerpV2MethodologySettings: PerpV2MethodologySettings; - let subjectExecutionSettings: PerpV2ExecutionSettings; - let subjectIncentiveSettings: PerpV2IncentiveSettings; - let subjectPerpV2ExchangeSettings: PerpV2ExchangeSettings; + let subjectContractSettings: PerpV2LeverageContractSettings; + let subjectPerpV2LeverageMethodologySettings: PerpV2LeverageMethodologySettings; + let subjectExecutionSettings: PerpV2LeverageExecutionSettings; + let subjectIncentiveSettings: PerpV2LeverageIncentiveSettings; + let subjectPerpV2LeverageExchangeSettings: PerpV2LeverageExchangeSettings; cacheBeforeEach(initializeRootScopeContracts); @@ -284,7 +284,7 @@ describe("PerpV2LeverageStrategyExtension", () => { virtualBaseAddress: perpV2Setup.vETH.address, virtualQuoteAddress: perpV2Setup.vQuote.address, }; - subjectPerpV2MethodologySettings = { + subjectPerpV2LeverageMethodologySettings = { targetLeverageRatio: ether(2), minLeverageRatio: ether(1.7), maxLeverageRatio: ether(2.3), @@ -301,7 +301,7 @@ describe("PerpV2LeverageStrategyExtension", () => { etherReward: ether(1), incentivizedLeverageRatio: ether(3.5), }; - subjectPerpV2ExchangeSettings = { + subjectPerpV2LeverageExchangeSettings = { twapMaxTradeSize: ether(5), incentivizedTwapMaxTradeSize: ether(10), }; @@ -311,10 +311,10 @@ describe("PerpV2LeverageStrategyExtension", () => { return await deployer.extensions.deployPerpV2LeverageStrategyExtension( subjectManagerAddress, subjectContractSettings, - subjectPerpV2MethodologySettings, + subjectPerpV2LeverageMethodologySettings, subjectExecutionSettings, subjectIncentiveSettings, - subjectPerpV2ExchangeSettings + subjectPerpV2LeverageExchangeSettings ); } @@ -344,11 +344,11 @@ describe("PerpV2LeverageStrategyExtension", () => { const retrievedAdapter = await subject(); const methodology = await retrievedAdapter.getMethodology(); - expect(methodology.targetLeverageRatio).to.eq(subjectPerpV2MethodologySettings.targetLeverageRatio); - expect(methodology.minLeverageRatio).to.eq(subjectPerpV2MethodologySettings.minLeverageRatio); - expect(methodology.maxLeverageRatio).to.eq(subjectPerpV2MethodologySettings.maxLeverageRatio); - expect(methodology.recenteringSpeed).to.eq(subjectPerpV2MethodologySettings.recenteringSpeed); - expect(methodology.rebalanceInterval).to.eq(subjectPerpV2MethodologySettings.rebalanceInterval); + expect(methodology.targetLeverageRatio).to.eq(subjectPerpV2LeverageMethodologySettings.targetLeverageRatio); + expect(methodology.minLeverageRatio).to.eq(subjectPerpV2LeverageMethodologySettings.minLeverageRatio); + expect(methodology.maxLeverageRatio).to.eq(subjectPerpV2LeverageMethodologySettings.maxLeverageRatio); + expect(methodology.recenteringSpeed).to.eq(subjectPerpV2LeverageMethodologySettings.recenteringSpeed); + expect(methodology.rebalanceInterval).to.eq(subjectPerpV2LeverageMethodologySettings.rebalanceInterval); }); it("should set the correct execution parameters", async () => { @@ -373,13 +373,13 @@ describe("PerpV2LeverageStrategyExtension", () => { const retrievedAdapter = await subject(); const exchange = await retrievedAdapter.getExchangeSettings(); - expect(exchange.twapMaxTradeSize).to.eq(subjectPerpV2ExchangeSettings.twapMaxTradeSize); - expect(exchange.incentivizedTwapMaxTradeSize).to.eq(subjectPerpV2ExchangeSettings.incentivizedTwapMaxTradeSize); + expect(exchange.twapMaxTradeSize).to.eq(subjectPerpV2LeverageExchangeSettings.twapMaxTradeSize); + expect(exchange.incentivizedTwapMaxTradeSize).to.eq(subjectPerpV2LeverageExchangeSettings.incentivizedTwapMaxTradeSize); }); describe("when min leverage ratio is 0", async () => { beforeEach(async () => { - subjectPerpV2MethodologySettings.minLeverageRatio = ZERO; + subjectPerpV2LeverageMethodologySettings.minLeverageRatio = ZERO; }); it("should revert", async () => { @@ -389,7 +389,7 @@ describe("PerpV2LeverageStrategyExtension", () => { describe("when min leverage ratio is above target", async () => { beforeEach(async () => { - subjectPerpV2MethodologySettings.minLeverageRatio = ether(2.1); + subjectPerpV2LeverageMethodologySettings.minLeverageRatio = ether(2.1); }); it("should revert", async () => { @@ -399,7 +399,7 @@ describe("PerpV2LeverageStrategyExtension", () => { describe("when max leverage ratio is below target", async () => { beforeEach(async () => { - subjectPerpV2MethodologySettings.maxLeverageRatio = ether(1.9); + subjectPerpV2LeverageMethodologySettings.maxLeverageRatio = ether(1.9); }); it("should revert", async () => { @@ -409,7 +409,7 @@ describe("PerpV2LeverageStrategyExtension", () => { describe("when recentering speed is >100%", async () => { beforeEach(async () => { - subjectPerpV2MethodologySettings.recenteringSpeed = ether(1.1); + subjectPerpV2LeverageMethodologySettings.recenteringSpeed = ether(1.1); }); it("should revert", async () => { @@ -419,7 +419,7 @@ describe("PerpV2LeverageStrategyExtension", () => { describe("when recentering speed is 0%", async () => { beforeEach(async () => { - subjectPerpV2MethodologySettings.recenteringSpeed = ZERO; + subjectPerpV2LeverageMethodologySettings.recenteringSpeed = ZERO; }); it("should revert", async () => { @@ -459,7 +459,7 @@ describe("PerpV2LeverageStrategyExtension", () => { describe("when rebalance interval is shorter than TWAP cooldown period", async () => { beforeEach(async () => { - subjectPerpV2MethodologySettings.rebalanceInterval = ZERO; + subjectPerpV2LeverageMethodologySettings.rebalanceInterval = ZERO; }); it("should revert", async () => { @@ -479,7 +479,7 @@ describe("PerpV2LeverageStrategyExtension", () => { describe("when an exchange has a twapMaxTradeSize of 0", async () => { beforeEach(async () => { - subjectPerpV2ExchangeSettings.twapMaxTradeSize = ZERO; + subjectPerpV2LeverageExchangeSettings.twapMaxTradeSize = ZERO; }); it("should revert", async () => { @@ -619,14 +619,14 @@ describe("PerpV2LeverageStrategyExtension", () => { context("when rebalance notional is greater than max trade size", async () => { describe("when the collateral balance is not zero", () => { - let newPerpV2ExchangeSettings: PerpV2ExchangeSettings; + let newPerpV2LeverageExchangeSettings: PerpV2LeverageExchangeSettings; beforeEach(async () => { - newPerpV2ExchangeSettings = { + newPerpV2LeverageExchangeSettings = { twapMaxTradeSize: ether(10), incentivizedTwapMaxTradeSize: ether(15), }; - await leverageStrategyExtension.setExchangeSettings(newPerpV2ExchangeSettings); + await leverageStrategyExtension.setExchangeSettings(newPerpV2LeverageExchangeSettings); }); it("should set the last trade timestamp", async () => { @@ -654,7 +654,7 @@ describe("PerpV2LeverageStrategyExtension", () => { expect(initialPositions.length).to.eq(0); expect(finalPositions.length).to.eq(1); - expect(finalPositions[0].baseBalance).to.eq(newPerpV2ExchangeSettings.twapMaxTradeSize); + expect(finalPositions[0].baseBalance).to.eq(newPerpV2LeverageExchangeSettings.twapMaxTradeSize); expect(finalPositions[0].baseToken).to.eq(strategy.virtualBaseAddress); }); @@ -662,7 +662,7 @@ describe("PerpV2LeverageStrategyExtension", () => { const currentCollateral = (await perpV2LeverageModule.getAccountInfo(setToken.address)).collateralBalance; const basePrice = (await perpV2Setup.ethPriceFeed.latestAnswer()).div(usdc(1)); - const chunkRebalanceNotional = newPerpV2ExchangeSettings.twapMaxTradeSize; + const chunkRebalanceNotional = newPerpV2LeverageExchangeSettings.twapMaxTradeSize; const totalRebalanceNotional = preciseMul(currentCollateral, methodology.targetLeverageRatio).div(basePrice); await expect(subject()).to.emit(leverageStrategyExtension, "Engaged").withArgs( @@ -710,7 +710,7 @@ describe("PerpV2LeverageStrategyExtension", () => { }); context("when engaging a short position", async () => { - let newMethodologySettings: PerpV2MethodologySettings; + let newMethodologySettings: PerpV2LeverageMethodologySettings; beforeEach(async () => { newMethodologySettings = { @@ -766,20 +766,20 @@ describe("PerpV2LeverageStrategyExtension", () => { }); describe("when rebalance notional is greater than max trade size ", async () => { - let newPerpV2ExchangeSettings: PerpV2ExchangeSettings; + let newPerpV2LeverageExchangeSettings: PerpV2LeverageExchangeSettings; beforeEach(async () => { - newPerpV2ExchangeSettings = { + newPerpV2LeverageExchangeSettings = { twapMaxTradeSize: ether(10), incentivizedTwapMaxTradeSize: ether(15), }; - await leverageStrategyExtension.setExchangeSettings(newPerpV2ExchangeSettings); + await leverageStrategyExtension.setExchangeSettings(newPerpV2LeverageExchangeSettings); }); it("should open a base token position on Perpetual Protocol", async () => { const initialPositions = await perpV2LeverageModule.getPositionNotionalInfo(setToken.address); - const totalRebalanceNotional = newPerpV2ExchangeSettings.twapMaxTradeSize.mul(-1); + const totalRebalanceNotional = newPerpV2LeverageExchangeSettings.twapMaxTradeSize.mul(-1); await subject(); @@ -804,7 +804,7 @@ describe("PerpV2LeverageStrategyExtension", () => { const targetLeverageRatio = (await leverageStrategyExtension.getMethodology()).targetLeverageRatio; const basePrice = (await perpV2Setup.ethPriceFeed.latestAnswer()).div(usdc(1)); - const chunkRebalanceNotional = newPerpV2ExchangeSettings.twapMaxTradeSize.mul(-1); + const chunkRebalanceNotional = newPerpV2LeverageExchangeSettings.twapMaxTradeSize.mul(-1); const totalRebalanceNotional = preciseMul(currentCollateral, targetLeverageRatio).div(basePrice); await expect(subject()).to.emit(leverageStrategyExtension, "Engaged").withArgs( @@ -986,7 +986,7 @@ describe("PerpV2LeverageStrategyExtension", () => { }); describe("when rebalance interval has not elapsed below min leverage ratio and greater than max trade size", async () => { - let newSettings: PerpV2ExchangeSettings; + let newSettings: PerpV2LeverageExchangeSettings; cacheBeforeEach(async () => { await perpV2Setup.setBaseTokenOraclePrice(perpV2Setup.vETH, usdc(1500)); @@ -1177,7 +1177,7 @@ describe("PerpV2LeverageStrategyExtension", () => { }); describe("when rebalance interval has not elapsed, above max leverage ratio and greater than max trade size", async () => { - let newSettings: PerpV2ExchangeSettings; + let newSettings: PerpV2LeverageExchangeSettings; cacheBeforeEach(async () => { await perpV2Setup.setBaseTokenOraclePrice(perpV2Setup.vETH, usdc(850)); @@ -1241,7 +1241,7 @@ describe("PerpV2LeverageStrategyExtension", () => { }); describe("when in a TWAP rebalance", async () => { - let newSettings: PerpV2ExchangeSettings; + let newSettings: PerpV2LeverageExchangeSettings; beforeEach(async () => { // Setup a TWAP rebalance @@ -1313,7 +1313,7 @@ describe("PerpV2LeverageStrategyExtension", () => { }); context("when rebalancing a short position", async () => { - let newMethodologySettings: PerpV2MethodologySettings; + let newMethodologySettings: PerpV2LeverageMethodologySettings; cacheBeforeEach(async () => { newMethodologySettings = { @@ -1476,7 +1476,7 @@ describe("PerpV2LeverageStrategyExtension", () => { }); describe("when rebalance interval has not elapsed below min leverage ratio and greater than max trade size", async () => { - let newExchangeSettings: PerpV2ExchangeSettings; + let newExchangeSettings: PerpV2LeverageExchangeSettings; cacheBeforeEach(async () => { await perpV2Setup.setBaseTokenOraclePrice(perpV2Setup.vETH, usdc(900)); @@ -1668,7 +1668,7 @@ describe("PerpV2LeverageStrategyExtension", () => { }); describe("when rebalance interval has not elapsed, above max leverage ratio and greater than max trade size", async () => { - let newSettings: PerpV2ExchangeSettings; + let newSettings: PerpV2LeverageExchangeSettings; cacheBeforeEach(async () => { await perpV2Setup.setBaseTokenOraclePrice(perpV2Setup.vETH, usdc(1060)); @@ -1733,7 +1733,7 @@ describe("PerpV2LeverageStrategyExtension", () => { }); describe("when in a TWAP rebalance", async () => { - let newSettings: PerpV2ExchangeSettings; + let newSettings: PerpV2LeverageExchangeSettings; beforeEach(async () => { // Setup a TWAP rebalance @@ -1784,7 +1784,7 @@ describe("PerpV2LeverageStrategyExtension", () => { }); context("when currently in the last chunk of a TWAP rebalance", async () => { - let newExchangeSettings: PerpV2ExchangeSettings; + let newExchangeSettings: PerpV2LeverageExchangeSettings; cacheBeforeEach(async () => { await increaseTimeAsync(ONE_DAY_IN_SECONDS); @@ -1859,7 +1859,7 @@ describe("PerpV2LeverageStrategyExtension", () => { }); context("when current leverage ratio is below target and in the middle of a TWAP", async () => { - let newExchangeSettings: PerpV2ExchangeSettings; + let newExchangeSettings: PerpV2LeverageExchangeSettings; let preTwapLeverageRatio: BigNumber; cacheBeforeEach(async () => { @@ -1952,7 +1952,7 @@ describe("PerpV2LeverageStrategyExtension", () => { }); context("when current leverage ratio is above target and in the middle of a TWAP", async () => { - let newExchangeSettings: PerpV2ExchangeSettings; + let newExchangeSettings: PerpV2LeverageExchangeSettings; let preTwapLeverageRatio: BigNumber; cacheBeforeEach(async () => { @@ -2043,7 +2043,7 @@ describe("PerpV2LeverageStrategyExtension", () => { }); describe("when price has moved advantageously towards target leverage ratio", async () => { - let newExchangeSettings: PerpV2ExchangeSettings; + let newExchangeSettings: PerpV2LeverageExchangeSettings; cacheBeforeEach(async () => { await increaseTimeAsync(ONE_DAY_IN_SECONDS); @@ -2109,7 +2109,7 @@ describe("PerpV2LeverageStrategyExtension", () => { }); describe("when cooldown has not elapsed", async () => { - let newExchangeSettings: PerpV2ExchangeSettings; + let newExchangeSettings: PerpV2LeverageExchangeSettings; beforeEach(async () => { await increaseTimeAsync(ONE_DAY_IN_SECONDS); @@ -2148,7 +2148,7 @@ describe("PerpV2LeverageStrategyExtension", () => { }); describe("when caller is not an allowed trader", async () => { - let newExchangeSettings: PerpV2ExchangeSettings; + let newExchangeSettings: PerpV2LeverageExchangeSettings; beforeEach(async () => { subjectCaller = await getRandomAccount(); @@ -2215,7 +2215,7 @@ describe("PerpV2LeverageStrategyExtension", () => { }); context("when rebalancing short position", async () => { - let newMethodologySettings: PerpV2MethodologySettings; + let newMethodologySettings: PerpV2LeverageMethodologySettings; cacheBeforeEach(async () => { newMethodologySettings = { @@ -2232,7 +2232,7 @@ describe("PerpV2LeverageStrategyExtension", () => { }); context("when currently in the last chunk of a TWAP rebalance", async () => { - let newExchangeSettings: PerpV2ExchangeSettings; + let newExchangeSettings: PerpV2LeverageExchangeSettings; let preTwapLeverageRatio: BigNumber; cacheBeforeEach(async () => { @@ -2310,7 +2310,7 @@ describe("PerpV2LeverageStrategyExtension", () => { }); context("when current leverage ratio is below target and in the middle of a TWAP", async () => { - let newExchangeSettings: PerpV2ExchangeSettings; + let newExchangeSettings: PerpV2LeverageExchangeSettings; let preTwapLeverageRatio: BigNumber; cacheBeforeEach(async () => { @@ -2399,7 +2399,7 @@ describe("PerpV2LeverageStrategyExtension", () => { }); context("when current leverage ratio is above target and in the middle of a TWAP", async () => { - let newExchangeSettings: PerpV2ExchangeSettings; + let newExchangeSettings: PerpV2LeverageExchangeSettings; let preTwapLeverageRatio: BigNumber; cacheBeforeEach(async () => { @@ -2488,7 +2488,7 @@ describe("PerpV2LeverageStrategyExtension", () => { }); describe("when price has moved advantageously towards target leverage ratio", async () => { - let newExchangeSettings: PerpV2ExchangeSettings; + let newExchangeSettings: PerpV2LeverageExchangeSettings; cacheBeforeEach(async () => { await increaseTimeAsync(ONE_DAY_IN_SECONDS); @@ -2697,11 +2697,11 @@ describe("PerpV2LeverageStrategyExtension", () => { cacheBeforeEach(async () => { newIncentivizedMaxTradeSize = ether(0.01); - const newPerpV2ExchangeSettings: PerpV2ExchangeSettings = { + const newPerpV2LeverageExchangeSettings: PerpV2LeverageExchangeSettings = { twapMaxTradeSize: ether(0.001), incentivizedTwapMaxTradeSize: newIncentivizedMaxTradeSize }; - await leverageStrategyExtension.setExchangeSettings(newPerpV2ExchangeSettings); + await leverageStrategyExtension.setExchangeSettings(newPerpV2LeverageExchangeSettings); }); it("should set the global last trade timestamp", async () => { @@ -2735,11 +2735,11 @@ describe("PerpV2LeverageStrategyExtension", () => { cacheBeforeEach(async () => { newIncentivizedMaxTradeSize = ether(0.01); - const newPerpV2ExchangeSettings: PerpV2ExchangeSettings = { + const newPerpV2LeverageExchangeSettings: PerpV2LeverageExchangeSettings = { twapMaxTradeSize: ether(0.001), incentivizedTwapMaxTradeSize: newIncentivizedMaxTradeSize }; - await leverageStrategyExtension.setExchangeSettings(newPerpV2ExchangeSettings); + await leverageStrategyExtension.setExchangeSettings(newPerpV2LeverageExchangeSettings); }); beforeEach(async () => { @@ -2810,11 +2810,11 @@ describe("PerpV2LeverageStrategyExtension", () => { await owner.wallet.sendTransaction({to: leverageStrategyExtension.address, value: transferredEth}); newIncentivizedMaxTradeSize = ether(0.001); - const newPerpV2ExchangeSettings: PerpV2ExchangeSettings = { + const newPerpV2LeverageExchangeSettings: PerpV2LeverageExchangeSettings = { twapMaxTradeSize: ether(0.001), incentivizedTwapMaxTradeSize: newIncentivizedMaxTradeSize }; - await leverageStrategyExtension.setExchangeSettings(newPerpV2ExchangeSettings); + await leverageStrategyExtension.setExchangeSettings(newPerpV2LeverageExchangeSettings); await perpV2Setup.setBaseTokenOraclePrice(perpV2Setup.vETH, usdc(990)); await perpV2PriceFeedMock.setPrice(BigNumber.from(990).mul(10 ** 8)); @@ -2847,8 +2847,8 @@ describe("PerpV2LeverageStrategyExtension", () => { }); context("when ripcord short position", async () => { - let newMethodologySettings: PerpV2MethodologySettings; - let newIncentiveSettings: PerpV2IncentiveSettings; + let newMethodologySettings: PerpV2LeverageMethodologySettings; + let newIncentiveSettings: PerpV2LeverageIncentiveSettings; cacheBeforeEach(async () => { newMethodologySettings = { @@ -2980,11 +2980,11 @@ describe("PerpV2LeverageStrategyExtension", () => { cacheBeforeEach(async () => { newIncentivizedMaxTradeSize = ether(0.01); - const newPerpV2ExchangeSettings: PerpV2ExchangeSettings = { + const newPerpV2LeverageExchangeSettings: PerpV2LeverageExchangeSettings = { twapMaxTradeSize: ether(0.001), incentivizedTwapMaxTradeSize: newIncentivizedMaxTradeSize }; - await leverageStrategyExtension.setExchangeSettings(newPerpV2ExchangeSettings); + await leverageStrategyExtension.setExchangeSettings(newPerpV2LeverageExchangeSettings); }); it("should set the global last trade timestamp", async () => { @@ -3018,11 +3018,11 @@ describe("PerpV2LeverageStrategyExtension", () => { cacheBeforeEach(async () => { newIncentivizedMaxTradeSize = ether(0.01); - const newPerpV2ExchangeSettings: PerpV2ExchangeSettings = { + const newPerpV2LeverageExchangeSettings: PerpV2LeverageExchangeSettings = { twapMaxTradeSize: ether(0.001), incentivizedTwapMaxTradeSize: newIncentivizedMaxTradeSize }; - await leverageStrategyExtension.setExchangeSettings(newPerpV2ExchangeSettings); + await leverageStrategyExtension.setExchangeSettings(newPerpV2LeverageExchangeSettings); }); beforeEach(async () => { @@ -3054,11 +3054,11 @@ describe("PerpV2LeverageStrategyExtension", () => { await owner.wallet.sendTransaction({to: leverageStrategyExtension.address, value: transferredEth}); newIncentivizedMaxTradeSize = ether(0.001); - const newPerpV2ExchangeSettings: PerpV2ExchangeSettings = { + const newPerpV2LeverageExchangeSettings: PerpV2LeverageExchangeSettings = { twapMaxTradeSize: ether(0.001), incentivizedTwapMaxTradeSize: newIncentivizedMaxTradeSize }; - await leverageStrategyExtension.setExchangeSettings(newPerpV2ExchangeSettings); + await leverageStrategyExtension.setExchangeSettings(newPerpV2LeverageExchangeSettings); await perpV2Setup.setBaseTokenOraclePrice(perpV2Setup.vETH, usdc(950)); await perpV2PriceFeedMock.setPrice(BigNumber.from(950).mul(10 ** 8)); @@ -3196,7 +3196,7 @@ describe("PerpV2LeverageStrategyExtension", () => { }); context("when notional is greater than max trade size", async () => { - let newExchangeSettings: PerpV2ExchangeSettings; + let newExchangeSettings: PerpV2LeverageExchangeSettings; before(async () => { ifEngaged = true; @@ -3298,7 +3298,7 @@ describe("PerpV2LeverageStrategyExtension", () => { }); context("when disengage short position", async () => { - let newMethodologySettings: PerpV2MethodologySettings; + let newMethodologySettings: PerpV2LeverageMethodologySettings; cacheBeforeEach(async () => { newMethodologySettings = { @@ -3363,7 +3363,7 @@ describe("PerpV2LeverageStrategyExtension", () => { }); context("when notional is greater than max trade size", async () => { - let newExchangeSettings: PerpV2ExchangeSettings; + let newExchangeSettings: PerpV2LeverageExchangeSettings; before(async () => { ifEngaged = true; @@ -3440,7 +3440,7 @@ describe("PerpV2LeverageStrategyExtension", () => { }); describe("#setMethodologySettings", async () => { - let subjectMethodologySettings: PerpV2MethodologySettings; + let subjectMethodologySettings: PerpV2LeverageMethodologySettings; let subjectCaller: Account; const initializeSubjectVariables = () => { @@ -3474,7 +3474,7 @@ describe("PerpV2LeverageStrategyExtension", () => { expect(methodology.rebalanceInterval).to.eq(subjectMethodologySettings.rebalanceInterval); }); - it("should emit PerpV2MethodologySettingsUpdated event", async () => { + it("should emit PerpV2LeverageMethodologySettingsUpdated event", async () => { await expect(subject()).to.emit(leverageStrategyExtension, "MethodologySettingsUpdated").withArgs( subjectMethodologySettings.targetLeverageRatio, subjectMethodologySettings.minLeverageRatio, @@ -3566,7 +3566,7 @@ describe("PerpV2LeverageStrategyExtension", () => { }); describe("when rebalance is in progress", async () => { - let newExchangeSettings: PerpV2ExchangeSettings; + let newExchangeSettings: PerpV2LeverageExchangeSettings; beforeEach(async () => { await initializeRootScopeContracts(); @@ -3590,7 +3590,7 @@ describe("PerpV2LeverageStrategyExtension", () => { }); describe("#setExecutionSettings", async () => { - let subjectExecutionSettings: PerpV2ExecutionSettings; + let subjectExecutionSettings: PerpV2LeverageExecutionSettings; let subjectCaller: Account; const initializeSubjectVariables = () => { @@ -3666,7 +3666,7 @@ describe("PerpV2LeverageStrategyExtension", () => { }); describe("when rebalance is in progress", async () => { - let newExchangeSettings: PerpV2ExchangeSettings; + let newExchangeSettings: PerpV2LeverageExchangeSettings; beforeEach(async () => { await initializeRootScopeContracts(); @@ -3690,7 +3690,7 @@ describe("PerpV2LeverageStrategyExtension", () => { }); describe("#setIncentiveSettings", async () => { - let subjectIncentiveSettings: PerpV2IncentiveSettings; + let subjectIncentiveSettings: PerpV2LeverageIncentiveSettings; let subjectCaller: Account; const initializeSubjectVariables = () => { @@ -3773,7 +3773,7 @@ describe("PerpV2LeverageStrategyExtension", () => { }); describe("when rebalance is in progress", async () => { - let newExchangeSettings: PerpV2ExchangeSettings; + let newExchangeSettings: PerpV2LeverageExchangeSettings; beforeEach(async () => { await initializeRootScopeContracts(); @@ -3797,7 +3797,7 @@ describe("PerpV2LeverageStrategyExtension", () => { }); describe("#setExchangeSettings", async () => { - let subjectExchangeSettings: PerpV2ExchangeSettings; + let subjectExchangeSettings: PerpV2LeverageExchangeSettings; let subjectCaller: Account; cacheBeforeEach(initializeRootScopeContracts); @@ -3910,7 +3910,7 @@ describe("PerpV2LeverageStrategyExtension", () => { }); describe("when rebalance is in progress", async () => { - let newExchangeSettings: PerpV2ExchangeSettings; + let newExchangeSettings: PerpV2LeverageExchangeSettings; beforeEach(async () => { await initializeRootScopeContracts(); @@ -4012,7 +4012,7 @@ describe("PerpV2LeverageStrategyExtension", () => { }); context("when in the midst of a TWAP rebalance", async () => { - let newExchangeSettings: PerpV2ExchangeSettings; + let newExchangeSettings: PerpV2LeverageExchangeSettings; cacheBeforeEach(async () => { // Set up new rebalance TWAP @@ -4228,8 +4228,8 @@ describe("PerpV2LeverageStrategyExtension", () => { }); context("when short position", async () => { - let newMethodologySettings: PerpV2MethodologySettings; - let newIncentiveSettings: PerpV2IncentiveSettings; + let newMethodologySettings: PerpV2LeverageMethodologySettings; + let newIncentiveSettings: PerpV2LeverageIncentiveSettings; cacheBeforeEach(async () => { newMethodologySettings = { @@ -4253,7 +4253,7 @@ describe("PerpV2LeverageStrategyExtension", () => { }); context("when in the midst of a TWAP rebalance", async () => { - let newExchangeSettings: PerpV2ExchangeSettings; + let newExchangeSettings: PerpV2LeverageExchangeSettings; cacheBeforeEach(async () => { // Set up new rebalance TWAP @@ -4507,7 +4507,7 @@ describe("PerpV2LeverageStrategyExtension", () => { }); context("when in the midst of a TWAP rebalance", async () => { - let newExchangeSettings: PerpV2ExchangeSettings; + let newExchangeSettings: PerpV2LeverageExchangeSettings; cacheBeforeEach(async () => { // Set up new rebalance TWAP @@ -4743,8 +4743,8 @@ describe("PerpV2LeverageStrategyExtension", () => { }); context("when short position", async () => { - let newMethodologySettings: PerpV2MethodologySettings; - let newIncentiveSettings: PerpV2IncentiveSettings; + let newMethodologySettings: PerpV2LeverageMethodologySettings; + let newIncentiveSettings: PerpV2LeverageIncentiveSettings; cacheBeforeEach(async () => { newMethodologySettings = { @@ -4773,7 +4773,7 @@ describe("PerpV2LeverageStrategyExtension", () => { }); context("when in the midst of a TWAP rebalance", async () => { - let newExchangeSettings: PerpV2ExchangeSettings; + let newExchangeSettings: PerpV2LeverageExchangeSettings; cacheBeforeEach(async () => { // Set up new rebalance TWAP @@ -5037,7 +5037,7 @@ describe("PerpV2LeverageStrategyExtension", () => { }); context("when in the midst of a TWAP rebalance", async () => { - let exchangeSettings: PerpV2ExchangeSettings; + let exchangeSettings: PerpV2LeverageExchangeSettings; let preTwapLeverageRatio: BigNumber; cacheBeforeEach(async () => { @@ -5290,8 +5290,8 @@ describe("PerpV2LeverageStrategyExtension", () => { }); context("when short position", async () => { - let newMethodologySettings: PerpV2MethodologySettings; - let newIncentiveSettings: PerpV2IncentiveSettings; + let newMethodologySettings: PerpV2LeverageMethodologySettings; + let newIncentiveSettings: PerpV2LeverageIncentiveSettings; cacheBeforeEach(async () => { newMethodologySettings = { @@ -5316,7 +5316,7 @@ describe("PerpV2LeverageStrategyExtension", () => { }); context("when in the midst of a TWAP rebalance", async () => { - let exchangeSettings: PerpV2ExchangeSettings; + let exchangeSettings: PerpV2LeverageExchangeSettings; let preTwapLeverageRatio: BigNumber; cacheBeforeEach(async () => { diff --git a/utils/contracts/index.ts b/utils/contracts/index.ts index d98c7d7..52d1914 100644 --- a/utils/contracts/index.ts +++ b/utils/contracts/index.ts @@ -4,6 +4,7 @@ export { BaseManager } from "../..//typechain/BaseManager"; export { ChainlinkAggregatorMock } from "../../typechain/ChainlinkAggregatorMock"; export { DelegatedManager } from "../../typechain/DelegatedManager"; export { DelegatedManagerFactory } from "../../typechain/DelegatedManagerFactory"; +export { DeltaNeutralBasisTradingStrategyExtension } from "../../typechain/DeltaNeutralBasisTradingStrategyExtension"; export { ManagerCore } from "../../typechain/ManagerCore"; export { ManagerMock } from "../../typechain/ManagerMock"; export { ModuleMock } from "../../typechain/ModuleMock"; diff --git a/utils/deploys/deployExtensions.ts b/utils/deploys/deployExtensions.ts index b3beae2..60305d7 100644 --- a/utils/deploys/deployExtensions.ts +++ b/utils/deploys/deployExtensions.ts @@ -1,17 +1,24 @@ import { Signer, BigNumber } from "ethers"; import { Address, - PerpV2ContractSettings, - PerpV2MethodologySettings, - PerpV2ExecutionSettings, - PerpV2IncentiveSettings, - PerpV2ExchangeSettings + PerpV2LeverageContractSettings, + PerpV2LeverageMethodologySettings, + PerpV2LeverageExecutionSettings, + PerpV2LeverageIncentiveSettings, + PerpV2LeverageExchangeSettings, + PerpV2BasisContractSettings, + PerpV2BasisMethodologySettings, + PerpV2BasisExecutionSettings, + PerpV2BasisIncentiveSettings, + PerpV2BasisExchangeSettings } from "../types"; import { + DeltaNeutralBasisTradingStrategyExtension, PerpV2LeverageStrategyExtension, FeeSplitExtension } from "../contracts/index"; +import { DeltaNeutralBasisTradingStrategyExtension__factory } from "../../typechain/factories/DeltaNeutralBasisTradingStrategyExtension__factory"; import { PerpV2LeverageStrategyExtension__factory } from "../../typechain/factories/PerpV2LeverageStrategyExtension__factory"; import { FeeSplitExtension__factory } from "../../typechain/factories/FeeSplitExtension__factory"; @@ -24,11 +31,11 @@ export default class DeployExtensions { public async deployPerpV2LeverageStrategyExtension( manager: Address, - contractSettings: PerpV2ContractSettings, - methodologySettings: PerpV2MethodologySettings, - executionSettings: PerpV2ExecutionSettings, - incentiveSettings: PerpV2IncentiveSettings, - exchangeSettings: PerpV2ExchangeSettings + contractSettings: PerpV2LeverageContractSettings, + methodologySettings: PerpV2LeverageMethodologySettings, + executionSettings: PerpV2LeverageExecutionSettings, + incentiveSettings: PerpV2LeverageIncentiveSettings, + exchangeSettings: PerpV2LeverageExchangeSettings ): Promise { return await new PerpV2LeverageStrategyExtension__factory(this._deployerSigner).deploy( manager, @@ -40,6 +47,24 @@ export default class DeployExtensions { ); } + public async deployDeltaNeutralBasisTradingStrategyExtension( + manager: Address, + contractSettings: PerpV2BasisContractSettings, + methodologySettings: PerpV2BasisMethodologySettings, + executionSettings: PerpV2BasisExecutionSettings, + incentiveSettings: PerpV2BasisIncentiveSettings, + exchangeSettings: PerpV2BasisExchangeSettings + ): Promise { + return await new DeltaNeutralBasisTradingStrategyExtension__factory(this._deployerSigner).deploy( + manager, + contractSettings, + methodologySettings, + executionSettings, + incentiveSettings, + exchangeSettings, + ); + } + public async deployFeeSplitExtension( manager: Address, streamingFeeModule: Address, diff --git a/utils/flexibleLeverageUtils/flexibleLeverage.ts b/utils/flexibleLeverageUtils/flexibleLeverage.ts index f8b0347..c6197f5 100644 --- a/utils/flexibleLeverageUtils/flexibleLeverage.ts +++ b/utils/flexibleLeverageUtils/flexibleLeverage.ts @@ -33,6 +33,23 @@ export function calculateNewLeverageRatioPerpV2( return currentLeverageRatio.gt(0) ? nlr : nlr.mul(-1); } +export function calculateNewLeverageRatioPerpV2Basis( + currentLeverageRatio: BigNumber, + methodology: { + targetLeverageRatio: BigNumber; + minLeverageRatio: BigNumber; + maxLeverageRatio: BigNumber; + recenteringSpeed: BigNumber; + } +): BigNumber { + const a = preciseMul(methodology.targetLeverageRatio.abs(), methodology.recenteringSpeed); + const b = preciseMul(ether(1).sub(methodology.recenteringSpeed), currentLeverageRatio.abs()); + const c = a.add(b); + const d = c.lt(methodology.maxLeverageRatio.abs()) ? c : methodology.maxLeverageRatio.abs(); + const nlr = methodology.minLeverageRatio.abs().gte(d) ? methodology.minLeverageRatio.abs() : d; + return currentLeverageRatio.gt(0) ? nlr : nlr.mul(-1); +} + export function calculateCollateralRebalanceUnits( currentLeverageRatio: BigNumber, newLeverageRatio: BigNumber, diff --git a/utils/flexibleLeverageUtils/index.ts b/utils/flexibleLeverageUtils/index.ts index fb818d6..3420774 100644 --- a/utils/flexibleLeverageUtils/index.ts +++ b/utils/flexibleLeverageUtils/index.ts @@ -4,5 +4,6 @@ export { calculateMaxBorrowForDelever, calculateMaxRedeemForDeleverToZero, calculateTotalRebalanceNotionalPerpV2, - calculateNewLeverageRatioPerpV2 + calculateNewLeverageRatioPerpV2, + calculateNewLeverageRatioPerpV2Basis } from "./flexibleLeverage"; \ No newline at end of file diff --git a/utils/index.ts b/utils/index.ts index 8e57dfa..fbf47a7 100644 --- a/utils/index.ts +++ b/utils/index.ts @@ -43,5 +43,6 @@ export { calculateMaxBorrowForDelever, calculateMaxRedeemForDeleverToZero, calculateTotalRebalanceNotionalPerpV2, - calculateNewLeverageRatioPerpV2 + calculateNewLeverageRatioPerpV2, + calculateNewLeverageRatioPerpV2Basis } from "./flexibleLeverageUtils"; \ No newline at end of file diff --git a/utils/types.ts b/utils/types.ts index b0cb365..acfc44c 100644 --- a/utils/types.ts +++ b/utils/types.ts @@ -33,7 +33,7 @@ export interface MerkleDistributorInfo { export type DistributionFormat = { address: string; earnings: BigNumber }; -export interface PerpV2ContractSettings { +export interface PerpV2LeverageContractSettings { setToken: Address; perpV2LeverageModule: Address; perpV2AccountBalance: Address; @@ -44,7 +44,7 @@ export interface PerpV2ContractSettings { virtualQuoteAddress: Address; } -export interface PerpV2MethodologySettings { +export interface PerpV2LeverageMethodologySettings { targetLeverageRatio: BigNumber; minLeverageRatio: BigNumber; maxLeverageRatio: BigNumber; @@ -52,17 +52,61 @@ export interface PerpV2MethodologySettings { rebalanceInterval: BigNumber; } -export interface PerpV2ExecutionSettings { +export interface PerpV2LeverageExecutionSettings { twapCooldownPeriod: BigNumber; slippageTolerance: BigNumber; } -export interface PerpV2ExchangeSettings { +export interface PerpV2LeverageExchangeSettings { twapMaxTradeSize: BigNumber; incentivizedTwapMaxTradeSize: BigNumber; } -export interface PerpV2IncentiveSettings { +export interface PerpV2LeverageIncentiveSettings { + incentivizedTwapCooldownPeriod: BigNumber; + incentivizedSlippageTolerance: BigNumber; + etherReward: BigNumber; + incentivizedLeverageRatio: BigNumber; +} + +export interface PerpV2BasisContractSettings { + setToken: Address; + basisTradingModule: Address; + tradeModule: Address; + quoter: Address; + perpV2AccountBalance: Address; + baseUSDPriceOracle: Address; + twapInterval: BigNumber; + basePriceDecimalAdjustment: BigNumber; + virtualBaseAddress: Address; + virtualQuoteAddress: Address; + spotAssetAddress: Address; +} + +export interface PerpV2BasisMethodologySettings { + targetLeverageRatio: BigNumber; + minLeverageRatio: BigNumber; + maxLeverageRatio: BigNumber; + recenteringSpeed: BigNumber; + rebalanceInterval: BigNumber; + reinvestInterval: BigNumber; +} + +export interface PerpV2BasisExecutionSettings { + twapCooldownPeriod: BigNumber; + slippageTolerance: BigNumber; +} + +export interface PerpV2BasisExchangeSettings { + exchangeName: string; + buyExactSpotTradeData: Bytes; + sellExactSpotTradeData: Bytes; + buySpotQuoteExactInputPath: Bytes; + twapMaxTradeSize: BigNumber; + incentivizedTwapMaxTradeSize: BigNumber; +} + +export interface PerpV2BasisIncentiveSettings { incentivizedTwapCooldownPeriod: BigNumber; incentivizedSlippageTolerance: BigNumber; etherReward: BigNumber; diff --git a/yarn.lock b/yarn.lock index 87e5f77..e08b0a2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1503,10 +1503,10 @@ "@sentry/types" "5.29.2" tslib "^1.9.3" -"@setprotocol/set-protocol-v2@^0.4.0-hhat.1": - version "0.4.0-hhat.1" - resolved "https://registry.yarnpkg.com/@setprotocol/set-protocol-v2/-/set-protocol-v2-0.4.0-hhat.1.tgz#0d8a49551cc69e8addd4af2ee167729e9b2a0319" - integrity sha512-5hmTZO//ch+XbW/MYZf+be0VnZbORbbwwLajAuQYHYKEYbbERAsjGpGg4JAA2m79g/aA0amp84QtH46el7mpOQ== +"@setprotocol/set-protocol-v2@0.10.0-hhat.1": + version "0.10.0-hhat.1" + resolved "https://registry.yarnpkg.com/@setprotocol/set-protocol-v2/-/set-protocol-v2-0.10.0-hhat.1.tgz#933dc1ad9599ddf39796ce5785e0cd4ec0e7ba5d" + integrity sha512-wjTIHUzsAmL8u01XsOALJp62ceznVPK420zfpNK6Kv/mK56UKZWq3ptzHbnhjujU1vDfiNylH+enEHjUYiBogQ== dependencies: "@uniswap/v3-sdk" "^3.5.1" ethers "^5.5.2"