diff --git a/packages/perennial-vaults/contracts/interfaces/ISingleBalancedVault.sol b/packages/perennial-vaults/contracts/interfaces/ISingleBalancedVault.sol new file mode 100644 index 00000000..7c2cb697 --- /dev/null +++ b/packages/perennial-vaults/contracts/interfaces/ISingleBalancedVault.sol @@ -0,0 +1,73 @@ +//SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.13; + +import "@equilibria/perennial/contracts/interfaces/IController.sol"; +import "@equilibria/perennial/contracts/interfaces/ICollateral.sol"; +import "@equilibria/root/number/types/UFixed18.sol"; + +interface ISingleBalancedVault { + + /* BalancedVault Interface */ + + struct Version { + UFixed18 longPosition; + UFixed18 shortPosition; + UFixed18 totalShares; + UFixed18 longAssets; + UFixed18 shortAssets; + UFixed18 totalAssets; + } + + struct VersionContext { + uint256 version; + UFixed18 latestCollateral; + UFixed18 latestShares; + } + + event Deposit(address indexed sender, address indexed account, uint256 version, UFixed18 assets); + event Redemption(address indexed sender, address indexed account, uint256 version, UFixed18 shares); + event Claim(address indexed sender, address indexed account, UFixed18 assets); + event PositionUpdated(IProduct product, UFixed18 targetPosition); + event CollateralUpdated(IProduct product, UFixed18 targetCollateral); + + error BalancedVaultDepositLimitExceeded(); + error BalancedVaultRedemptionLimitExceeded(); + + function initialize(string memory name_, string memory symbol_) external; + function sync() external; + function controller() external view returns (IController); + function collateral() external view returns (ICollateral); + function long() external view returns (IProduct); + function short() external view returns (IProduct); + function targetLeverage() external view returns (UFixed18); + function maxCollateral() external view returns (UFixed18); + function unclaimed(address account) external view returns (UFixed18); + function totalUnclaimed() external view returns (UFixed18); + function claim(address account) external; + + /* Partial ERC4626 Interface */ + + function asset() external view returns (Token18); + function totalAssets() external view returns (UFixed18); + function convertToShares(UFixed18 assets) external view returns (UFixed18); + function convertToAssets(UFixed18 shares) external view returns (UFixed18); + function maxDeposit(address account) external view returns (UFixed18); + function deposit(UFixed18 assets, address account) external; + function maxRedeem(address account) external view returns (UFixed18); + function redeem(UFixed18 shares, address account) external; + + /* Partial ERC20 Interface */ + + event Transfer(address indexed from, address indexed to, UFixed18 value); + event Approval(address indexed account, address indexed spender, UFixed18 value); + + function name() external view returns (string memory); + function symbol() external view returns (string memory); + function decimals() external view returns (uint8); + function totalSupply() external view returns (UFixed18); + function balanceOf(address account) external view returns (UFixed18); + function transfer(address to, UFixed18 amount) external returns (bool); + function allowance(address account, address spender) external view returns (UFixed18); + function approve(address spender, UFixed18 amount) external returns (bool); + function transferFrom(address from, address to, UFixed18 amount) external returns (bool); +} \ No newline at end of file diff --git a/packages/perennial-vaults/contracts/single-balanced/SingleBalancedVault.sol b/packages/perennial-vaults/contracts/single-balanced/SingleBalancedVault.sol new file mode 100644 index 00000000..c4296d8c --- /dev/null +++ b/packages/perennial-vaults/contracts/single-balanced/SingleBalancedVault.sol @@ -0,0 +1,693 @@ +//SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.17; + +import "../interfaces/ISingleBalancedVault.sol"; +import "@equilibria/root/control/unstructured/UInitializable.sol"; +import "@openzeppelin/contracts/utils/math/Math.sol"; + +/** + * @title SingleBalancedVault + * @notice ERC4626 vault that manages a 50-50 position between long-short markets of the same payoff on Perennial. + * @dev Vault deploys and rebalances collateral between the corresponding long and short markets, while attempting to + * maintain `targetLeverage` with its open positions at any given time. Deposits are only gated in so much as to cap + * the maximum amount of assets in the vault. + * + * The vault has a "delayed mint" mechanism for shares on deposit. After depositing to the vault, a user must wait + * until the next settlement of the underlying products in order for shares to be reflected in the getters. + * The shares will be fully reflected in contract state when the next settlement occurs on the vault itself. + * Similarly, when redeeming shares, underlying assets are not claimable until a settlement occurs. + * Each state changing interaction triggers the `settle` flywheel in order to bring the vault to the + * desired state. + * In the event that there is not a settlement for a long period of time, keepers can call the `sync` method to + * force settlement and rebalancing. This is most useful to prevent vault liquidation due to PnL changes + * causing the vault to be in an unhealthy state (far away from target leverage) + */ +contract SingleBalancedVault is ISingleBalancedVault, UInitializable { + UFixed18 constant private TWO = UFixed18.wrap(2e18); + + /// @dev The address of the Perennial controller contract + IController public immutable controller; + + /// @dev The address of the Perennial collateral contract + ICollateral public immutable collateral; + + /// @dev The address of the Perennial product on the long side + IProduct public immutable long; + + /// @dev The address of the Perennial product on the short side + IProduct public immutable short; + + /// @dev The target leverage amount for the vault + UFixed18 public immutable targetLeverage; + + /// @dev The collateral cap for the vault + UFixed18 public immutable maxCollateral; + + /// @dev The underlying asset of the vault + Token18 public immutable asset; + + /// @dev The ERC20 name of the vault + string public name; + + /// @dev The ERC20 symbol of the vault + string public symbol; + + /// @dev Mapping of allowance across all users + mapping(address => mapping(address => UFixed18)) public allowance; + + /// @dev Mapping of shares of the vault per user + mapping(address => UFixed18) private _balanceOf; + + /// @dev Total number of shares across all users + UFixed18 private _totalSupply; + + /// @dev Mapping of unclaimed underlying of the vault per user + mapping(address => UFixed18) private _unclaimed; + + /// @dev Mapping of unclaimed underlying of the vault per user + UFixed18 private _totalUnclaimed; + + /// @dev Deposits that have not been settled, or have been settled but not yet processed by this contract + UFixed18 private _deposit; + + /// @dev Redemptions that have not been settled, or have been settled but not yet processed by this contract + UFixed18 private _redemption; + + /// @dev The latest version that a pending deposit or redemption has been placed + uint256 private _latestVersion; + + /// @dev Mapping of pending (not yet converted to shares) per user + mapping(address => UFixed18) private _deposits; + + /// @dev Mapping of pending (not yet withdrawn) per user + mapping(address => UFixed18) private _redemptions; + + /// @dev Mapping of the latest version that a pending deposit or redemption has been placed per user + mapping(address => uint256) private _latestVersions; + + /// @dev Mapping of versions of the vault state at a given oracle version + mapping(uint256 => Version) private _versions; + + constructor( + Token18 asset_, + IController controller_, + IProduct long_, + IProduct short_, + UFixed18 targetLeverage_, + UFixed18 maxCollateral_ + ) { + asset = asset_; + controller = controller_; + collateral = controller_.collateral(); + long = long_; + short = short_; + targetLeverage = targetLeverage_; + maxCollateral = maxCollateral_; + } + + /** + * @notice Initializes the contract state + * @param name_ ERC20 asset name + * @param symbol_ ERC20 asset symbol + */ + function initialize(string memory name_, string memory symbol_) external initializer(1) { + name = name_; + symbol = symbol_; + + asset.approve(address(collateral)); + } + + /** + * @notice Rebalances the collateral and position of the vault without a deposit or withdraw + * @dev Should be called by a keeper when the vault approaches a liquidation state on either side + */ + function sync() external { + (VersionContext memory context, ) = _settle(address(0)); + _rebalance(context, UFixed18Lib.ZERO); + } + + /** + * @notice Deposits `assets` assets into the vault, returning shares to `account` after the deposit settles. + * @param assets The amount of assets to deposit + * @param account The account to deposit on behalf of + */ + function deposit(UFixed18 assets, address account) external { + (VersionContext memory context, ) = _settle(account); + if (assets.gt(_maxDepositAtVersion(context))) revert BalancedVaultDepositLimitExceeded(); + + _deposit = _deposit.add(assets); + _latestVersion = context.version; + _deposits[account] = _deposits[account].add(assets); + _latestVersions[account] = context.version; + emit Deposit(msg.sender, account, context.version, assets); + + asset.pull(msg.sender, assets); + + _rebalance(context, UFixed18Lib.ZERO); + } + + /** + * @notice Redeems `shares` shares from the vault + * @dev Does not return any assets to the user due to delayed settlement. Use `claim` to claim assets + * If account is not msg.sender, requires prior spending approval + * @param shares The amount of shares to redeem + * @param account The account to redeem on behalf of + */ + function redeem(UFixed18 shares, address account) external { + if (msg.sender != account) _consumeAllowance(account, msg.sender, shares); + + (VersionContext memory context, VersionContext memory accountContext) = _settle(account); + if (shares.gt(_maxRedeemAtVersion(context, accountContext, account))) revert BalancedVaultRedemptionLimitExceeded(); + + _redemption = _redemption.add(shares); + _latestVersion = context.version; + _redemptions[account] = _redemptions[account].add(shares); + _latestVersions[account] = context.version; + emit Redemption(msg.sender, account, context.version, shares); + + _burn(account, shares); + + _rebalance(context, UFixed18Lib.ZERO); + } + + /** + * @notice Claims all claimable assets for account, sending assets to account + * @param account The account to claim for + */ + function claim(address account) external { + (VersionContext memory context, ) = _settle(account); + + UFixed18 unclaimedAmount = _unclaimed[account]; + UFixed18 unclaimedTotal = _totalUnclaimed; + _unclaimed[account] = UFixed18Lib.ZERO; + _totalUnclaimed = unclaimedTotal.sub(unclaimedAmount); + emit Claim(msg.sender, account, unclaimedAmount); + + // pro-rate if vault has less collateral than unclaimed + UFixed18 claimAmount = unclaimedAmount; + (UFixed18 longCollateral, UFixed18 shortCollateral, UFixed18 idleCollateral) = _collateral(); + UFixed18 totalCollateral = longCollateral.add(shortCollateral).add(idleCollateral); + if (totalCollateral.lt(unclaimedTotal)) claimAmount = claimAmount.muldiv(totalCollateral, unclaimedTotal); + + _rebalance(context, claimAmount); + + asset.push(account, claimAmount); + } + + /** + * @notice Sets `amount` as the allowance of `spender` over the caller's shares + * @param spender Address which can spend operate on shares + * @param amount Amount of shares that spender can operate on + * @return bool true if the approval was successful, otherwise reverts + */ + function approve(address spender, UFixed18 amount) external returns (bool) { + allowance[msg.sender][spender] = amount; + emit Approval(msg.sender, spender, amount); + return true; + } + + /** + * @notice Moves `amount` shares from the caller's account to `to` + * @param to Address to send shares to + * @param amount Amount of shares to send + * @return bool true if the transfer was successful, otherwise reverts + */ + function transfer(address to, UFixed18 amount) external returns (bool) { + _settle(msg.sender); + _transfer(msg.sender, to, amount); + return true; + } + + /** + * @notice Moves `amount` shares from `from to `to` + * @param from Address to send shares from + * @param to Address to send shares to + * @param amount Amount of shares to send + * @return bool true if the transfer was successful, otherwise reverts + */ + function transferFrom(address from, address to, UFixed18 amount) external returns (bool) { + _settle(from); + _consumeAllowance(from, msg.sender, amount); + _transfer(from, to, amount); + return true; + } + + /** + * @notice Returns the decimals places of the share token + * @return Decimal places of the share share token + */ + function decimals() external pure returns (uint8) { + return 18; + } + + /** + * @notice The maximum available deposit amount + * @dev Only exact when vault is synced, otherwise approximate + * @return Maximum available deposit amount + */ + function maxDeposit(address) external view returns (UFixed18) { + (VersionContext memory context, ) = _loadContextForRead(address(0)); + return _maxDepositAtVersion(context); + } + + /** + * @notice The maximum available redeemable amount + * @dev Only exact when vault is synced, otherwise approximate + * @param account The account to redeem for + * @return Maximum available redeemable amount + */ + function maxRedeem(address account) external view returns (UFixed18) { + (VersionContext memory context, VersionContext memory accountContext) = _loadContextForRead(account); + return _maxRedeemAtVersion(context, accountContext, account); + } + + /** + * @notice The total amount of assets currently held by the vault + * @return Amount of assets held by the vault + */ + function totalAssets() external view returns (UFixed18) { + (VersionContext memory context, ) = _loadContextForRead(address(0)); + return _totalAssetsAtVersion(context); + } + + /** + * @notice The total amount of shares currently issued + * @return Amount of shares currently issued + */ + function totalSupply() external view returns (UFixed18) { + (VersionContext memory context, ) = _loadContextForRead(address(0)); + return _totalSupplyAtVersion(context); + } + + /** + * @notice Number of shares held by `account` + * @param account Account to query balance of + * @return Number of shares held by `account` + */ + function balanceOf(address account) external view returns (UFixed18) { + (, VersionContext memory accountContext) = _loadContextForRead(account); + return _balanceOfAtVersion(accountContext, account); + } + + /** + * @notice Total unclaimed assets in vault + * @return Total unclaimed assets in vault + */ + function totalUnclaimed() external view returns (UFixed18) { + (VersionContext memory context, ) = _loadContextForRead(address(0)); + return _totalUnclaimedAtVersion(context); + } + + /** + * @notice `account`'s unclaimed assets + * @param account Account to query unclaimed balance of + * @return `account`'s unclaimed assets + */ + function unclaimed(address account) external view returns (UFixed18) { + (, VersionContext memory accountContext) = _loadContextForRead(account); + return _unclaimedAtVersion(accountContext, account); + } + + /** + * @notice Converts a given amount of assets to shares + * @param assets Number of assets to convert to shares + * @return Amount of shares for the given assets + */ + function convertToShares(UFixed18 assets) external view returns (UFixed18) { + (VersionContext memory context, ) = _loadContextForRead(address(0)); + (context.latestCollateral, context.latestShares) = + (_totalAssetsAtVersion(context), _totalSupplyAtVersion(context)); + return _convertToSharesAtVersion(context, assets); + } + + /** + * @notice Converts a given amount of shares to assets + * @param shares Number of shares to convert to assets + * @return Amount of assets for the given shares + */ + function convertToAssets(UFixed18 shares) external view returns (UFixed18) { + (VersionContext memory context, ) = _loadContextForRead(address(0)); + (context.latestCollateral, context.latestShares) = + (_totalAssetsAtVersion(context), _totalSupplyAtVersion(context)); + return _convertToAssetsAtVersion(context, shares); + } + + /** + * @notice Hook that is called before every stateful operation + * @dev Settles the vault's account on both the long and short product, along with any global or user-specific deposits/redemptions + * @param account The account that called the operation, or 0 if called by a keeper. + * @return context The current version context + */ + function _settle(address account) private returns (VersionContext memory context, VersionContext memory accountContext) { + (context, accountContext) = _loadContextForWrite(account); + + if (context.version > _latestVersion) { + _delayedMint(_totalSupplyAtVersion(context).sub(_totalSupply)); + _totalUnclaimed = _totalUnclaimedAtVersion(context); + _deposit = UFixed18Lib.ZERO; + _redemption = UFixed18Lib.ZERO; + _latestVersion = context.version; + + _versions[context.version] = Version({ + longPosition: long.position(address(this)).maker, + shortPosition: short.position(address(this)).maker, + totalShares: _totalSupply, + longAssets: collateral.collateral(address(this), long), + shortAssets: collateral.collateral(address(this), short), + totalAssets: _totalAssetsAtVersion(context) + }); + } + + if (account != address(0) && accountContext.version > _latestVersions[account]) { + _delayedMintAccount(account, _balanceOfAtVersion(accountContext, account).sub(_balanceOf[account])); + _unclaimed[account] = _unclaimedAtVersion(accountContext, account); + _deposits[account] = UFixed18Lib.ZERO; + _redemptions[account] = UFixed18Lib.ZERO; + _latestVersions[account] = accountContext.version; + } + } + + /** + * @notice Rebalances the collateral and position of the vault + * @dev Rebalance is executed on best-effort, any failing legs of the strategy will not cause a revert + * @param claimAmount The amount of assets that will be withdrawn from the vault at the end of the operation + */ + function _rebalance(VersionContext memory context, UFixed18 claimAmount) private { + _rebalanceCollateral(claimAmount); + _rebalancePosition(context, claimAmount); + } + + /** + * @notice Rebalances the collateral of the vault + * @param claimAmount The amount of assets that will be withdrawn from the vault at the end of the operation + */ + function _rebalanceCollateral(UFixed18 claimAmount) private { + (UFixed18 longCollateral, UFixed18 shortCollateral, UFixed18 idleCollateral) = _collateral(); + UFixed18 currentCollateral = longCollateral.add(shortCollateral).add(idleCollateral).sub(claimAmount); + UFixed18 targetCollateral = currentCollateral.div(TWO); + if (targetCollateral.lt(controller.minCollateral())) targetCollateral = UFixed18Lib.ZERO; + + (IProduct greaterProduct, IProduct lesserProduct) = + longCollateral.gt(shortCollateral) ? (long, short) : (short, long); + + _updateCollateral(greaterProduct, greaterProduct == long ? longCollateral : shortCollateral, targetCollateral); + _updateCollateral(lesserProduct, lesserProduct == long ? longCollateral : shortCollateral, targetCollateral); + } + + /** + * @notice Rebalances the position of the vault + */ + function _rebalancePosition(VersionContext memory context, UFixed18 claimAmount) private { + UFixed18 currentAssets = _totalAssetsAtVersion(context).sub(claimAmount); + UFixed18 currentUtilized = _totalSupply.add(_redemption).isZero() ? + _deposit.add(currentAssets) : + _deposit.add(currentAssets.muldiv(_totalSupply, _totalSupply.add(_redemption))); + if (currentUtilized.lt(controller.minCollateral().mul(TWO))) currentUtilized = UFixed18Lib.ZERO; + + UFixed18 currentPrice = long.atVersion(context.version).price.abs(); + UFixed18 targetPosition = currentUtilized.mul(targetLeverage).div(currentPrice).div(TWO); + + _updateMakerPosition(long, targetPosition); + _updateMakerPosition(short, targetPosition); + } + + /** + * @notice Adjusts the collateral on `product` to `targetCollateral` + * @param product The product to adjust the vault's collateral on + * @param currentCollateral The current collateral of the product + * @param targetCollateral The new collateral to target + */ + function _updateCollateral(IProduct product, UFixed18 currentCollateral, UFixed18 targetCollateral) private { + if (currentCollateral.gt(targetCollateral)) + collateral.withdrawTo(address(this), product, currentCollateral.sub(targetCollateral)); + if (currentCollateral.lt(targetCollateral)) + collateral.depositTo(address(this), product, targetCollateral.sub(currentCollateral)); + + emit CollateralUpdated(product, targetCollateral); + } + + /** + * @notice Adjusts the position on `product` to `targetPosition` + * @param product The product to adjust the vault's position on + * @param targetPosition The new position to target + */ + function _updateMakerPosition(IProduct product, UFixed18 targetPosition) private { + UFixed18 currentPosition = product.position(address(this)).next(product.pre(address(this))).maker; + UFixed18 currentMaker = product.positionAtVersion(product.latestVersion()).next(product.pre()).maker; + UFixed18 makerLimit = product.makerLimit(); + UFixed18 makerAvailable = makerLimit.gt(currentMaker) ? makerLimit.sub(currentMaker) : UFixed18Lib.ZERO; + + if (targetPosition.lt(currentPosition)) + product.closeMake(currentPosition.sub(targetPosition)); + if (targetPosition.gte(currentPosition)) + product.openMake(targetPosition.sub(currentPosition).min(makerAvailable)); + + emit PositionUpdated(product, targetPosition); + } + + /** + * @notice Moves `amount` shares from `from` to `to` + * @param from Address to send shares from + * @param to Address to send shares to + * @param amount Amount of shares to move + */ + function _transfer(address from, address to, UFixed18 amount) private { + _balanceOf[from] = _balanceOf[from].sub(amount); + _balanceOf[to] = _balanceOf[to].add(amount); + emit Transfer(from, to, amount); + } + + /** + * @notice Burns `amount` shares from `from`, adjusting totalSupply + * @param from Address to burn shares from + * @param amount Amount of shares to burn + */ + function _burn(address from, UFixed18 amount) private { + _balanceOf[from] = _balanceOf[from].sub(amount); + _totalSupply = _totalSupply.sub(amount); + emit Transfer(from, address(0), amount); + } + + /** + * @notice Mints `amount` shares, adjusting totalSupply + * @param amount Amount of shares to mint + */ + function _delayedMint(UFixed18 amount) private { + _totalSupply = _totalSupply.add(amount); + } + + /** + * @notice Mints `amount` shares to `to` + * @param to Address to mint shares to + * @param amount Amount of shares to mint + */ + function _delayedMintAccount(address to, UFixed18 amount) private { + _balanceOf[to] = _balanceOf[to].add(amount); + emit Transfer(address(0), to, amount); + } + + /** + * @notice Decrements `spender`s allowance for `account` by `amount` + * @dev Does not decrement if approval is for -1 + * @param account Address of allower + * @param spender Address of spender + * @param amount Amount to decrease allowance by + */ + function _consumeAllowance(address account, address spender, UFixed18 amount) private { + if (allowance[account][spender].eq(UFixed18Lib.MAX)) return; + allowance[account][spender] = allowance[account][spender].sub(amount); + } + + /** + * @notice Loads the context for the given `account`, settling the vault first + * @param account Account to load the context for + * @return global version context + * @return account version context + */ + function _loadContextForWrite(address account) private returns (VersionContext memory, VersionContext memory) { + long.settleAccount(address(this)); + short.settleAccount(address(this)); + uint256 currentVersion = long.latestVersion(address(this)); + + return ( + VersionContext(currentVersion, _assetsAt(_latestVersion), _sharesAt(_latestVersion)), + VersionContext(currentVersion, _assetsAt(_latestVersions[account]), _sharesAt(_latestVersions[account])) + ); + } + + /** + * @notice Loads the context for the given `account` + * @param account Account to load the context for + * @return global version context + * @return account version context + */ + function _loadContextForRead(address account) private view returns (VersionContext memory, VersionContext memory) { + uint256 currentVersion = Math.min(long.latestVersion(), short.latestVersion()); // latest version that both products are settled to + + return ( + VersionContext(currentVersion, _assetsAt(_latestVersion), _sharesAt(_latestVersion)), + VersionContext(currentVersion, _assetsAt(_latestVersions[account]), _sharesAt(_latestVersions[account])) + ); + } + + /** + * @notice Calculates whether or not the vault is in an unhealthy state at the provided version + * @param context Version context to calculate health + * @return bool true if unhealthy, false if healthy + */ + function _unhealthyAtVersion(VersionContext memory context) private view returns (bool) { + return collateral.liquidatable(address(this), long) + || collateral.liquidatable(address(this), short) + || long.isLiquidating(address(this)) + || short.isLiquidating(address(this)) + || (!context.latestShares.isZero() && context.latestCollateral.isZero()); + } + + /** + * @notice The maximum available deposit amount at the given version + * @param context Version context to use in calculation + * @return Maximum available deposit amount at version + */ + function _maxDepositAtVersion(VersionContext memory context) private view returns (UFixed18) { + if (_unhealthyAtVersion(context)) return UFixed18Lib.ZERO; + UFixed18 currentCollateral = _totalAssetsAtVersion(context).add(_deposit); + return maxCollateral.gt(currentCollateral) ? maxCollateral.sub(currentCollateral) : UFixed18Lib.ZERO; + } + + /** + * @notice The maximum available redeemable amount at the given version for `account` + * @param context Version context to use in calculation + * @param accountContext Account version context to use in calculation + * @param account Account to calculate redeemable amount + * @return Maximum available redeemable amount at version + */ + function _maxRedeemAtVersion( + VersionContext memory context, + VersionContext memory accountContext, + address account + ) private view returns (UFixed18) { + if (_unhealthyAtVersion(context)) return UFixed18Lib.ZERO; + return _balanceOfAtVersion(accountContext, account); + } + + /** + * @notice The total assets at the given version + * @param context Version context to use in calculation + * @return Total assets amount at version + */ + function _totalAssetsAtVersion(VersionContext memory context) private view returns (UFixed18) { + (UFixed18 longCollateral, UFixed18 shortCollateral, UFixed18 idleCollateral) = _collateral(); + (UFixed18 totalCollateral, UFixed18 totalDebt) = + (longCollateral.add(shortCollateral).add(idleCollateral), _totalUnclaimedAtVersion(context).add(_deposit)); + return totalCollateral.gt(totalDebt) ? totalCollateral.sub(totalDebt) : UFixed18Lib.ZERO; + } + + /** + * @notice The total supply at the given version + * @param context Version context to use in calculation + * @return Total supply amount at version + */ + function _totalSupplyAtVersion(VersionContext memory context) private view returns (UFixed18) { + if (context.version == _latestVersion) return _totalSupply; + return _totalSupply.add(_convertToSharesAtVersion(context, _deposit)); + } + + /** + * @notice The balance of `account` at the given version + * @param accountContext Account version context to use in calculation + * @param account Account to calculate balance of amount + * @return Account balance at version + */ + function _balanceOfAtVersion(VersionContext memory accountContext, address account) private view returns (UFixed18) { + if (accountContext.version == _latestVersions[account]) return _balanceOf[account]; + return _balanceOf[account].add(_convertToSharesAtVersion(accountContext, _deposits[account])); + } + + /** + * @notice The total unclaimed assets at the given version + * @param context Version context to use in calculation + * @return Total unclaimed asset amount at version + */ + function _totalUnclaimedAtVersion(VersionContext memory context) private view returns (UFixed18) { + if (context.version == _latestVersion) return _totalUnclaimed; + return _totalUnclaimed.add(_convertToAssetsAtVersion(context, _redemption)); + } + + /** + * @notice The total unclaimed assets at the given version for `account` + * @param accountContext Account version context to use in calculation + * @param account Account to calculate unclaimed assets for + * @return Total unclaimed asset amount for `account` at version + */ + function _unclaimedAtVersion(VersionContext memory accountContext, address account) private view returns (UFixed18) { + if (accountContext.version == _latestVersions[account]) return _unclaimed[account]; + return _unclaimed[account].add(_convertToAssetsAtVersion(accountContext, _redemptions[account])); + } + + /** + * @notice Returns the amounts of the individual sources of assets in the vault + * @return The amount of collateral in the long product + * @return The amount of collateral in the short product + * @return The amount of collateral idle in the vault contract + */ + function _collateral() private view returns (UFixed18, UFixed18, UFixed18) { + return ( + collateral.collateral(address(this), long), + collateral.collateral(address(this), short), + asset.balanceOf() + ); + } + + /** + * @notice The total assets at the given version + * @dev Calculates and adds accumulated PnL for `version` + 1 + * @param version Version to get total assets at + * @return Total assets in the vault at the given version + */ + function _assetsAt(uint256 version) private view returns (UFixed18) { + Fixed18 longAccumulated = long.valueAtVersion(version + 1).maker.sub(long.valueAtVersion(version).maker) + .mul(Fixed18Lib.from(_versions[version].longPosition)) + .max(Fixed18Lib.from(_versions[version].longAssets).mul(Fixed18Lib.NEG_ONE)); // collateral can't go negative on a product + Fixed18 shortAccumulated = short.valueAtVersion(version + 1).maker.sub(short.valueAtVersion(version).maker) + .mul(Fixed18Lib.from(_versions[version].shortPosition)) + .max(Fixed18Lib.from(_versions[version].shortAssets).mul(Fixed18Lib.NEG_ONE)); // collateral can't go negative on a product + + return UFixed18Lib.from( + Fixed18Lib.from(_versions[version].totalAssets) + .add(longAccumulated) + .add(shortAccumulated) + .max(Fixed18Lib.ZERO) // vault can't have negative assets, socializes into unclaimed if triggered + ); + } + + /** + * @notice The total shares at the given version + * @param version Version to get total shares at + * @return Total shares at `version` + */ + function _sharesAt(uint256 version) private view returns (UFixed18) { + return _versions[version].totalShares; + } + + /** + * @notice Converts a given amount of assets to shares at version + * @param context Version context to use in calculation + * @param assets Number of assets to convert to shares + * @return Amount of shares for the given assets at version + */ + function _convertToSharesAtVersion(VersionContext memory context, UFixed18 assets) private pure returns (UFixed18) { + if (context.latestCollateral.isZero()) return assets; + return assets.muldiv(context.latestShares, context.latestCollateral); + } + + /** + * @notice Converts a given amount of shares to assets at version + * @param context Version context to use in calculation + * @param shares Number of shares to convert to shares + * @return Amount of assets for the given shares at version + */ + function _convertToAssetsAtVersion(VersionContext memory context, UFixed18 shares) private pure returns (UFixed18) { + if (context.latestShares.isZero()) return shares; + return shares.muldiv(context.latestCollateral, context.latestShares); + } +} \ No newline at end of file diff --git a/packages/perennial-vaults/test/integration/SingleBalancedVault/singleBalancedVault.test.ts b/packages/perennial-vaults/test/integration/SingleBalancedVault/singleBalancedVault.test.ts new file mode 100644 index 00000000..d424ae8f --- /dev/null +++ b/packages/perennial-vaults/test/integration/SingleBalancedVault/singleBalancedVault.test.ts @@ -0,0 +1,1126 @@ +import HRE from 'hardhat' +import { time, impersonate } from '../../../../common/testutil' +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' +import { FakeContract, smock } from '@defi-wonderland/smock' +import { expect, use } from 'chai' +import { + IERC20Metadata, + IERC20Metadata__factory, + IController__factory, + IProduct, + IProduct__factory, + SingleBalancedVault, + SingleBalancedVault__factory, + IOracleProvider__factory, + IOracleProvider, + ICollateral, + ICollateral__factory, +} from '../../../types/generated' +import { BigNumber, constants, utils } from 'ethers' + +const { config, ethers } = HRE +use(smock.matchers) + +const DSU_HOLDER = '0xaef566ca7e84d1e736f999765a804687f39d9094' + +describe('SingleBalancedVault', () => { + let vault: SingleBalancedVault + let asset: IERC20Metadata + let oracle: FakeContract + let collateral: ICollateral + let owner: SignerWithAddress + let user: SignerWithAddress + let user2: SignerWithAddress + let perennialUser: SignerWithAddress + let liquidator: SignerWithAddress + let long: IProduct + let short: IProduct + let leverage: BigNumber + let maxCollateral: BigNumber + let originalOraclePrice: BigNumber + + async function updateOracle(newPrice?: BigNumber) { + const [currentVersion, currentTimestamp, currentPrice] = await oracle.currentVersion() + const newVersion = { + version: currentVersion.add(1), + timestamp: currentTimestamp.add(13), + price: newPrice ?? currentPrice, + } + oracle.sync.returns(newVersion) + oracle.currentVersion.returns(newVersion) + oracle.atVersion.whenCalledWith(newVersion.version).returns(newVersion) + } + + async function longPosition() { + return (await long.position(vault.address)).maker + } + + async function shortPosition() { + return (await short.position(vault.address)).maker + } + + async function longCollateralInVault() { + return await collateral['collateral(address,address)'](vault.address, long.address) + } + + async function shortCollateralInVault() { + return await collateral['collateral(address,address)'](vault.address, short.address) + } + + async function totalCollateralInVault() { + return (await longCollateralInVault()).add(await shortCollateralInVault()).add(await asset.balanceOf(vault.address)) + } + + beforeEach(async () => { + await time.reset(config) + ;[owner, user, user2, liquidator, perennialUser] = await ethers.getSigners() + + const dsu = IERC20Metadata__factory.connect('0x605D26FBd5be761089281d5cec2Ce86eeA667109', owner) + const controller = IController__factory.connect('0x9df509186b6d3b7D033359f94c8b1BB5544d51b3', owner) + long = IProduct__factory.connect('0xdB60626FF6cDC9dB07d3625A93d21dDf0f8A688C', owner) + short = IProduct__factory.connect('0xfeD3E166330341e0305594B8c6e6598F9f4Cbe9B', owner) + collateral = ICollateral__factory.connect('0x2d264ebdb6632a06a1726193d4d37fef1e5dbdcd', owner) + leverage = utils.parseEther('4.0') + maxCollateral = utils.parseEther('500000') + + vault = await new SingleBalancedVault__factory(owner).deploy( + dsu.address, + controller.address, + long.address, + short.address, + leverage, + maxCollateral, + ) + await vault.initialize('Perennial Vault Alpha', 'PVA') + asset = IERC20Metadata__factory.connect(await vault.asset(), owner) + + const dsuHolder = await impersonate.impersonateWithBalance(DSU_HOLDER, utils.parseEther('10')) + const setUpWalletWithDSU = async (wallet: SignerWithAddress) => { + await dsu.connect(dsuHolder).transfer(wallet.address, utils.parseEther('200000')) + await dsu.connect(wallet).approve(vault.address, ethers.constants.MaxUint256) + } + await setUpWalletWithDSU(user) + await setUpWalletWithDSU(user2) + await setUpWalletWithDSU(liquidator) + await setUpWalletWithDSU(perennialUser) + await setUpWalletWithDSU(perennialUser) + + // Unfortunately, we can't make mocks of existing contracts. + // So, we make a fake and initialize it with the values that the real contract had at this block. + const realOracle = IOracleProvider__factory.connect('0xA59eF0208418559770a48D7ae4f260A28763167B', owner) + const currentVersion = await realOracle.currentVersion() + originalOraclePrice = currentVersion[2] + + oracle = await smock.fake('IOracleProvider', { + address: '0x2C19eac953048801FfE1358D109A1Ac2aF7930fD', + }) + oracle.sync.returns(currentVersion) + oracle.currentVersion.returns(currentVersion) + oracle.atVersion.whenCalledWith(currentVersion[0]).returns(currentVersion) + }) + + describe('#initialize', () => { + it('cant re-initialize', async () => { + await expect(vault.initialize('Perennial Vault Alpha', 'PVA')).to.revertedWithCustomError( + vault, + 'UInitializableAlreadyInitializedError', + ) + }) + }) + + describe('#name', () => { + it('is correct', async () => { + expect(await vault.name()).to.equal('Perennial Vault Alpha') + }) + }) + + describe('#symbol', () => { + it('is correct', async () => { + expect(await vault.symbol()).to.equal('PVA') + }) + }) + + describe('#decimals', () => { + it('is correct', async () => { + expect(await vault.decimals()).to.equal(18) + }) + }) + + describe('#approve', () => { + it('approves correctly', async () => { + expect(await vault.allowance(user.address, liquidator.address)).to.eq(0) + + await expect(vault.connect(user).approve(liquidator.address, utils.parseEther('10'))) + .to.emit(vault, 'Approval') + .withArgs(user.address, liquidator.address, utils.parseEther('10')) + + expect(await vault.allowance(user.address, liquidator.address)).to.eq(utils.parseEther('10')) + + await expect(vault.connect(user).approve(liquidator.address, 0)) + .to.emit(vault, 'Approval') + .withArgs(user.address, liquidator.address, 0) + + expect(await vault.allowance(user.address, liquidator.address)).to.eq(0) + }) + }) + + describe('#transfer', () => { + const EXPECTED_BALANCE_OF = utils.parseEther('10000') + beforeEach(async () => { + await vault.connect(user).deposit(EXPECTED_BALANCE_OF, user.address) + await updateOracle() + await vault.sync() + }) + + it('transfers correctly', async () => { + expect(await vault.balanceOf(user.address)).to.equal(EXPECTED_BALANCE_OF) + expect(await vault.balanceOf(user2.address)).to.equal(0) + expect(await vault.totalSupply()).to.equal(EXPECTED_BALANCE_OF) + + await expect(vault.connect(user).transfer(user2.address, EXPECTED_BALANCE_OF.div(2))) + .to.emit(vault, 'Transfer') + .withArgs(user.address, user2.address, EXPECTED_BALANCE_OF.div(2)) + + expect(await vault.balanceOf(user.address)).to.equal(EXPECTED_BALANCE_OF.div(2)) + expect(await vault.balanceOf(user2.address)).to.equal(EXPECTED_BALANCE_OF.div(2)) + expect(await vault.totalSupply()).to.equal(EXPECTED_BALANCE_OF) + + await expect(vault.connect(user).transfer(user2.address, EXPECTED_BALANCE_OF.div(2))) + .to.emit(vault, 'Transfer') + .withArgs(user.address, user2.address, EXPECTED_BALANCE_OF.div(2)) + + expect(await vault.balanceOf(user.address)).to.equal(0) + expect(await vault.balanceOf(user2.address)).to.equal(EXPECTED_BALANCE_OF) + expect(await vault.totalSupply()).to.equal(EXPECTED_BALANCE_OF) + }) + }) + + describe('#transferFrom', () => { + const EXPECTED_BALANCE_OF = utils.parseEther('10000') + beforeEach(async () => { + await vault.connect(user).deposit(EXPECTED_BALANCE_OF, user.address) + await updateOracle() + await vault.sync() + }) + + it('transfers from approved correctly', async () => { + await vault.connect(user).approve(liquidator.address, EXPECTED_BALANCE_OF) + + expect(await vault.balanceOf(user.address)).to.equal(EXPECTED_BALANCE_OF) + expect(await vault.balanceOf(user2.address)).to.equal(0) + expect(await vault.totalSupply()).to.equal(EXPECTED_BALANCE_OF) + expect(await vault.allowance(user.address, liquidator.address)).to.equal(EXPECTED_BALANCE_OF) + + await expect(vault.connect(liquidator).transferFrom(user.address, user2.address, EXPECTED_BALANCE_OF.div(2))) + .to.emit(vault, 'Transfer') + .withArgs(user.address, user2.address, EXPECTED_BALANCE_OF.div(2)) + + expect(await vault.balanceOf(user.address)).to.equal(EXPECTED_BALANCE_OF.div(2)) + expect(await vault.balanceOf(user2.address)).to.equal(EXPECTED_BALANCE_OF.div(2)) + expect(await vault.totalSupply()).to.equal(EXPECTED_BALANCE_OF) + expect(await vault.allowance(user.address, liquidator.address)).to.equal(EXPECTED_BALANCE_OF.div(2)) + + await expect(vault.connect(liquidator).transferFrom(user.address, user2.address, EXPECTED_BALANCE_OF.div(2))) + .to.emit(vault, 'Transfer') + .withArgs(user.address, user2.address, EXPECTED_BALANCE_OF.div(2)) + + expect(await vault.balanceOf(user.address)).to.equal(0) + expect(await vault.balanceOf(user2.address)).to.equal(EXPECTED_BALANCE_OF) + expect(await vault.totalSupply()).to.equal(EXPECTED_BALANCE_OF) + expect(await vault.allowance(user.address, liquidator.address)).to.equal(0) + }) + + it('transfers from approved correctly (infinite)', async () => { + await vault.connect(user).approve(liquidator.address, constants.MaxUint256) + + expect(await vault.balanceOf(user.address)).to.equal(EXPECTED_BALANCE_OF) + expect(await vault.balanceOf(user2.address)).to.equal(0) + expect(await vault.totalSupply()).to.equal(EXPECTED_BALANCE_OF) + expect(await vault.allowance(user.address, liquidator.address)).to.equal(constants.MaxUint256) + + await expect(vault.connect(liquidator).transferFrom(user.address, user2.address, EXPECTED_BALANCE_OF.div(2))) + .to.emit(vault, 'Transfer') + .withArgs(user.address, user2.address, EXPECTED_BALANCE_OF.div(2)) + + expect(await vault.balanceOf(user.address)).to.equal(EXPECTED_BALANCE_OF.div(2)) + expect(await vault.balanceOf(user2.address)).to.equal(EXPECTED_BALANCE_OF.div(2)) + expect(await vault.totalSupply()).to.equal(EXPECTED_BALANCE_OF) + expect(await vault.allowance(user.address, liquidator.address)).to.equal(constants.MaxUint256) + + await expect(vault.connect(liquidator).transferFrom(user.address, user2.address, EXPECTED_BALANCE_OF.div(2))) + .to.emit(vault, 'Transfer') + .withArgs(user.address, user2.address, EXPECTED_BALANCE_OF.div(2)) + + expect(await vault.balanceOf(user.address)).to.equal(0) + expect(await vault.balanceOf(user2.address)).to.equal(EXPECTED_BALANCE_OF) + expect(await vault.totalSupply()).to.equal(EXPECTED_BALANCE_OF) + expect(await vault.allowance(user.address, liquidator.address)).to.equal(constants.MaxUint256) + }) + + it('reverts when spender is unapproved', async () => { + await expect( + vault.connect(liquidator).transferFrom(user.address, user2.address, EXPECTED_BALANCE_OF.div(2)), + ).to.revertedWithPanic('0x11') + }) + + it('reverts when spender is unapproved (self)', async () => { + await expect( + vault.connect(user).transferFrom(user.address, user2.address, EXPECTED_BALANCE_OF.div(2)), + ).to.revertedWithPanic('0x11') + }) + }) + + describe('#deposit/#redeem/#claim/#sync', () => { + it('simple deposits and withdraws', async () => { + expect(await vault.convertToAssets(utils.parseEther('1'))).to.equal(utils.parseEther('1')) + expect(await vault.convertToShares(utils.parseEther('1'))).to.equal(utils.parseEther('1')) + + const smallDeposit = utils.parseEther('10') + await vault.connect(user).deposit(smallDeposit, user.address) + expect(await longCollateralInVault()).to.equal(0) + expect(await shortCollateralInVault()).to.equal(0) + expect(await vault.totalSupply()).to.equal(0) + expect(await vault.totalAssets()).to.equal(0) + await updateOracle() + await vault.sync() + + // We're underneath the collateral minimum, so we shouldn't have opened any positions. + expect(await longPosition()).to.equal(0) + expect(await shortPosition()).to.equal(0) + + const largeDeposit = utils.parseEther('10000') + await vault.connect(user).deposit(largeDeposit, user.address) + expect(await longCollateralInVault()).to.equal(utils.parseEther('5005')) + expect(await shortCollateralInVault()).to.equal(utils.parseEther('5005')) + expect(await vault.balanceOf(user.address)).to.equal(smallDeposit) + expect(await vault.totalSupply()).to.equal(smallDeposit) + expect(await vault.totalAssets()).to.equal(smallDeposit) + expect(await vault.convertToAssets(utils.parseEther('10'))).to.equal(utils.parseEther('10')) + expect(await vault.convertToShares(utils.parseEther('10'))).to.equal(utils.parseEther('10')) + await updateOracle() + + await vault.sync() + expect(await vault.balanceOf(user.address)).to.equal(utils.parseEther('10010')) + expect(await vault.totalSupply()).to.equal(utils.parseEther('10010')) + expect(await vault.totalAssets()).to.equal(utils.parseEther('10010')) + expect(await vault.convertToAssets(utils.parseEther('10010'))).to.equal(utils.parseEther('10010')) + expect(await vault.convertToShares(utils.parseEther('10010'))).to.equal(utils.parseEther('10010')) + + // Now we should have opened positions. + // The positions should be equal to (smallDeposit + largeDeposit) * leverage / 2 / originalOraclePrice. + expect(await longPosition()).to.equal( + smallDeposit.add(largeDeposit).mul(leverage).div(2).div(originalOraclePrice), + ) + expect(await shortPosition()).to.equal( + smallDeposit.add(largeDeposit).mul(leverage).div(2).div(originalOraclePrice), + ) + + // User 2 should not be able to withdraw; they haven't deposited anything. + await expect(vault.connect(user2).redeem(1, user2.address)).to.be.revertedWithCustomError( + vault, + 'BalancedVaultRedemptionLimitExceeded', + ) + + expect(await vault.maxRedeem(user.address)).to.equal(utils.parseEther('10010')) + await vault.connect(user).redeem(await vault.maxRedeem(user.address), user.address) + await updateOracle() + await vault.sync() + + // We should have closed all positions. + expect(await longPosition()).to.equal(0) + expect(await shortPosition()).to.equal(0) + + // We should have withdrawn all of our collateral. + const fundingAmount = BigNumber.from('1526207855124') + expect(await totalCollateralInVault()).to.equal(utils.parseEther('10010').add(fundingAmount)) + expect(await vault.balanceOf(user.address)).to.equal(0) + expect(await vault.totalSupply()).to.equal(0) + expect(await vault.totalAssets()).to.equal(0) + expect(await vault.convertToAssets(utils.parseEther('1'))).to.equal(utils.parseEther('1')) + expect(await vault.convertToShares(utils.parseEther('1'))).to.equal(utils.parseEther('1')) + expect(await vault.unclaimed(user.address)).to.equal(utils.parseEther('10010').add(fundingAmount)) + expect(await vault.totalUnclaimed()).to.equal(utils.parseEther('10010').add(fundingAmount)) + + await vault.connect(user).claim(user.address) + expect(await totalCollateralInVault()).to.equal(0) + expect(await asset.balanceOf(user.address)).to.equal(utils.parseEther('200000').add(fundingAmount)) + expect(await vault.unclaimed(user.address)).to.equal(0) + expect(await vault.totalUnclaimed()).to.equal(0) + }) + + it('multiple users', async () => { + expect(await vault.convertToAssets(utils.parseEther('1'))).to.equal(utils.parseEther('1')) + expect(await vault.convertToShares(utils.parseEther('1'))).to.equal(utils.parseEther('1')) + + const smallDeposit = utils.parseEther('1000') + await vault.connect(user).deposit(smallDeposit, user.address) + await updateOracle() + await vault.sync() + + const largeDeposit = utils.parseEther('10000') + await vault.connect(user2).deposit(largeDeposit, user2.address) + await updateOracle() + await vault.sync() + + // Now we should have opened positions. + // The positions should be equal to (smallDeposit + largeDeposit) * leverage / 2 / originalOraclePrice. + expect(await longPosition()).to.be.equal( + smallDeposit.add(largeDeposit).mul(leverage).div(2).div(originalOraclePrice), + ) + expect(await shortPosition()).to.equal( + smallDeposit.add(largeDeposit).mul(leverage).div(2).div(originalOraclePrice), + ) + const fundingAmount0 = BigNumber.from(156261444735) + const balanceOf2 = BigNumber.from('9999999998437385552894') + expect(await vault.balanceOf(user.address)).to.equal(utils.parseEther('1000')) + expect(await vault.balanceOf(user2.address)).to.equal(balanceOf2) + expect(await vault.totalAssets()).to.equal(utils.parseEther('11000').add(fundingAmount0)) + expect(await vault.totalSupply()).to.equal(utils.parseEther('1000').add(balanceOf2)) + expect(await vault.convertToAssets(utils.parseEther('1000').add(balanceOf2))).to.equal( + utils.parseEther('11000').add(fundingAmount0), + ) + expect(await vault.convertToShares(utils.parseEther('11000').add(fundingAmount0))).to.equal( + utils.parseEther('1000').add(balanceOf2), + ) + + const maxRedeem = await vault.maxRedeem(user.address) + await vault.connect(user).redeem(maxRedeem, user.address) + await updateOracle() + await vault.sync() + + const maxRedeem2 = await vault.maxRedeem(user2.address) + await vault.connect(user2).redeem(maxRedeem2, user2.address) + await updateOracle() + await vault.sync() + + // We should have closed all positions. + expect(await longPosition()).to.equal(0) + expect(await shortPosition()).to.equal(0) + + // We should have withdrawn all of our collateral. + const fundingAmount = BigNumber.from('308321913166') + const fundingAmount2 = BigNumber.from('3045329143208') + expect(await totalCollateralInVault()).to.equal(utils.parseEther('11000').add(fundingAmount).add(fundingAmount2)) + expect(await vault.balanceOf(user.address)).to.equal(0) + expect(await vault.balanceOf(user2.address)).to.equal(0) + expect(await vault.totalAssets()).to.equal(0) + expect(await vault.totalSupply()).to.equal(0) + expect(await vault.convertToAssets(utils.parseEther('1'))).to.equal(utils.parseEther('1')) + expect(await vault.convertToShares(utils.parseEther('1'))).to.equal(utils.parseEther('1')) + expect(await vault.unclaimed(user.address)).to.equal(utils.parseEther('1000').add(fundingAmount)) + expect(await vault.unclaimed(user2.address)).to.equal(utils.parseEther('10000').add(fundingAmount2)) + expect(await vault.totalUnclaimed()).to.equal(utils.parseEther('11000').add(fundingAmount).add(fundingAmount2)) + + await vault.connect(user).claim(user.address) + await vault.connect(user2).claim(user2.address) + + expect(await totalCollateralInVault()).to.equal(0) + expect(await vault.totalAssets()).to.equal(0) + expect(await asset.balanceOf(user.address)).to.equal(utils.parseEther('200000').add(fundingAmount)) + expect(await asset.balanceOf(user2.address)).to.equal(utils.parseEther('200000').add(fundingAmount2)) + expect(await vault.unclaimed(user2.address)).to.equal(0) + expect(await vault.totalUnclaimed()).to.equal(0) + }) + + it('deposit during withdraw', async () => { + expect(await vault.convertToAssets(utils.parseEther('1'))).to.equal(utils.parseEther('1')) + expect(await vault.convertToShares(utils.parseEther('1'))).to.equal(utils.parseEther('1')) + + const smallDeposit = utils.parseEther('1000') + await vault.connect(user).deposit(smallDeposit, user.address) + await updateOracle() + await vault.sync() + + const largeDeposit = utils.parseEther('2000') + await vault.connect(user2).deposit(largeDeposit, user2.address) + await vault.connect(user).redeem(utils.parseEther('400'), user.address) + await updateOracle() + await vault.sync() + + // Now we should have opened positions. + // The positions should be equal to (smallDeposit + largeDeposit) * leverage / 2 / originalOraclePrice. + expect(await longPosition()).to.be.equal( + smallDeposit.add(largeDeposit).sub(utils.parseEther('400')).mul(leverage).div(2).div(originalOraclePrice), + ) + expect(await shortPosition()).to.equal( + smallDeposit.add(largeDeposit).sub(utils.parseEther('400')).mul(leverage).div(2).div(originalOraclePrice), + ) + const fundingAmount0 = BigNumber.from('93756866841') + const balanceOf2 = BigNumber.from('1999999999687477110578') + expect(await vault.balanceOf(user.address)).to.equal(utils.parseEther('600')) + expect(await vault.balanceOf(user2.address)).to.equal(balanceOf2) + expect(await vault.totalAssets()).to.equal(utils.parseEther('2600').add(fundingAmount0)) + expect(await vault.totalSupply()).to.equal(utils.parseEther('600').add(balanceOf2)) + expect(await vault.convertToAssets(utils.parseEther('600').add(balanceOf2))).to.equal( + utils.parseEther('2600').add(fundingAmount0), + ) + expect(await vault.convertToShares(utils.parseEther('2600').add(fundingAmount0))).to.equal( + utils.parseEther('600').add(balanceOf2), + ) + + const maxRedeem = await vault.maxRedeem(user.address) + await vault.connect(user).redeem(maxRedeem, user.address) + await updateOracle() + await vault.sync() + + const maxRedeem2 = await vault.maxRedeem(user2.address) + await vault.connect(user2).redeem(maxRedeem2, user2.address) + await updateOracle() + await vault.sync() + + // We should have closed all positions. + expect(await longPosition()).to.equal(0) + expect(await shortPosition()).to.equal(0) + + // We should have withdrawn all of our collateral. + const fundingAmount = BigNumber.from('249607634342') + const fundingAmount2 = BigNumber.from('622820158534') + expect(await totalCollateralInVault()).to.equal(utils.parseEther('3000').add(fundingAmount).add(fundingAmount2)) + expect(await vault.balanceOf(user.address)).to.equal(0) + expect(await vault.balanceOf(user2.address)).to.equal(0) + expect(await vault.totalAssets()).to.equal(0) + expect(await vault.totalSupply()).to.equal(0) + expect(await vault.convertToAssets(utils.parseEther('1'))).to.equal(utils.parseEther('1')) + expect(await vault.convertToShares(utils.parseEther('1'))).to.equal(utils.parseEther('1')) + expect(await vault.unclaimed(user.address)).to.equal(utils.parseEther('1000').add(fundingAmount)) + expect(await vault.unclaimed(user2.address)).to.equal(utils.parseEther('2000').add(fundingAmount2)) + expect(await vault.totalUnclaimed()).to.equal(utils.parseEther('3000').add(fundingAmount).add(fundingAmount2)) + + await vault.connect(user).claim(user.address) + await vault.connect(user2).claim(user2.address) + + expect(await totalCollateralInVault()).to.equal(0) + expect(await vault.totalAssets()).to.equal(0) + expect(await asset.balanceOf(user.address)).to.equal(utils.parseEther('200000').add(fundingAmount)) + expect(await asset.balanceOf(user2.address)).to.equal(utils.parseEther('200000').add(fundingAmount2)) + expect(await vault.unclaimed(user2.address)).to.equal(0) + expect(await vault.totalUnclaimed()).to.equal(0) + }) + + it('transferring shares', async () => { + const smallDeposit = utils.parseEther('10') + await vault.connect(user).deposit(smallDeposit, user.address) + expect(await longCollateralInVault()).to.equal(0) + expect(await shortCollateralInVault()).to.equal(0) + await updateOracle() + await vault.sync() + + // We're underneath the collateral minimum, so we shouldn't have opened any positions. + expect(await longPosition()).to.equal(0) + expect(await shortPosition()).to.equal(0) + + const largeDeposit = utils.parseEther('10000') + await vault.connect(user).deposit(largeDeposit, user.address) + expect(await longCollateralInVault()).to.equal(utils.parseEther('5005')) + expect(await shortCollateralInVault()).to.equal(utils.parseEther('5005')) + await updateOracle() + + await vault.sync() + expect(await vault.balanceOf(user.address)).to.equal(utils.parseEther('10010')) + expect(await vault.totalSupply()).to.equal(utils.parseEther('10010')) + + // Now we should have opened positions. + // The positions should be equal to (smallDeposit + largeDeposit) * leverage / 2 / originalOraclePrice. + expect(await longPosition()).to.equal( + smallDeposit.add(largeDeposit).mul(leverage).div(2).div(originalOraclePrice), + ) + expect(await shortPosition()).to.equal( + smallDeposit.add(largeDeposit).mul(leverage).div(2).div(originalOraclePrice), + ) + + // User 2 should not be able to withdraw; they haven't deposited anything. + await expect(vault.connect(user2).redeem(1, user2.address)).to.be.revertedWithCustomError( + vault, + 'BalancedVaultRedemptionLimitExceeded', + ) + + // Transfer all of user's shares to user2 + await vault.connect(user).transfer(user2.address, utils.parseEther('10010')) + expect(await vault.balanceOf(user.address)).to.equal(0) + expect(await vault.balanceOf(user2.address)).to.equal(utils.parseEther('10010')) + expect(await vault.totalSupply()).to.equal(utils.parseEther('10010')) + // Now User should not be able to withdraw as they have no more shares + await expect(vault.connect(user).redeem(1, user.address)).to.be.revertedWithCustomError( + vault, + 'BalancedVaultRedemptionLimitExceeded', + ) + + expect(await vault.maxRedeem(user2.address)).to.equal(utils.parseEther('10010')) + await vault.connect(user2).redeem(await vault.maxRedeem(user2.address), user2.address) + await updateOracle() + await vault.sync() + + // We should have closed all positions. + expect(await longPosition()).to.equal(0) + expect(await shortPosition()).to.equal(0) + + // We should have withdrawn all of our collateral. + const fundingAmount = BigNumber.from('1526207855124') + const totalClaimable = utils.parseEther('10010').add(fundingAmount) + expect(await totalCollateralInVault()).to.equal(totalClaimable) + expect(await vault.totalAssets()).to.equal(0) + expect(await vault.unclaimed(user2.address)).to.equal(totalClaimable) + expect(await vault.totalUnclaimed()).to.equal(totalClaimable) + + await vault.connect(user2).claim(user2.address) + expect(await totalCollateralInVault()).to.equal(0) + expect(await vault.totalAssets()).to.equal(0) + expect(await asset.balanceOf(user2.address)).to.equal(utils.parseEther('200000').add(totalClaimable)) + }) + + it('partial transfers using transferFrom', async () => { + const smallDeposit = utils.parseEther('10') + await vault.connect(user).deposit(smallDeposit, user.address) + await updateOracle() + await vault.sync() + + const largeDeposit = utils.parseEther('10000') + await vault.connect(user).deposit(largeDeposit, user.address) + await updateOracle() + await vault.sync() + + // Now we should have opened positions. + // The positions should be equal to (smallDeposit + largeDeposit) * leverage / 2 / originalOraclePrice. + expect(await longPosition()).to.be.equal( + smallDeposit.add(largeDeposit).mul(leverage).div(2).div(originalOraclePrice), + ) + expect(await shortPosition()).to.equal( + smallDeposit.add(largeDeposit).mul(leverage).div(2).div(originalOraclePrice), + ) + + // Setup approval + const shareBalance = await vault.balanceOf(user.address) + await vault.connect(user).approve(owner.address, shareBalance.div(2)) + await vault.connect(owner).transferFrom(user.address, user2.address, shareBalance.div(2)) + expect(await vault.balanceOf(user.address)).to.equal(shareBalance.sub(shareBalance.div(2))) + expect(await vault.balanceOf(user2.address)).to.equal(shareBalance.div(2)) + expect(await vault.totalSupply()).to.equal(shareBalance) + + const maxRedeem = await vault.maxRedeem(user.address) + await vault.connect(user).redeem(maxRedeem, user.address) + const maxRedeem2 = await vault.maxRedeem(user2.address) + await vault.connect(user2).redeem(maxRedeem2, user2.address) + await updateOracle() + await vault.sync() + + // We should have closed all positions. + expect(await longPosition()).to.equal(0) + expect(await shortPosition()).to.equal(0) + + // We should have withdrawn all of our collateral. + await vault.connect(user).claim(user.address) + await vault.connect(user2).claim(user2.address) + + const fundingAmount = BigNumber.from('1526207855124') + const totalAssetsIn = utils.parseEther('10010') + expect(await totalCollateralInVault()).to.equal(0) + expect(await vault.totalAssets()).to.equal(0) + expect(await asset.balanceOf(user.address)).to.equal( + utils.parseEther('200000').sub(totalAssetsIn.div(2)).add(fundingAmount.div(2)), + ) + expect(await asset.balanceOf(user2.address)).to.equal( + utils.parseEther('200000').add(totalAssetsIn.div(2)).add(fundingAmount.div(2)), + ) + }) + + it('maxWithdraw', async () => { + const smallDeposit = utils.parseEther('500') + await vault.connect(user).deposit(smallDeposit, user.address) + await updateOracle() + await vault.sync() + + const shareAmount = BigNumber.from(utils.parseEther('500')) + expect(await vault.maxRedeem(user.address)).to.equal(shareAmount) + + const largeDeposit = utils.parseEther('10000') + await vault.connect(user).deposit(largeDeposit, user.address) + await updateOracle() + await vault.sync() + + const shareAmount2 = BigNumber.from('9999999998435236774264') + expect(await vault.maxRedeem(user.address)).to.equal(shareAmount.add(shareAmount2)) + + // We shouldn't be able to withdraw more than maxWithdraw. + await expect( + vault.connect(user).redeem((await vault.maxRedeem(user.address)).add(1), user.address), + ).to.be.revertedWithCustomError(vault, 'BalancedVaultRedemptionLimitExceeded') + + // But we should be able to withdraw exactly maxWithdraw. + await vault.connect(user).redeem(await vault.maxRedeem(user.address), user.address) + + // The oracle price hasn't changed yet, so we shouldn't be able to withdraw any more. + expect(await vault.maxRedeem(user.address)).to.equal(0) + + // But if we update the oracle price, we should be able to withdraw the rest of our collateral. + await updateOracle() + await vault.sync() + + expect(await longPosition()).to.equal(0) + expect(await shortPosition()).to.equal(0) + + // Our collateral should be less than the fixedFloat and greater than 0. + await vault.claim(user.address) + expect(await totalCollateralInVault()).to.eq(0) + expect(await vault.totalAssets()).to.equal(0) + }) + + it('maxDeposit', async () => { + expect(await vault.maxDeposit(user.address)).to.equal(maxCollateral) + const depositSize = utils.parseEther('200000') + + await vault.connect(user).deposit(depositSize, user.address) + expect(await vault.maxDeposit(user.address)).to.equal(maxCollateral.sub(depositSize)) + + await vault.connect(user2).deposit(utils.parseEther('200000'), user2.address) + expect(await vault.maxDeposit(user.address)).to.equal(maxCollateral.sub(depositSize).sub(depositSize)) + + await vault.connect(liquidator).deposit(utils.parseEther('100000'), liquidator.address) + expect(await vault.maxDeposit(user.address)).to.equal(0) + + await expect(vault.connect(liquidator).deposit(1, liquidator.address)).to.revertedWithCustomError( + vault, + 'BalancedVaultDepositLimitExceeded', + ) + }) + + it('rebalances collateral', async () => { + await vault.connect(user).deposit(utils.parseEther('100000'), user.address) + await updateOracle() + await vault.sync() + + const originalTotalCollateral = await totalCollateralInVault() + + // Collaterals should be equal. + expect(await longCollateralInVault()).to.equal(await shortCollateralInVault()) + + await updateOracle(utils.parseEther('1300')) + await long.connect(user).settleAccount(vault.address) + await short.connect(user).settleAccount(vault.address) + + // Collaterals should not be equal any more. + expect(await longCollateralInVault()).to.not.equal(await shortCollateralInVault()) + + await vault.sync() + + // Collaterals should be equal again! + expect(await longCollateralInVault()).to.equal(await shortCollateralInVault()) + + await updateOracle(originalOraclePrice) + await vault.sync() + + // Since the price changed then went back to the original, the total collateral should have increased. + const fundingAmount = BigNumber.from(21517482108955) + expect(await totalCollateralInVault()).to.eq(originalTotalCollateral.add(fundingAmount)) + expect(await vault.totalAssets()).to.eq(originalTotalCollateral.add(fundingAmount)) + }) + + it('rounds deposits correctly', async () => { + const collateralDifference = async () => { + return (await longCollateralInVault()).sub(await shortCollateralInVault()).abs() + } + const oddDepositAmount = utils.parseEther('10000').add(1) // 10K + 1 wei + + await vault.connect(user).deposit(oddDepositAmount, user.address) + await updateOracle() + await vault.sync() + expect(await collateralDifference()).to.equal(0) + expect(await asset.balanceOf(vault.address)).to.equal(1) + + await vault.connect(user).deposit(oddDepositAmount, user.address) + await updateOracle() + await vault.sync() + expect(await collateralDifference()).to.equal(0) + }) + + it('deposit on behalf', async () => { + const largeDeposit = utils.parseEther('10000') + await vault.connect(liquidator).deposit(largeDeposit, user.address) + await updateOracle() + + await vault.sync() + expect(await vault.balanceOf(user.address)).to.equal(utils.parseEther('10000')) + expect(await vault.totalSupply()).to.equal(utils.parseEther('10000')) + + await expect(vault.connect(liquidator).redeem(utils.parseEther('10000'), user.address)).to.revertedWithPanic( + '0x11', + ) + + await vault.connect(user).approve(liquidator.address, utils.parseEther('10000')) + + // User 2 should not be able to withdraw; they haven't deposited anything. + await vault.connect(liquidator).redeem(utils.parseEther('10000'), user.address) + await updateOracle() + await vault.sync() + + // We should have withdrawn all of our collateral. + const fundingAmount = BigNumber.from('1524724459128') + await vault.connect(user).claim(user.address) + expect(await totalCollateralInVault()).to.equal(0) + expect(await vault.totalAssets()).to.equal(0) + expect(await asset.balanceOf(liquidator.address)).to.equal(utils.parseEther('190000')) + expect(await asset.balanceOf(user.address)).to.equal(utils.parseEther('210000').add(fundingAmount)) + }) + + it('redeem on behalf', async () => { + const largeDeposit = utils.parseEther('10000') + await vault.connect(user).deposit(largeDeposit, user.address) + await updateOracle() + + await vault.sync() + expect(await vault.balanceOf(user.address)).to.equal(utils.parseEther('10000')) + expect(await vault.totalSupply()).to.equal(utils.parseEther('10000')) + + await expect(vault.connect(liquidator).redeem(utils.parseEther('10000'), user.address)).to.revertedWithPanic( + '0x11', + ) + + await vault.connect(user).approve(liquidator.address, utils.parseEther('10000')) + + // User 2 should not be able to withdraw; they haven't deposited anything. + await vault.connect(liquidator).redeem(utils.parseEther('10000'), user.address) + await updateOracle() + await vault.sync() + + // We should have withdrawn all of our collateral. + const fundingAmount = BigNumber.from('1524724459128') + await vault.connect(user).claim(user.address) + expect(await totalCollateralInVault()).to.equal(0) + expect(await asset.balanceOf(user.address)).to.equal(utils.parseEther('200000').add(fundingAmount)) + }) + + it('close to makerLimit', async () => { + // Get maker product very close to the makerLimit + await asset.connect(perennialUser).approve(collateral.address, constants.MaxUint256) + await collateral + .connect(perennialUser) + .depositTo(perennialUser.address, short.address, utils.parseEther('400000')) + await short.connect(perennialUser).openMake(utils.parseEther('480')) + await updateOracle() + await vault.sync() + + // Deposit should create a greater position than what's available + const largeDeposit = utils.parseEther('10000') + await vault.connect(user).deposit(largeDeposit, user.address) + await updateOracle() + await vault.sync() + + // Now we should have opened positions. + // The positions should be equal to (smallDeposit + largeDeposit) * leverage / 2 / originalOraclePrice. + expect(await longPosition()).to.equal(largeDeposit.mul(leverage).div(2).div(originalOraclePrice)) + const makerLimitDelta = BigNumber.from('8282802043703935198') + expect(await shortPosition()).to.equal(makerLimitDelta) + }) + + it('exactly at makerLimit', async () => { + // Get maker product very close to the makerLimit + await asset.connect(perennialUser).approve(collateral.address, constants.MaxUint256) + await collateral + .connect(perennialUser) + .depositTo(perennialUser.address, short.address, utils.parseEther('400000')) + const makerAvailable = (await short.makerLimit()).sub( + (await short.positionAtVersion(await short['latestVersion()']())).maker, + ) + + await short.connect(perennialUser).openMake(makerAvailable) + await updateOracle() + await vault.sync() + + // Deposit should create a greater position than what's available + const largeDeposit = utils.parseEther('10000') + await vault.connect(user).deposit(largeDeposit, user.address) + await updateOracle() + await vault.sync() + + // Now we should have opened positions. + // The positions should be equal to (smallDeposit + largeDeposit) * leverage / 2 / originalOraclePrice. + expect(await longPosition()).to.equal(largeDeposit.mul(leverage).div(2).div(originalOraclePrice)) + expect(await shortPosition()).to.equal(0) + }) + + context('liquidation', () => { + context('long', () => { + it('recovers before being liquidated', async () => { + await vault.connect(user).deposit(utils.parseEther('100000'), user.address) + await updateOracle() + + // 1. An oracle update makes the long position liquidatable. + // We should now longer be able to deposit or redeem + await updateOracle(utils.parseEther('4000')) + + await expect(vault.connect(user).deposit(2, user.address)).to.revertedWithCustomError( + vault, + 'BalancedVaultDepositLimitExceeded', + ) + await expect(vault.connect(user).redeem(2, user.address)).to.revertedWithCustomError( + vault, + 'BalancedVaultRedemptionLimitExceeded', + ) + + // 2. Settle accounts. + // We should still not be able to deposit. + await long.connect(user).settleAccount(vault.address) + await short.connect(user).settleAccount(vault.address) + expect(await vault.maxDeposit(user.address)).to.equal(0) + await expect(vault.connect(user).deposit(2, user.address)).to.revertedWithCustomError( + vault, + 'BalancedVaultDepositLimitExceeded', + ) + await expect(vault.connect(user).redeem(2, user.address)).to.revertedWithCustomError( + vault, + 'BalancedVaultRedemptionLimitExceeded', + ) + + // 3. Sync the vault before it has a chance to get liquidated, it will work and no longer be liquidatable + // We should still be able to deposit. + await vault.sync() + expect(await vault.maxDeposit(user.address)).to.equal('402312347065256226909035') + await vault.connect(user).deposit(2, user.address) + }) + + it('recovers from a liquidation', async () => { + await vault.connect(user).deposit(utils.parseEther('100000'), user.address) + await updateOracle() + + // 1. An oracle update makes the long position liquidatable. + // We should now longer be able to deposit or redeem + await updateOracle(utils.parseEther('4000')) + + await expect(vault.connect(user).deposit(2, user.address)).to.revertedWithCustomError( + vault, + 'BalancedVaultDepositLimitExceeded', + ) + await expect(vault.connect(user).redeem(2, user.address)).to.revertedWithCustomError( + vault, + 'BalancedVaultRedemptionLimitExceeded', + ) + + // 2. Settle accounts. + // We should still not be able to deposit or redeem. + await long.connect(user).settleAccount(vault.address) + await short.connect(user).settleAccount(vault.address) + expect(await vault.maxDeposit(user.address)).to.equal(0) + await expect(vault.connect(user).deposit(2, user.address)).to.revertedWithCustomError( + vault, + 'BalancedVaultDepositLimitExceeded', + ) + await expect(vault.connect(user).redeem(2, user.address)).to.revertedWithCustomError( + vault, + 'BalancedVaultRedemptionLimitExceeded', + ) + + // 4. Liquidate the long position. + // We should still not be able to deposit or redeem. + await collateral.connect(liquidator).liquidate(vault.address, long.address) + expect(await vault.maxDeposit(user.address)).to.equal(0) + await expect(vault.connect(user).deposit(2, user.address)).to.revertedWithCustomError( + vault, + 'BalancedVaultDepositLimitExceeded', + ) + await expect(vault.connect(user).redeem(2, user.address)).to.revertedWithCustomError( + vault, + 'BalancedVaultRedemptionLimitExceeded', + ) + + // 5. Settle the liquidation. + // We now be able to deposit. + await updateOracle(utils.parseEther('3000')) + await vault.connect(user).deposit(2, user.address) + + await updateOracle() + await vault.sync() + + const finalPosition = BigNumber.from('62621983855221267778') + const finalCollateral = BigNumber.from('46966487895388362252059') + expect(await longPosition()).to.equal(finalPosition) + expect(await shortPosition()).to.equal(finalPosition) + expect(await longCollateralInVault()).to.equal(finalCollateral) + expect(await shortCollateralInVault()).to.equal(finalCollateral) + }) + }) + + context('short', () => { + beforeEach(async () => { + // get utilization closer to target in order to trigger pnl on price deviation + await asset.connect(perennialUser).approve(collateral.address, constants.MaxUint256) + await collateral + .connect(perennialUser) + .depositTo(perennialUser.address, long.address, utils.parseEther('120000')) + await long.connect(perennialUser).openTake(utils.parseEther('700')) + await collateral + .connect(perennialUser) + .depositTo(perennialUser.address, short.address, utils.parseEther('280000')) + await short.connect(perennialUser).openTake(utils.parseEther('1100')) + await updateOracle() + await vault.sync() + }) + + it('recovers before being liquidated', async () => { + await vault.connect(user).deposit(utils.parseEther('100000'), user.address) + await updateOracle() + + // 1. An oracle update makes the short position liquidatable. + // We should now longer be able to deposit or redeem + await updateOracle(utils.parseEther('1200')) + + await expect(vault.connect(user).deposit(2, user.address)).to.revertedWithCustomError( + vault, + 'BalancedVaultDepositLimitExceeded', + ) + await expect(vault.connect(user).redeem(2, user.address)).to.revertedWithCustomError( + vault, + 'BalancedVaultRedemptionLimitExceeded', + ) + + // 2. Settle accounts. + // We should still not be able to deposit. + await long.connect(user).settleAccount(vault.address) + await short.connect(user).settleAccount(vault.address) + expect(await vault.maxDeposit(user.address)).to.equal(0) + await expect(vault.connect(user).deposit(2, user.address)).to.revertedWithCustomError( + vault, + 'BalancedVaultDepositLimitExceeded', + ) + await expect(vault.connect(user).redeem(2, user.address)).to.revertedWithCustomError( + vault, + 'BalancedVaultRedemptionLimitExceeded', + ) + + // 3. Sync the vault before it has a chance to get liquidated, it will work and no longer be liquidatable + // We should still be able to deposit. + await vault.sync() + expect(await vault.maxDeposit(user.address)).to.equal('396604778052719336340483') + await vault.connect(user).deposit(2, user.address) + }) + + it('recovers from a liquidation', async () => { + await vault.connect(user).deposit(utils.parseEther('100000'), user.address) + await updateOracle() + + // 1. An oracle update makes the long position liquidatable. + // We should now longer be able to deposit or redeem + await updateOracle(utils.parseEther('1200')) + + await expect(vault.connect(user).deposit(2, user.address)).to.revertedWithCustomError( + vault, + 'BalancedVaultDepositLimitExceeded', + ) + await expect(vault.connect(user).redeem(2, user.address)).to.revertedWithCustomError( + vault, + 'BalancedVaultRedemptionLimitExceeded', + ) + + // 2. Settle accounts. + // We should still not be able to deposit or redeem. + await long.connect(user).settleAccount(vault.address) + await short.connect(user).settleAccount(vault.address) + expect(await vault.maxDeposit(user.address)).to.equal(0) + await expect(vault.connect(user).deposit(2, user.address)).to.revertedWithCustomError( + vault, + 'BalancedVaultDepositLimitExceeded', + ) + await expect(vault.connect(user).redeem(2, user.address)).to.revertedWithCustomError( + vault, + 'BalancedVaultRedemptionLimitExceeded', + ) + + // 4. Liquidate the long position. + // We should still not be able to deposit or redeem. + await collateral.connect(liquidator).liquidate(vault.address, short.address) + expect(await vault.maxDeposit(user.address)).to.equal(0) + await expect(vault.connect(user).deposit(2, user.address)).to.revertedWithCustomError( + vault, + 'BalancedVaultDepositLimitExceeded', + ) + await expect(vault.connect(user).redeem(2, user.address)).to.revertedWithCustomError( + vault, + 'BalancedVaultRedemptionLimitExceeded', + ) + + // 5. Settle the liquidation. + // We now be able to deposit. + await updateOracle() + await vault.connect(user).deposit(2, user.address) + + await updateOracle() + await vault.sync() + + const finalPosition = BigNumber.from('169949012636167808676') + const finalCollateral = BigNumber.from('50984710404199215353605') + expect(await longPosition()).to.equal(finalPosition) + expect(await shortPosition()).to.equal(finalPosition) + expect(await longCollateralInVault()).to.equal(finalCollateral) + expect(await shortCollateralInVault()).to.equal(finalCollateral) + }) + }) + }) + + context('insolvency', () => { + beforeEach(async () => { + // get utilization closer to target in order to trigger pnl on price deviation + await asset.connect(perennialUser).approve(collateral.address, constants.MaxUint256) + await collateral + .connect(perennialUser) + .depositTo(perennialUser.address, long.address, utils.parseEther('120000')) + await long.connect(perennialUser).openTake(utils.parseEther('700')) + await updateOracle() + await vault.sync() + }) + + it('gracefully unwinds upon insolvency', async () => { + // 1. Deposit initial amount into the vault + await vault.connect(user).deposit(utils.parseEther('100000'), user.address) + await updateOracle() + await vault.sync() + + // 2. Redeem most of the amount, but leave it unclaimed + await vault.connect(user).redeem(utils.parseEther('80000'), user.address) + await updateOracle() + await vault.sync() + + // 3. An oracle update makes the long position liquidatable, initiate take close + await updateOracle(utils.parseEther('20000')) + await long.connect(user).settleAccount(vault.address) + await short.connect(user).settleAccount(vault.address) + await long.connect(perennialUser).closeTake(utils.parseEther('700')) + await collateral.connect(liquidator).liquidate(vault.address, long.address) + + // // 4. Settle the vault to recover and rebalance + await updateOracle() // let take settle at high price + await updateOracle(utils.parseEther('1500')) // return to normal price to let vault rebalance + await vault.sync() + await updateOracle() + await vault.sync() + + // 5. Vault should no longer have enough collateral to cover claims, pro-rata claim should be enabled + const finalPosition = BigNumber.from('0') + const finalCollateral = BigNumber.from('24937450010257810297106') + const finalUnclaimed = BigNumber.from('80000014845946136115820') + expect(await longPosition()).to.equal(finalPosition) + expect(await shortPosition()).to.equal(finalPosition) + expect(await longCollateralInVault()).to.equal(finalCollateral) + expect(await shortCollateralInVault()).to.equal(finalCollateral) + expect(await vault.unclaimed(user.address)).to.equal(finalUnclaimed) + expect(await vault.totalUnclaimed()).to.equal(finalUnclaimed) + await expect(vault.connect(user).deposit(2, user.address)).to.revertedWithCustomError( + vault, + 'BalancedVaultDepositLimitExceeded', + ) + + // 6. Claim should be pro-rated + const initialBalanceOf = await asset.balanceOf(user.address) + await vault.claim(user.address) + expect(await longCollateralInVault()).to.equal(0) + expect(await shortCollateralInVault()).to.equal(0) + expect(await vault.unclaimed(user.address)).to.equal(0) + expect(await vault.totalUnclaimed()).to.equal(0) + expect(await asset.balanceOf(user.address)).to.equal(initialBalanceOf.add(finalCollateral.mul(2))) + + // 7. Should no longer be able to deposit, vault is closed + await expect(vault.connect(user).deposit(2, user.address)).to.revertedWithCustomError( + vault, + 'BalancedVaultDepositLimitExceeded', + ) + }) + }) + }) +}) diff --git a/packages/perennial-vaults/test/unit/SingleBalancedVault/SingleBalancedVault.test.ts b/packages/perennial-vaults/test/unit/SingleBalancedVault/SingleBalancedVault.test.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/perennial-vaults/test/verification/arbitrum/PerennialVaults/verifyPVA.test.ts b/packages/perennial-vaults/test/verification/arbitrum/PerennialVaults/verifyPVA.test.ts index 212eb79c..31b1fc12 100644 --- a/packages/perennial-vaults/test/verification/arbitrum/PerennialVaults/verifyPVA.test.ts +++ b/packages/perennial-vaults/test/verification/arbitrum/PerennialVaults/verifyPVA.test.ts @@ -4,8 +4,8 @@ import { utils } from 'ethers' import { Deployment } from 'hardhat-deploy/types' import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' import { - BalancedVault, - BalancedVault__factory, + SingleBalancedVault, + SingleBalancedVault__factory, IController, IController__factory, ProxyAdmin, @@ -21,7 +21,7 @@ describe('Vault - Perennial Vault Alpha - Arbitrum Verification', () => { let deployments: { [name: string]: Deployment } let controller: IController let proxyAdmin: ProxyAdmin - let vault: BalancedVault + let vault: SingleBalancedVault beforeEach(async () => { await time.reset(config) @@ -31,11 +31,11 @@ describe('Vault - Perennial Vault Alpha - Arbitrum Verification', () => { controller = IController__factory.connect(deployments['Controller_Proxy'].address, signer) proxyAdmin = ProxyAdmin__factory.connect(deployments['ProxyAdmin'].address, signer) - vault = BalancedVault__factory.connect(deployments['PerennialVaultAlpha_Proxy'].address, signer) + vault = SingleBalancedVault__factory.connect(deployments['PerennialVaultAlpha_Proxy'].address, signer) }) it('is already initialized', async () => { - await expect(vault.callStatic.initialize('PerennialVaultAlpha')).to.be.revertedWithCustomError( + await expect(vault.callStatic.initialize('PerennialVaultAlpha', 'PVA')).to.be.revertedWithCustomError( vault, 'UInitializableAlreadyInitializedError', ) diff --git a/packages/perennial-vaults/test/verification/arbitrum/PerennialVaults/verifyPVB.test.ts b/packages/perennial-vaults/test/verification/arbitrum/PerennialVaults/verifyPVB.test.ts index 90024509..322d006b 100644 --- a/packages/perennial-vaults/test/verification/arbitrum/PerennialVaults/verifyPVB.test.ts +++ b/packages/perennial-vaults/test/verification/arbitrum/PerennialVaults/verifyPVB.test.ts @@ -4,8 +4,8 @@ import { utils } from 'ethers' import { Deployment } from 'hardhat-deploy/types' import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' import { - BalancedVault, - BalancedVault__factory, + SingleBalancedVault, + SingleBalancedVault__factory, IController, IController__factory, ProxyAdmin, @@ -19,18 +19,18 @@ describe('Vault - Perennial Vault Bravo - Arbitrum Verification', () => { let deployments: { [name: string]: Deployment } let controller: IController let proxyAdmin: ProxyAdmin - let vault: BalancedVault + let vault: SingleBalancedVault beforeEach(async () => { ;[signer] = await ethers.getSigners() deployments = await HRE.deployments.all() controller = IController__factory.connect(deployments['Controller_Proxy'].address, signer) proxyAdmin = ProxyAdmin__factory.connect(deployments['ProxyAdmin'].address, signer) - vault = BalancedVault__factory.connect(deployments['PerennialVaultBravo_Proxy'].address, signer) + vault = SingleBalancedVault__factory.connect(deployments['PerennialVaultBravo_Proxy'].address, signer) }) it('is already initialized', async () => { - await expect(vault.callStatic.initialize('PerennialVaultBravo')).to.be.revertedWithCustomError( + await expect(vault.callStatic.initialize('PerennialVaultBravo', 'PVB')).to.be.revertedWithCustomError( vault, 'UInitializableAlreadyInitializedError', )