diff --git a/contracts/contracts/interfaces/IVault.sol b/contracts/contracts/interfaces/IVault.sol index 55fae1009f..67999a0982 100644 --- a/contracts/contracts/interfaces/IVault.sol +++ b/contracts/contracts/interfaces/IVault.sol @@ -139,11 +139,18 @@ interface IVault { function getAllStrategies() external view returns (address[] memory); - /// @notice Deprecated. + function strategies(address _addr) + external + view + returns (VaultStorage.Strategy memory); + + /// @notice Deprecated: use `asset()` instead. function isSupportedAsset(address _asset) external view returns (bool); function asset() external view returns (address); + function oToken() external view returns (address); + function initialize(address) external; function addWithdrawalQueueLiquidity() external; diff --git a/contracts/contracts/interfaces/sonic/ISwapXGauge.sol b/contracts/contracts/interfaces/algebra/IAlgebraGauge.sol similarity index 100% rename from contracts/contracts/interfaces/sonic/ISwapXGauge.sol rename to contracts/contracts/interfaces/algebra/IAlgebraGauge.sol diff --git a/contracts/contracts/interfaces/sonic/ISwapXPair.sol b/contracts/contracts/interfaces/algebra/IAlgebraPair.sol similarity index 100% rename from contracts/contracts/interfaces/sonic/ISwapXPair.sol rename to contracts/contracts/interfaces/algebra/IAlgebraPair.sol diff --git a/contracts/contracts/proxies/Proxies.sol b/contracts/contracts/proxies/Proxies.sol index 2dc8e89832..39f70cec94 100644 --- a/contracts/contracts/proxies/Proxies.sol +++ b/contracts/contracts/proxies/Proxies.sol @@ -242,3 +242,10 @@ contract CompoundingStakingSSVStrategyProxy is contract OUSDMorphoV2StrategyProxy is InitializeGovernedUpgradeabilityProxy { } + +/** + * @notice OETHSupernovaAMOProxy delegates calls to an OETHSupernovaAMOStrategy implementation + */ +contract OETHSupernovaAMOProxy is InitializeGovernedUpgradeabilityProxy { + +} diff --git a/contracts/contracts/strategies/algebra/OETHSupernovaAMOStrategy.sol b/contracts/contracts/strategies/algebra/OETHSupernovaAMOStrategy.sol new file mode 100644 index 0000000000..a9d2de0b61 --- /dev/null +++ b/contracts/contracts/strategies/algebra/OETHSupernovaAMOStrategy.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +/** + * @title Supernova OETH Algorithmic Market Maker (AMO) Strategy + * @notice AMO strategy for the Supernova OETH/WETH stable pool + * @author Origin Protocol Inc + */ +import { StableSwapAMMStrategy } from "./StableSwapAMMStrategy.sol"; + +contract OETHSupernovaAMOStrategy is StableSwapAMMStrategy { + /** + * @param _baseConfig The `platformAddress` is the address of the Supernova OETH/WETH pool. + * The `vaultAddress` is the address of the OETH Vault. + * @param _gauge Address of the Supernova gauge for the pool. + */ + constructor(BaseStrategyConfig memory _baseConfig, address _gauge) + StableSwapAMMStrategy(_baseConfig, _gauge) + {} +} diff --git a/contracts/contracts/strategies/algebra/README.md b/contracts/contracts/strategies/algebra/README.md new file mode 100644 index 0000000000..2d0dc5066c --- /dev/null +++ b/contracts/contracts/strategies/algebra/README.md @@ -0,0 +1,19 @@ +# Diagrams + +## OETH Supernova AMO Strategy + +### Hierarchy + +![OETH Supernova AMO Strategy Hierarchy](../../../docs/OETHSupernovaAMOStrategyHierarchy.svg) + +### Interactions + +![OETH Supernova AMO Strategy Interactions](../../../docs/OETHSupernovaAMOStrategyInteractions.svg) + +### Squashed + +![OETH Supernova AMO Strategy Squashed](../../../docs/OETHSupernovaAMOStrategySquashed.svg) + +### Storage + +![OETH Supernova AMO Strategy Storage](../../../docs/OETHSupernovaAMOStrategyStorage.svg) diff --git a/contracts/contracts/strategies/algebra/StableSwapAMMStrategy.sol b/contracts/contracts/strategies/algebra/StableSwapAMMStrategy.sol new file mode 100644 index 0000000000..9273b564eb --- /dev/null +++ b/contracts/contracts/strategies/algebra/StableSwapAMMStrategy.sol @@ -0,0 +1,883 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +/** + * @title Algebra Algorithmic Market Maker (AMO) Strategy + * @notice AMO strategy for the Algebra stable swap pool + * @author Origin Protocol Inc + */ +import { SafeCast } from "@openzeppelin/contracts/utils/math/SafeCast.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +import { IERC20, InitializableAbstractStrategy } from "../../utils/InitializableAbstractStrategy.sol"; +import { StableMath } from "../../utils/StableMath.sol"; +import { sqrt } from "../../utils/PRBMath.sol"; +import { IBasicToken } from "../../interfaces/IBasicToken.sol"; +import { IPair } from "../../interfaces/algebra/IAlgebraPair.sol"; +import { IGauge } from "../../interfaces/algebra/IAlgebraGauge.sol"; +import { IVault } from "../../interfaces/IVault.sol"; + +contract StableSwapAMMStrategy is InitializableAbstractStrategy { + using SafeERC20 for IERC20; + using StableMath for uint256; + using SafeCast for uint256; + + /** + * @notice a threshold under which the contract no longer allows for the protocol to manually rebalance. + * Guarding against a strategist / guardian being taken over and with multiple transactions + * draining the protocol funds. + */ + uint256 public constant SOLVENCY_THRESHOLD = 0.998 ether; + + /// @notice Precision for the Algebra Stable AMM (sAMM) invariant k. + uint256 public constant PRECISION = 1e18; + + /// @notice Address of the asset (non OToken) token contract + address public immutable asset; + + /// @notice Address of the OToken token contract. + address public immutable oToken; + + /// @notice Address of the Algebra Stable pool contract. + address public immutable pool; + + /// @notice Address of the Algebra Gauge contract. + address public immutable gauge; + + /// @notice Index of the OToken in the Algebra pool. + uint256 public immutable oTokenPoolIndex; + + /// @notice The max amount the OToken/asset price can deviate from peg (1e18) + /// before deposits are reverted scaled to 18 decimals. + /// eg 0.01e18 or 1e16 is 1% which is 100 basis points. + /// This is the amount below and above peg so a 50 basis point deviation (0.005e18) + /// allows a price range from 0.995 to 1.005. + uint256 public maxDepeg; + + event SwapOTokensToPool( + uint256 oTokenMinted, + uint256 assetDepositAmount, + uint256 oTokenDepositAmount, + uint256 lpTokens + ); + event SwapAssetsToPool( + uint256 assetSwapped, + uint256 lpTokens, + uint256 oTokenBurnt + ); + event MaxDepegUpdated(uint256 maxDepeg); + + /** + * @dev Verifies that the caller is the Strategist of the Vault. + */ + modifier onlyStrategist() { + require( + msg.sender == IVault(vaultAddress).strategistAddr(), + "Caller is not the Strategist" + ); + _; + } + + /** + * @dev Skim the Algebra pool in case any extra asset or OToken tokens were added + */ + modifier skimPool() { + IPair(pool).skim(address(this)); + _; + } + + /** + * @dev Checks the pool is balanced enough to allow deposits. + */ + modifier nearBalancedPool() { + // OToken/asset price = asset / OToken + // Get the OToken/asset price for selling 1 OToken for asset + // As OToken is 1, the asset amount is the OToken/asset price + uint256 sellPrice = IPair(pool).getAmountOut(1e18, oToken); + + // Get the amount of OToken received from selling 1 asset. This is buying OToken. + uint256 oTokenAmount = IPair(pool).getAmountOut(1e18, asset); + + // If the pool is degenerate, then the pool is not valid and we can't deposit. + require(oTokenAmount > 0, "Pool degenerate"); + + // Convert to a OToken/asset price = asset / OToken + uint256 buyPrice = 1e36 / oTokenAmount; + + uint256 pegPrice = 1e18; + + require( + sellPrice >= pegPrice - maxDepeg && buyPrice <= pegPrice + maxDepeg, + "price out of range" + ); + _; + } + + /** + * @dev Checks the pool's balances have improved and the balances + * have not tipped to the other side. + * This modifier is only applied to functions that do swaps against the pool. + * Deposits and withdrawals are proportional to the pool's balances hence don't need this check. + */ + modifier improvePoolBalance() { + // Get the asset and OToken balances in the pool + ( + uint256 assetReserveBefore, + uint256 oTokenReserveBefore + ) = _getPoolReserves(); + // diff = asset balance - OToken balance + int256 diffBefore = assetReserveBefore.toInt256() - + oTokenReserveBefore.toInt256(); + + _; + + // Get the asset and OToken balances in the pool + ( + uint256 assetReserveAfter, + uint256 oTokenReserveAfter + ) = _getPoolReserves(); + // diff = asset balance - OToken balance + int256 diffAfter = assetReserveAfter.toInt256() - + oTokenReserveAfter.toInt256(); + + if (diffBefore == 0) { + require(diffAfter == 0, "Position balance is worsened"); + } else if (diffBefore < 0) { + // If the pool was originally imbalanced in favor of OToken, then + // we want to check that the pool is now more balanced + require(diffAfter <= 0, "Assets overshot peg"); + require(diffBefore < diffAfter, "OTokens balance worse"); + } else if (diffBefore > 0) { + // If the pool was originally imbalanced in favor of asset, then + // we want to check that the pool is now more balanced + require(diffAfter >= 0, "OTokens overshot peg"); + require(diffAfter < diffBefore, "Assets balance worse"); + } + } + + /** + * @param _baseConfig The `platformAddress` is the address of the Algebra pool. + * The `vaultAddress` is the address of the Origin Vault. + * @param _gauge Address of the Algebra gauge for the pool. + */ + constructor(BaseStrategyConfig memory _baseConfig, address _gauge) + InitializableAbstractStrategy(_baseConfig) + { + // Read the oToken address from the Vault + // TODO: After vault upgrade remove these hardcoded values + // address oTokenMem = IVault(_baseConfig.vaultAddress).oToken(); + // address assetMem = IVault(_baseConfig.vaultAddress).asset(); + address oTokenMem = 0x856c4Efb76C1D1AE02e20CEB03A2A6a08b0b8dC3; + address assetMem = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; + + // Checked both tokens are to 18 decimals + require( + IBasicToken(assetMem).decimals() == 18 && + IBasicToken(oTokenMem).decimals() == 18, + "Incorrect token decimals" + ); + // Check the Algebra pool is a Stable AMM (sAMM) + require( + IPair(_baseConfig.platformAddress).isStable() == true, + "Pool not stable" + ); + // Check the gauge is for the pool + require( + IGauge(_gauge).TOKEN() == _baseConfig.platformAddress, + "Incorrect gauge" + ); + oTokenPoolIndex = IPair(_baseConfig.platformAddress).token0() == + oTokenMem + ? 0 + : 1; + // Check the pool tokens are correct + require( + IPair(_baseConfig.platformAddress).token0() == + (oTokenPoolIndex == 0 ? oTokenMem : assetMem) && + IPair(_baseConfig.platformAddress).token1() == + (oTokenPoolIndex == 0 ? assetMem : oTokenMem), + "Incorrect pool tokens" + ); + + // Set the immutable variables + oToken = oTokenMem; + asset = assetMem; + pool = _baseConfig.platformAddress; + gauge = _gauge; + + // This is an implementation contract. The governor is set in the proxy contract. + _setGovernor(address(0)); + } + + /** + * Initializer for setting up strategy internal state. This overrides the + * InitializableAbstractStrategy initializer as Algebra strategies don't fit + * well within that abstraction. + * @param _rewardTokenAddresses Array containing SWPx token address + * @param _maxDepeg The max amount the OToken/asset price can deviate from peg (1e18) before deposits are reverted. + */ + function initialize( + address[] calldata _rewardTokenAddresses, + uint256 _maxDepeg + ) external onlyGovernor initializer { + address[] memory pTokens = new address[](1); + pTokens[0] = pool; + + address[] memory _assets = new address[](1); + _assets[0] = asset; + + InitializableAbstractStrategy._initialize( + _rewardTokenAddresses, + _assets, + pTokens + ); + + maxDepeg = _maxDepeg; + + _approveBase(); + } + + /*************************************** + Deposit + ****************************************/ + + /** + * @notice Deposit an amount of asset into the Algebra pool. + * Mint OToken in proportion to the pool's asset and OToken reserves, + * transfer asset and OToken to the pool, + * mint the pool's LP token and deposit in the gauge. + * @dev This tx must be wrapped by the VaultValueChecker. + * To minimize loses, the pool should be rebalanced before depositing. + * The pool's oToken/asset price must be within the maxDepeg range. + * @param _asset Address of asset token. + * @param _assetAmount Amount of asset tokens to deposit. + */ + function deposit(address _asset, uint256 _assetAmount) + external + override + onlyVault + nonReentrant + skimPool + nearBalancedPool + { + require(_asset == asset, "Unsupported asset"); + require(_assetAmount > 0, "Must deposit something"); + + (uint256 oTokenDepositAmount, ) = _deposit(_assetAmount); + + // Ensure solvency of the vault + _solvencyAssert(); + + // Emit event for the deposited asset tokens + emit Deposit(asset, pool, _assetAmount); + // Emit event for the minted OToken tokens + emit Deposit(oToken, pool, oTokenDepositAmount); + } + + /** + * @notice Deposit all the strategy's asset tokens into the Algebra pool. + * Mint OToken in proportion to the pool's asset and OToken reserves, + * transfer asset and OToken to the pool, + * mint the pool's LP token and deposit in the gauge. + * @dev This tx must be wrapped by the VaultValueChecker. + * To minimize loses, the pool should be rebalanced before depositing. + * The pool's oToken/asset price must be within the maxDepeg range. + */ + function depositAll() + external + override + onlyVault + nonReentrant + skimPool + nearBalancedPool + { + uint256 assetBalance = IERC20(asset).balanceOf(address(this)); + if (assetBalance > 0) { + (uint256 oTokenDepositAmount, ) = _deposit(assetBalance); + + // Ensure solvency of the vault + _solvencyAssert(); + + // Emit event for the deposited asset tokens + emit Deposit(asset, pool, assetBalance); + // Emit event for the minted OToken tokens + emit Deposit(oToken, pool, oTokenDepositAmount); + } + } + + /** + * @dev Mint OToken in proportion to the pool's asset and OToken reserves, + * transfer asset and OToken to the pool, + * mint the pool's LP token and deposit in the gauge. + * @param _assetAmount Amount of asset tokens to deposit. + * @return oTokenDepositAmount Amount of OToken tokens minted and deposited into the pool. + * @return lpTokens Amount of Algebra pool LP tokens minted and deposited into the gauge. + */ + function _deposit(uint256 _assetAmount) + internal + returns (uint256 oTokenDepositAmount, uint256 lpTokens) + { + // Calculate the required amount of OToken to mint based on the asset amount. + oTokenDepositAmount = _calcTokensToMint(_assetAmount); + + // Mint the required OToken tokens to this strategy + IVault(vaultAddress).mintForStrategy(oTokenDepositAmount); + + // Add asset and OToken liquidity to the pool and stake in gauge + lpTokens = _depositToPoolAndGauge(_assetAmount, oTokenDepositAmount); + } + + /*************************************** + Withdraw + ****************************************/ + + /** + * @notice Withdraw asset and OToken from the Algebra pool, burn the OToken, + * and transfer the asset to the recipient. + * @param _recipient Address of the Vault. + * @param _asset Address of the asset token. + * @param _assetAmount Amount of asset tokens to withdraw. + */ + function withdraw( + address _recipient, + address _asset, + uint256 _assetAmount + ) external override onlyVault nonReentrant skimPool { + require(_assetAmount > 0, "Must withdraw something"); + require(_asset == asset, "Unsupported asset"); + // This strategy can't be set as a default strategy for asset in the Vault. + // This means the recipient must always be the Vault. + require(_recipient == vaultAddress, "Only withdraw to vault allowed"); + + // Calculate how much pool LP tokens to burn to get the required amount of asset tokens back + uint256 lpTokens = _calcTokensToBurn(_assetAmount); + + // Withdraw pool LP tokens from the gauge and remove assets from from the pool + _withdrawFromGaugeAndPool(lpTokens); + + // Burn all the removed OToken and any that was left in the strategy + uint256 oTokenToBurn = IERC20(oToken).balanceOf(address(this)); + IVault(vaultAddress).burnForStrategy(oTokenToBurn); + + // Transfer asset to the recipient + // Note there can be a dust amount of asset left in the strategy as + // the burn of the pool's LP tokens is rounded up + require( + IERC20(asset).balanceOf(address(this)) >= _assetAmount, + "Not enough asset removed" + ); + IERC20(asset).safeTransfer(_recipient, _assetAmount); + + // Ensure solvency of the vault + _solvencyAssert(); + + // Emit event for the withdrawn asset tokens + emit Withdrawal(asset, pool, _assetAmount); + // Emit event for the burnt OToken tokens + emit Withdrawal(oToken, pool, oTokenToBurn); + } + + /** + * @notice Withdraw all pool LP tokens from the gauge, + * remove all asset and OToken from the Algebra pool, + * burn all the OToken, + * and transfer all the asset to the Vault contract. + * @dev There is no solvency check here as withdrawAll can be called to + * quickly secure assets to the Vault in emergencies. + */ + function withdrawAll() + external + override + onlyVaultOrGovernor + nonReentrant + skimPool + { + // Get all the pool LP tokens the strategy has staked in the gauge + uint256 lpTokens = IGauge(gauge).balanceOf(address(this)); + // Can not withdraw zero LP tokens from the gauge + if (lpTokens == 0) return; + + if (IGauge(gauge).emergency()) { + // The gauge is in emergency mode + _emergencyWithdrawFromGaugeAndPool(); + } else { + // Withdraw pool LP tokens from the gauge and remove assets from from the pool + _withdrawFromGaugeAndPool(lpTokens); + } + + // Burn all OToken in this strategy contract + uint256 oTokenToBurn = IERC20(oToken).balanceOf(address(this)); + IVault(vaultAddress).burnForStrategy(oTokenToBurn); + + // Get the strategy contract's asset balance. + // This includes all that was removed from the Algebra pool and + // any that was sitting in the strategy contract before the removal. + uint256 assetBalance = IERC20(asset).balanceOf(address(this)); + IERC20(asset).safeTransfer(vaultAddress, assetBalance); + + // Emit event for the withdrawn asset tokens + emit Withdrawal(asset, pool, assetBalance); + // Emit event for the burnt OToken tokens + emit Withdrawal(oToken, pool, oTokenToBurn); + } + + /*************************************** + Pool Rebalancing + ****************************************/ + + /** @notice Used when there is more OToken than asset in the pool. + * asset and OToken is removed from the pool, the received asset is swapped for OToken + * and the left over OToken in the strategy is burnt. + * The OToken/asset price is < 1.0 so OToken is being bought at a discount. + * @param _assetAmount Amount of asset tokens to swap into the pool. + */ + function swapAssetsToPool(uint256 _assetAmount) + external + onlyStrategist + nonReentrant + improvePoolBalance + skimPool + { + require(_assetAmount > 0, "Must swap something"); + + // 1. Partially remove liquidity so there’s enough asset for the swap + + // Calculate how much pool LP tokens to burn to get the required amount of asset tokens back + uint256 lpTokens = _calcTokensToBurn(_assetAmount); + require(lpTokens > 0, "No LP tokens to burn"); + + _withdrawFromGaugeAndPool(lpTokens); + + // 2. Swap asset for OToken against the pool + // Swap exact amount of asset for OToken against the pool + // There can be a dust amount of asset left in the strategy as the burn of the pool's LP tokens is rounded up + _swapExactTokensForTokens(_assetAmount, asset, oToken); + + // 3. Burn all the OToken left in the strategy from the remove liquidity and swap + uint256 oTokenToBurn = IERC20(oToken).balanceOf(address(this)); + IVault(vaultAddress).burnForStrategy(oTokenToBurn); + + // Ensure solvency of the vault + _solvencyAssert(); + + // Emit event for the burnt OToken tokens + emit Withdrawal(oToken, pool, oTokenToBurn); + // Emit event for the swap + emit SwapAssetsToPool(_assetAmount, lpTokens, oTokenToBurn); + } + + /** + * @notice Used when there is more asset than OToken in the pool. + * OToken is minted and swapped for asset against the pool, + * more OToken is minted and added back into the pool with the swapped out asset. + * The OToken/asset price is > 1.0 so OToken is being sold at a premium. + * @param _oTokenAmount Amount of OToken to swap into the pool. + */ + function swapOTokensToPool(uint256 _oTokenAmount) + external + onlyStrategist + nonReentrant + improvePoolBalance + skimPool + { + require(_oTokenAmount > 0, "Must swap something"); + + // 1. Mint OToken so it can be swapped into the pool + + // There can be OToken in the strategy from skimming the pool + uint256 oTokenInStrategy = IERC20(oToken).balanceOf(address(this)); + require( + _oTokenAmount >= oTokenInStrategy, + "Too much OToken in strategy" + ); + uint256 oTokenToMint = _oTokenAmount - oTokenInStrategy; + + // Mint the required OToken tokens to this strategy + IVault(vaultAddress).mintForStrategy(oTokenToMint); + + // 2. Swap OToken for asset against the pool + _swapExactTokensForTokens(_oTokenAmount, oToken, asset); + + // The asset is from the swap and any asset that was sitting in the strategy + uint256 assetDepositAmount = IERC20(asset).balanceOf(address(this)); + + // 3. Add asset and OToken back to the pool in proportion to the pool's reserves + (uint256 oTokenDepositAmount, uint256 lpTokens) = _deposit( + assetDepositAmount + ); + + // Ensure solvency of the vault + _solvencyAssert(); + + // Emit event for the minted OToken tokens + emit Deposit(oToken, pool, oTokenToMint + oTokenDepositAmount); + // Emit event for the swap + emit SwapOTokensToPool( + oTokenToMint, + assetDepositAmount, + oTokenDepositAmount, + lpTokens + ); + } + + /*************************************** + Assets and Rewards + ****************************************/ + + /** + * @notice Get the asset value of assets in the strategy and Algebra pool. + * The value of the assets in the pool is calculated assuming the pool is balanced. + * This way the value can not be manipulated by changing the pool's token balances. + * @param _asset Address of the asset token + * @return balance Total value in asset. + */ + function checkBalance(address _asset) + external + view + override + returns (uint256 balance) + { + require(_asset == asset, "Unsupported asset"); + + // asset balance needed here for the balance check that happens from vault during depositing. + balance = IERC20(asset).balanceOf(address(this)); + + // This assumes 1 gauge LP token = 1 pool LP token + uint256 lpTokens = IGauge(gauge).balanceOf(address(this)); + if (lpTokens == 0) return balance; + + // Add the strategy’s share of the asset and OToken tokens in the Algebra pool if the pool was balanced. + balance += _lpValue(lpTokens); + } + + /** + * @notice Returns bool indicating whether asset is supported by strategy + * @param _asset Address of the asset + */ + function supportsAsset(address _asset) public view override returns (bool) { + return _asset == asset; + } + + /** + * @notice Collect accumulated SWPx (and other) rewards and send to the Harvester. + */ + function collectRewardTokens() + external + override + onlyHarvester + nonReentrant + { + // Collect SWPx rewards from the gauge + IGauge(gauge).getReward(); + + _collectRewardTokens(); + } + + /*************************************** + Internal Algebra Pool and Gauge Functions + ****************************************/ + + /** + * @dev Calculate the required amount of OToken to mint based on the asset amount. + * This ensures the proportion of OToken tokens being added to the pool matches the proportion of asset tokens. + * For example, if the added asset tokens is 10% of existing asset tokens in the pool, + * then the OToken tokens being added should also be 10% of the OToken tokens in the pool. + * @param _assetAmount Amount of asset tokens to be added to the pool. + * @return oTokenAmount Amount of OToken tokens to be minted and added to the pool. + */ + function _calcTokensToMint(uint256 _assetAmount) + internal + view + returns (uint256 oTokenAmount) + { + (uint256 assetReserves, uint256 oTokenReserves) = _getPoolReserves(); + require(assetReserves > 0, "Empty pool"); + + // OToken to add = (asset being added * OToken in pool) / asset in pool + oTokenAmount = (_assetAmount * oTokenReserves) / assetReserves; + } + + /** + * @dev Calculate how much pool LP tokens to burn to get the required amount of asset tokens back + * from the pool. + * @param _assetAmount Amount of asset tokens to be removed from the pool. + * @return lpTokens Amount of Algebra pool LP tokens to burn. + */ + function _calcTokensToBurn(uint256 _assetAmount) + internal + view + returns (uint256 lpTokens) + { + /* The Algebra pool proportionally returns the reserve tokens when removing liquidity. + * First, calculate the proportion of required asset tokens against the pools asset reserves. + * That same proportion is used to calculate the required amount of pool LP tokens. + * For example, if the required asset tokens is 10% of the pool's asset reserves, + * then 10% of the pool's LP supply needs to be burned. + * + * Because we are doing balanced removal we should be making profit when removing liquidity in a + * pool tilted to either side. + * + * Important: A downside is that the Strategist / Governor needs to be + * cognizant of not removing too much liquidity. And while the proposal to remove liquidity + * is being voted on, the pool tilt might change so much that the proposal that has been valid while + * created is no longer valid. + */ + + (uint256 assetReserves, ) = _getPoolReserves(); + require(assetReserves > 0, "Empty pool"); + + lpTokens = (_assetAmount * IPair(pool).totalSupply()) / assetReserves; + lpTokens += 1; // Add 1 to ensure we get enough LP tokens with rounding + } + + /** + * @dev Deposit asset and OToken liquidity to the Algebra pool + * and stake the pool's LP token in the gauge. + * @param _assetAmount Amount of asset to deposit. + * @param _oTokenAmount Amount of OToken to deposit. + * @return lpTokens Amount of Algebra pool LP tokens minted. + */ + function _depositToPoolAndGauge(uint256 _assetAmount, uint256 _oTokenAmount) + internal + returns (uint256 lpTokens) + { + // Transfer asset to the pool + IERC20(asset).safeTransfer(pool, _assetAmount); + // Transfer OToken to the pool + IERC20(oToken).safeTransfer(pool, _oTokenAmount); + + // Mint LP tokens from the pool + lpTokens = IPair(pool).mint(address(this)); + + // Deposit the pool's LP tokens into the gauge + IGauge(gauge).deposit(lpTokens); + } + + /** + * @dev Withdraw pool LP tokens from the gauge and remove asset and OToken from the pool. + * @param _lpTokens Amount of Algebra pool LP tokens to withdraw from the gauge + */ + function _withdrawFromGaugeAndPool(uint256 _lpTokens) internal { + require( + IGauge(gauge).balanceOf(address(this)) >= _lpTokens, + "Not enough LP tokens in gauge" + ); + + // Withdraw pool LP tokens from the gauge + IGauge(gauge).withdraw(_lpTokens); + + // Transfer the pool LP tokens to the pool + IERC20(pool).safeTransfer(pool, _lpTokens); + + // Burn the LP tokens and transfer the asset and OToken back to the strategy + IPair(pool).burn(address(this)); + } + + /** + * @dev Withdraw all pool LP tokens from the gauge when it's in emergency mode + * and remove asset and OToken from the pool. + */ + function _emergencyWithdrawFromGaugeAndPool() internal { + // Withdraw all pool LP tokens from the gauge + IGauge(gauge).emergencyWithdraw(); + + // Get the pool LP tokens in strategy + uint256 _lpTokens = IERC20(pool).balanceOf(address(this)); + + // Transfer the pool LP tokens to the pool + IERC20(pool).safeTransfer(pool, _lpTokens); + + // Burn the LP tokens and transfer the asset and OToken back to the strategy + IPair(pool).burn(address(this)); + } + + /** + * @dev Swap exact amount of tokens for another token against the pool. + * @param _amountIn Amount of tokens to swap into the pool. + * @param _tokenIn Address of the token going into the pool. + * @param _tokenOut Address of the token being swapped out of the pool. + */ + function _swapExactTokensForTokens( + uint256 _amountIn, + address _tokenIn, + address _tokenOut + ) internal { + // Calculate how much out tokens we get from the swap + uint256 amountOut = IPair(pool).getAmountOut(_amountIn, _tokenIn); + + // Transfer in tokens to the pool after the amountOut calculation has been mde. + // This way we don't have to worry about sending tokens to pool confusing the pool's reserves. + IERC20(_tokenIn).safeTransfer(pool, _amountIn); + + // Safety check that we are dealing with the correct pool tokens + require( + (_tokenIn == asset && _tokenOut == oToken) || + (_tokenIn == oToken && _tokenOut == asset), + "Unsupported swap" + ); + + uint256 amount0; + uint256 amount1; + + // Work out the correct order of the amounts for the pool + if (_tokenIn == asset) { + if (oTokenPoolIndex == 0) { + amount0 = amountOut; + amount1 = 0; + } else { + amount0 = 0; + amount1 = amountOut; + } + } else { + if (oTokenPoolIndex == 0) { + amount0 = 0; + amount1 = amountOut; + } else { + amount0 = amountOut; + amount1 = 0; + } + } + + // Perform the swap on the pool + IPair(pool).swap(amount0, amount1, address(this), new bytes(0)); + + // The slippage protection against the amount out is indirectly done + // via the improvePoolBalance + } + + /// @dev Calculate the value of a LP position in a Algebra stable pool + /// if the pool was balanced. + /// @param _lpTokens Amount of LP tokens in the Algebra pool + /// @return value The asset value of the LP tokens when the pool is balanced + function _lpValue(uint256 _lpTokens) internal view returns (uint256 value) { + // Get total supply of LP tokens + uint256 totalSupply = IPair(pool).totalSupply(); + if (totalSupply == 0) return 0; + + // Get the current reserves of the pool + (uint256 assetReserves, uint256 oTokenReserves) = _getPoolReserves(); + + // Calculate the invariant of the pool assuming both tokens have 18 decimals. + // k is scaled to 18 decimals. + uint256 k = _invariant(assetReserves, oTokenReserves); + + // If x = y, let’s denote x = y = z (where z is the common reserve value) + // Substitute z into the invariant: + // k = z^3 * z + z * z^3 + // k = 2 * z^4 + // Going back the other way to calculate the common reserve value z + // z = (k / 2) ^ (1/4) + // the total value of the pool when x = y is 2 * z, which is 2 * (k / 2) ^ (1/4) + uint256 zSquared = sqrt((k * 1e18) / 2); // 18 + 18 = 36 decimals becomes 18 decimals after sqrt + uint256 z = sqrt(zSquared * 1e18); // 18 + 18 = 36 decimals becomes 18 decimals after sqrt + uint256 totalValueOfPool = 2 * z; + + // lp value = lp tokens * value of pool / total supply + value = (_lpTokens * totalValueOfPool) / totalSupply; + } + + /** + * @dev Compute the invariant for a Algebra stable pool. + * This assumed both x and y tokens are to 18 decimals which is checked in the constructor. + * invariant: k = x^3 * y + x * y^3 + * @dev This implementation is copied from Algebra's Pair contract. + * @param _x The amount of asset tokens in the pool + * @param _y The amount of the OToken tokens in the pool + * @return k The invariant of the Algebra stable pool + */ + function _invariant(uint256 _x, uint256 _y) + internal + pure + returns (uint256 k) + { + uint256 _a = (_x * _y) / PRECISION; + uint256 _b = ((_x * _x) / PRECISION + (_y * _y) / PRECISION); + // slither-disable-next-line divide-before-multiply + k = (_a * _b) / PRECISION; + } + + /** + * @dev Checks that the protocol is solvent, protecting from a rogue Strategist / Guardian that can + * keep rebalancing the pool in both directions making the protocol lose a tiny amount of + * funds each time. + * + * Protocol must be at least SOLVENCY_THRESHOLD (99,8 %) backed in order for the rebalances to + * function. + */ + function _solvencyAssert() internal view { + uint256 _totalVaultValue = IVault(vaultAddress).totalValue(); + uint256 _totalSupply = IERC20(oToken).totalSupply(); + + if ( + _totalSupply > 0 && + _totalVaultValue.divPrecisely(_totalSupply) < SOLVENCY_THRESHOLD + ) { + revert("Protocol insolvent"); + } + } + + /** + * @dev Get the reserves of the pool no matter the order of tokens in the underlying + * Algebra pool. + * @return assetReserves The reserves of the asset token in the pool. + * @return oTokenReserves The reserves of the OToken token in the pool. + */ + function _getPoolReserves() + internal + view + returns (uint256 assetReserves, uint256 oTokenReserves) + { + (uint256 reserve0, uint256 reserve1, ) = IPair(pool).getReserves(); + assetReserves = oTokenPoolIndex == 0 ? reserve1 : reserve0; + oTokenReserves = oTokenPoolIndex == 0 ? reserve0 : reserve1; + } + + /*************************************** + Setters + ****************************************/ + + /** + * @notice Set the maximum deviation from the OToken/asset peg (1e18) before deposits are reverted. + * @param _maxDepeg the OToken/asset price from peg (1e18) in 18 decimals. + * eg 0.01e18 or 1e16 is 1% which is 100 basis points. + */ + function setMaxDepeg(uint256 _maxDepeg) external onlyGovernor { + require( + _maxDepeg >= 0.001 ether && _maxDepeg <= 0.1 ether, + "Invalid max depeg range" + ); + maxDepeg = _maxDepeg; + + emit MaxDepegUpdated(_maxDepeg); + } + + /*************************************** + Approvals + ****************************************/ + + /** + * @notice Approve the spending of all assets by their corresponding pool tokens, + * if for some reason is it necessary. + */ + function safeApproveAllTokens() + external + override + onlyGovernor + nonReentrant + { + _approveBase(); + } + + // solhint-disable-next-line no-unused-vars + function _abstractSetPToken(address _asset, address _pToken) + internal + override + {} + + function _approveBase() internal { + // Approve Algebra gauge contract to transfer Algebra pool LP tokens + // This is needed for deposits of Algebra pool LP tokens into the gauge. + // slither-disable-next-line unused-return + IPair(pool).approve(address(gauge), type(uint256).max); + } +} diff --git a/contracts/contracts/strategies/sonic/SonicSwapXAMOStrategy.sol b/contracts/contracts/strategies/sonic/SonicSwapXAMOStrategy.sol index 1f1206a032..5fd18c8dfe 100644 --- a/contracts/contracts/strategies/sonic/SonicSwapXAMOStrategy.sol +++ b/contracts/contracts/strategies/sonic/SonicSwapXAMOStrategy.sol @@ -6,816 +6,15 @@ pragma solidity ^0.8.0; * @notice AMO strategy for the SwapX OS/wS stable pool * @author Origin Protocol Inc */ -import { SafeCast } from "@openzeppelin/contracts/utils/math/SafeCast.sol"; -import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; - -import { IERC20, InitializableAbstractStrategy } from "../../utils/InitializableAbstractStrategy.sol"; -import { StableMath } from "../../utils/StableMath.sol"; -import { sqrt } from "../../utils/PRBMath.sol"; -import { IBasicToken } from "../../interfaces/IBasicToken.sol"; -import { IPair } from "../../interfaces/sonic/ISwapXPair.sol"; -import { IGauge } from "../../interfaces/sonic/ISwapXGauge.sol"; -import { IVault } from "../../interfaces/IVault.sol"; - -contract SonicSwapXAMOStrategy is InitializableAbstractStrategy { - using SafeERC20 for IERC20; - using StableMath for uint256; - using SafeCast for uint256; - - /** - * @notice a threshold under which the contract no longer allows for the protocol to manually rebalance. - * Guarding against a strategist / guardian being taken over and with multiple transactions - * draining the protocol funds. - */ - uint256 public constant SOLVENCY_THRESHOLD = 0.998 ether; - - /// @notice Precision for the SwapX Stable AMM (sAMM) invariant k. - uint256 public constant PRECISION = 1e18; - - /// @notice Address of the Wrapped S (wS) token. - address public immutable ws; - - /// @notice Address of the OS token contract. - address public immutable os; - - /// @notice Address of the SwapX Stable pool contract. - address public immutable pool; - - /// @notice Address of the SwapX Gauge contract. - address public immutable gauge; - - /// @notice The max amount the OS/wS price can deviate from peg (1e18) - /// before deposits are reverted scaled to 18 decimals. - /// eg 0.01e18 or 1e16 is 1% which is 100 basis points. - /// This is the amount below and above peg so a 50 basis point deviation (0.005e18) - /// allows a price range from 0.995 to 1.005. - uint256 public maxDepeg; - - event SwapOTokensToPool( - uint256 osMinted, - uint256 wsDepositAmount, - uint256 osDepositAmount, - uint256 lpTokens - ); - event SwapAssetsToPool( - uint256 wsSwapped, - uint256 lpTokens, - uint256 osBurnt - ); - event MaxDepegUpdated(uint256 maxDepeg); - - /** - * @dev Verifies that the caller is the Strategist of the Vault. - */ - modifier onlyStrategist() { - require( - msg.sender == IVault(vaultAddress).strategistAddr(), - "Caller is not the Strategist" - ); - _; - } - - /** - * @dev Skim the SwapX pool in case any extra wS or OS tokens were added - */ - modifier skimPool() { - IPair(pool).skim(address(this)); - _; - } - - /** - * @dev Checks the pool is balanced enough to allow deposits. - */ - modifier nearBalancedPool() { - // OS/wS price = wS / OS - // Get the OS/wS price for selling 1 OS for wS - // As OS is 1, the wS amount is the OS/wS price - uint256 sellPrice = IPair(pool).getAmountOut(1e18, os); - - // Get the amount of OS received from selling 1 wS. This is buying OS. - uint256 osAmount = IPair(pool).getAmountOut(1e18, ws); - // Convert to a OS/wS price = wS / OS - uint256 buyPrice = 1e36 / osAmount; - - uint256 pegPrice = 1e18; - - require( - sellPrice >= pegPrice - maxDepeg && buyPrice <= pegPrice + maxDepeg, - "price out of range" - ); - _; - } - - /** - * @dev Checks the pool's balances have improved and the balances - * have not tipped to the other side. - * This modifier is only applied to functions that do swaps against the pool. - * Deposits and withdrawals are proportional to the pool's balances hence don't need this check. - */ - modifier improvePoolBalance() { - // Get the asset and OToken balances in the pool - (uint256 wsReservesBefore, uint256 osReservesBefore, ) = IPair(pool) - .getReserves(); - // diff = wS balance - OS balance - int256 diffBefore = wsReservesBefore.toInt256() - - osReservesBefore.toInt256(); - - _; - - // Get the asset and OToken balances in the pool - (uint256 wsReservesAfter, uint256 osReservesAfter, ) = IPair(pool) - .getReserves(); - // diff = wS balance - OS balance - int256 diffAfter = wsReservesAfter.toInt256() - - osReservesAfter.toInt256(); - - if (diffBefore == 0) { - require(diffAfter == 0, "Position balance is worsened"); - } else if (diffBefore < 0) { - // If the pool was originally imbalanced in favor of OS, then - // we want to check that the pool is now more balanced - require(diffAfter <= 0, "Assets overshot peg"); - require(diffBefore < diffAfter, "OTokens balance worse"); - } else if (diffBefore > 0) { - // If the pool was originally imbalanced in favor of wS, then - // we want to check that the pool is now more balanced - require(diffAfter >= 0, "OTokens overshot peg"); - require(diffAfter < diffBefore, "Assets balance worse"); - } - } +import { StableSwapAMMStrategy } from "../algebra/StableSwapAMMStrategy.sol"; +contract SonicSwapXAMOStrategy is StableSwapAMMStrategy { /** * @param _baseConfig The `platformAddress` is the address of the SwapX pool. * The `vaultAddress` is the address of the Origin Sonic Vault. - * @param _os Address of the OS token. - * @param _ws Address of the Wrapped S (wS) token. * @param _gauge Address of the SwapX gauge for the pool. */ - constructor( - BaseStrategyConfig memory _baseConfig, - address _os, - address _ws, - address _gauge - ) InitializableAbstractStrategy(_baseConfig) { - // Check the pool tokens are correct - require( - IPair(_baseConfig.platformAddress).token0() == _ws && - IPair(_baseConfig.platformAddress).token1() == _os, - "Incorrect pool tokens" - ); - // Checked both tokens are to 18 decimals - require( - IBasicToken(_ws).decimals() == 18 && - IBasicToken(_os).decimals() == 18, - "Incorrect token decimals" - ); - // Check the SwapX pool is a Stable AMM (sAMM) - require( - IPair(_baseConfig.platformAddress).isStable() == true, - "Pool not stable" - ); - // Check the gauge is for the pool - require( - IGauge(_gauge).TOKEN() == _baseConfig.platformAddress, - "Incorrect gauge" - ); - - // Set the immutable variables - os = _os; - ws = _ws; - pool = _baseConfig.platformAddress; - gauge = _gauge; - - // This is an implementation contract. The governor is set in the proxy contract. - _setGovernor(address(0)); - } - - /** - * Initializer for setting up strategy internal state. This overrides the - * InitializableAbstractStrategy initializer as SwapX strategies don't fit - * well within that abstraction. - * @param _rewardTokenAddresses Array containing SWPx token address - * @param _maxDepeg The max amount the OS/wS price can deviate from peg (1e18) before deposits are reverted. - */ - function initialize( - address[] calldata _rewardTokenAddresses, - uint256 _maxDepeg - ) external onlyGovernor initializer { - address[] memory pTokens = new address[](1); - pTokens[0] = pool; - - address[] memory _assets = new address[](1); - _assets[0] = ws; - - InitializableAbstractStrategy._initialize( - _rewardTokenAddresses, - _assets, - pTokens - ); - - maxDepeg = _maxDepeg; - - _approveBase(); - } - - /*************************************** - Deposit - ****************************************/ - - /** - * @notice Deposit an amount of Wrapped S (wS) into the SwapX pool. - * Mint OS in proportion to the pool's wS and OS reserves, - * transfer Wrapped S (wS) and OS to the pool, - * mint the pool's LP token and deposit in the gauge. - * @dev This tx must be wrapped by the VaultValueChecker. - * To minimize loses, the pool should be rebalanced before depositing. - * The pool's OS/wS price must be within the maxDepeg range. - * @param _asset Address of Wrapped S (wS) token. - * @param _wsAmount Amount of Wrapped S (wS) tokens to deposit. - */ - function deposit(address _asset, uint256 _wsAmount) - external - override - onlyVault - nonReentrant - skimPool - nearBalancedPool - { - require(_asset == ws, "Unsupported asset"); - require(_wsAmount > 0, "Must deposit something"); - - (uint256 osDepositAmount, ) = _deposit(_wsAmount); - - // Ensure solvency of the vault - _solvencyAssert(); - - // Emit event for the deposited wS tokens - emit Deposit(ws, pool, _wsAmount); - // Emit event for the minted OS tokens - emit Deposit(os, pool, osDepositAmount); - } - - /** - * @notice Deposit all the strategy's Wrapped S (wS) tokens into the SwapX pool. - * Mint OS in proportion to the pool's wS and OS reserves, - * transfer Wrapped S (wS) and OS to the pool, - * mint the pool's LP token and deposit in the gauge. - * @dev This tx must be wrapped by the VaultValueChecker. - * To minimize loses, the pool should be rebalanced before depositing. - * The pool's OS/wS price must be within the maxDepeg range. - */ - function depositAll() - external - override - onlyVault - nonReentrant - skimPool - nearBalancedPool - { - uint256 wsBalance = IERC20(ws).balanceOf(address(this)); - if (wsBalance > 0) { - (uint256 osDepositAmount, ) = _deposit(wsBalance); - - // Ensure solvency of the vault - _solvencyAssert(); - - // Emit event for the deposited wS tokens - emit Deposit(ws, pool, wsBalance); - // Emit event for the minted OS tokens - emit Deposit(os, pool, osDepositAmount); - } - } - - /** - * @dev Mint OS in proportion to the pool's wS and OS reserves, - * transfer Wrapped S (wS) and OS to the pool, - * mint the pool's LP token and deposit in the gauge. - * @param _wsAmount Amount of Wrapped S (wS) tokens to deposit. - * @return osDepositAmount Amount of OS tokens minted and deposited into the pool. - * @return lpTokens Amount of SwapX pool LP tokens minted and deposited into the gauge. - */ - function _deposit(uint256 _wsAmount) - internal - returns (uint256 osDepositAmount, uint256 lpTokens) - { - // Calculate the required amount of OS to mint based on the wS amount. - osDepositAmount = _calcTokensToMint(_wsAmount); - - // Mint the required OS tokens to this strategy - IVault(vaultAddress).mintForStrategy(osDepositAmount); - - // Add wS and OS liquidity to the pool and stake in gauge - lpTokens = _depositToPoolAndGauge(_wsAmount, osDepositAmount); - } - - /*************************************** - Withdraw - ****************************************/ - - /** - * @notice Withdraw wS and OS from the SwapX pool, burn the OS, - * and transfer the wS to the recipient. - * @param _recipient Address of the Vault. - * @param _asset Address of the Wrapped S (wS) contract. - * @param _wsAmount Amount of Wrapped S (wS) to withdraw. - */ - function withdraw( - address _recipient, - address _asset, - uint256 _wsAmount - ) external override onlyVault nonReentrant skimPool { - require(_wsAmount > 0, "Must withdraw something"); - require(_asset == ws, "Unsupported asset"); - // This strategy can't be set as a default strategy for wS in the Vault. - // This means the recipient must always be the Vault. - require(_recipient == vaultAddress, "Only withdraw to vault allowed"); - - // Calculate how much pool LP tokens to burn to get the required amount of wS tokens back - uint256 lpTokens = _calcTokensToBurn(_wsAmount); - - // Withdraw pool LP tokens from the gauge and remove assets from from the pool - _withdrawFromGaugeAndPool(lpTokens); - - // Burn all the removed OS and any that was left in the strategy - uint256 osToBurn = IERC20(os).balanceOf(address(this)); - IVault(vaultAddress).burnForStrategy(osToBurn); - - // Transfer wS to the recipient - // Note there can be a dust amount of wS left in the strategy as - // the burn of the pool's LP tokens is rounded up - require( - IERC20(ws).balanceOf(address(this)) >= _wsAmount, - "Not enough wS removed from pool" - ); - IERC20(ws).safeTransfer(_recipient, _wsAmount); - - // Ensure solvency of the vault - _solvencyAssert(); - - // Emit event for the withdrawn wS tokens - emit Withdrawal(ws, pool, _wsAmount); - // Emit event for the burnt OS tokens - emit Withdrawal(os, pool, osToBurn); - } - - /** - * @notice Withdraw all pool LP tokens from the gauge, - * remove all wS and OS from the SwapX pool, - * burn all the OS tokens, - * and transfer all the wS to the Vault contract. - * @dev There is no solvency check here as withdrawAll can be called to - * quickly secure assets to the Vault in emergencies. - */ - function withdrawAll() - external - override - onlyVaultOrGovernor - nonReentrant - skimPool - { - // Get all the pool LP tokens the strategy has staked in the gauge - uint256 lpTokens = IGauge(gauge).balanceOf(address(this)); - // Can not withdraw zero LP tokens from the gauge - if (lpTokens == 0) return; - - if (IGauge(gauge).emergency()) { - // The gauge is in emergency mode - _emergencyWithdrawFromGaugeAndPool(); - } else { - // Withdraw pool LP tokens from the gauge and remove assets from from the pool - _withdrawFromGaugeAndPool(lpTokens); - } - - // Burn all OS in this strategy contract - uint256 osToBurn = IERC20(os).balanceOf(address(this)); - IVault(vaultAddress).burnForStrategy(osToBurn); - - // Get the strategy contract's wS balance. - // This includes all that was removed from the SwapX pool and - // any that was sitting in the strategy contract before the removal. - uint256 wsBalance = IERC20(ws).balanceOf(address(this)); - IERC20(ws).safeTransfer(vaultAddress, wsBalance); - - // Emit event for the withdrawn wS tokens - emit Withdrawal(ws, pool, wsBalance); - // Emit event for the burnt OS tokens - emit Withdrawal(os, pool, osToBurn); - } - - /*************************************** - Pool Rebalancing - ****************************************/ - - /** @notice Used when there is more OS than wS in the pool. - * wS and OS is removed from the pool, the received wS is swapped for OS - * and the left over OS in the strategy is burnt. - * The OS/wS price is < 1.0 so OS is being bought at a discount. - * @param _wsAmount Amount of Wrapped S (wS) to swap into the pool. - */ - function swapAssetsToPool(uint256 _wsAmount) - external - onlyStrategist - nonReentrant - improvePoolBalance - skimPool - { - require(_wsAmount > 0, "Must swap something"); - - // 1. Partially remove liquidity so there’s enough wS for the swap - - // Calculate how much pool LP tokens to burn to get the required amount of wS tokens back - uint256 lpTokens = _calcTokensToBurn(_wsAmount); - require(lpTokens > 0, "No LP tokens to burn"); - - _withdrawFromGaugeAndPool(lpTokens); - - // 2. Swap wS for OS against the pool - // Swap exact amount of wS for OS against the pool - // There can be a dust amount of wS left in the strategy as the burn of the pool's LP tokens is rounded up - _swapExactTokensForTokens(_wsAmount, ws, os); - - // 3. Burn all the OS left in the strategy from the remove liquidity and swap - uint256 osToBurn = IERC20(os).balanceOf(address(this)); - IVault(vaultAddress).burnForStrategy(osToBurn); - - // Ensure solvency of the vault - _solvencyAssert(); - - // Emit event for the burnt OS tokens - emit Withdrawal(os, pool, osToBurn); - // Emit event for the swap - emit SwapAssetsToPool(_wsAmount, lpTokens, osToBurn); - } - - /** - * @notice Used when there is more wS than OS in the pool. - * OS is minted and swapped for wS against the pool, - * more OS is minted and added back into the pool with the swapped out wS. - * The OS/wS price is > 1.0 so OS is being sold at a premium. - * @param _osAmount Amount of OS to swap into the pool. - */ - function swapOTokensToPool(uint256 _osAmount) - external - onlyStrategist - nonReentrant - improvePoolBalance - skimPool - { - require(_osAmount > 0, "Must swap something"); - - // 1. Mint OS so it can be swapped into the pool - - // There can be OS in the strategy from skimming the pool - uint256 osInStrategy = IERC20(os).balanceOf(address(this)); - require(_osAmount >= osInStrategy, "Too much OS in strategy"); - uint256 osToMint = _osAmount - osInStrategy; - - // Mint the required OS tokens to this strategy - IVault(vaultAddress).mintForStrategy(osToMint); - - // 2. Swap OS for wS against the pool - _swapExactTokensForTokens(_osAmount, os, ws); - - // The wS is from the swap and any wS that was sitting in the strategy - uint256 wsDepositAmount = IERC20(ws).balanceOf(address(this)); - - // 3. Add wS and OS back to the pool in proportion to the pool's reserves - (uint256 osDepositAmount, uint256 lpTokens) = _deposit(wsDepositAmount); - - // Ensure solvency of the vault - _solvencyAssert(); - - // Emit event for the minted OS tokens - emit Deposit(os, pool, osToMint + osDepositAmount); - // Emit event for the swap - emit SwapOTokensToPool( - osToMint, - wsDepositAmount, - osDepositAmount, - lpTokens - ); - } - - /*************************************** - Assets and Rewards - ****************************************/ - - /** - * @notice Get the wS value of assets in the strategy and SwapX pool. - * The value of the assets in the pool is calculated assuming the pool is balanced. - * This way the value can not be manipulated by changing the pool's token balances. - * @param _asset Address of the Wrapped S (wS) token - * @return balance Total value in wS. - */ - function checkBalance(address _asset) - external - view - override - returns (uint256 balance) - { - require(_asset == ws, "Unsupported asset"); - - // wS balance needed here for the balance check that happens from vault during depositing. - balance = IERC20(ws).balanceOf(address(this)); - - // This assumes 1 gauge LP token = 1 pool LP token - uint256 lpTokens = IGauge(gauge).balanceOf(address(this)); - if (lpTokens == 0) return balance; - - // Add the strategy’s share of the wS and OS tokens in the SwapX pool if the pool was balanced. - balance += _lpValue(lpTokens); - } - - /** - * @notice Returns bool indicating whether asset is supported by strategy - * @param _asset Address of the asset - */ - function supportsAsset(address _asset) public view override returns (bool) { - return _asset == ws; - } - - /** - * @notice Collect accumulated SWPx (and other) rewards and send to the Harvester. - */ - function collectRewardTokens() - external - override - onlyHarvester - nonReentrant - { - // Collect SWPx rewards from the gauge - IGauge(gauge).getReward(); - - _collectRewardTokens(); - } - - /*************************************** - Internal SwapX Pool and Gauge Functions - ****************************************/ - - /** - * @dev Calculate the required amount of OS to mint based on the wS amount. - * This ensures the proportion of OS tokens being added to the pool matches the proportion of wS tokens. - * For example, if the added wS tokens is 10% of existing wS tokens in the pool, - * then the OS tokens being added should also be 10% of the OS tokens in the pool. - * @param _wsAmount Amount of Wrapped S (wS) to be added to the pool. - * @return osAmount Amount of OS to be minted and added to the pool. - */ - function _calcTokensToMint(uint256 _wsAmount) - internal - view - returns (uint256 osAmount) - { - (uint256 wsReserves, uint256 osReserves, ) = IPair(pool).getReserves(); - require(wsReserves > 0, "Empty pool"); - - // OS to add = (wS being added * OS in pool) / wS in pool - osAmount = (_wsAmount * osReserves) / wsReserves; - } - - /** - * @dev Calculate how much pool LP tokens to burn to get the required amount of wS tokens back - * from the pool. - * @param _wsAmount Amount of Wrapped S (wS) to be removed from the pool. - * @return lpTokens Amount of SwapX pool LP tokens to burn. - */ - function _calcTokensToBurn(uint256 _wsAmount) - internal - view - returns (uint256 lpTokens) - { - /* The SwapX pool proportionally returns the reserve tokens when removing liquidity. - * First, calculate the proportion of required wS tokens against the pools wS reserves. - * That same proportion is used to calculate the required amount of pool LP tokens. - * For example, if the required wS tokens is 10% of the pool's wS reserves, - * then 10% of the pool's LP supply needs to be burned. - * - * Because we are doing balanced removal we should be making profit when removing liquidity in a - * pool tilted to either side. - * - * Important: A downside is that the Strategist / Governor needs to be - * cognizant of not removing too much liquidity. And while the proposal to remove liquidity - * is being voted on, the pool tilt might change so much that the proposal that has been valid while - * created is no longer valid. - */ - - (uint256 wsReserves, , ) = IPair(pool).getReserves(); - require(wsReserves > 0, "Empty pool"); - - lpTokens = (_wsAmount * IPair(pool).totalSupply()) / wsReserves; - lpTokens += 1; // Add 1 to ensure we get enough LP tokens with rounding - } - - /** - * @dev Deposit Wrapped S (wS) and OS liquidity to the SwapX pool - * and stake the pool's LP token in the gauge. - * @param _wsAmount Amount of Wrapped S (wS) to deposit. - * @param _osAmount Amount of OS to deposit. - * @return lpTokens Amount of SwapX pool LP tokens minted. - */ - function _depositToPoolAndGauge(uint256 _wsAmount, uint256 _osAmount) - internal - returns (uint256 lpTokens) - { - // Transfer wS to the pool - IERC20(ws).safeTransfer(pool, _wsAmount); - // Transfer OS to the pool - IERC20(os).safeTransfer(pool, _osAmount); - - // Mint LP tokens from the pool - lpTokens = IPair(pool).mint(address(this)); - - // Deposit the pool's LP tokens into the gauge - IGauge(gauge).deposit(lpTokens); - } - - /** - * @dev Withdraw pool LP tokens from the gauge and remove wS and OS from the pool. - * @param _lpTokens Amount of SwapX pool LP tokens to withdraw from the gauge - */ - function _withdrawFromGaugeAndPool(uint256 _lpTokens) internal { - require( - IGauge(gauge).balanceOf(address(this)) >= _lpTokens, - "Not enough LP tokens in gauge" - ); - - // Withdraw pool LP tokens from the gauge - IGauge(gauge).withdraw(_lpTokens); - - // Transfer the pool LP tokens to the pool - IERC20(pool).safeTransfer(pool, _lpTokens); - - // Burn the LP tokens and transfer the wS and OS back to the strategy - IPair(pool).burn(address(this)); - } - - /** - * @dev Withdraw all pool LP tokens from the gauge when it's in emergency mode - * and remove wS and OS from the pool. - */ - function _emergencyWithdrawFromGaugeAndPool() internal { - // Withdraw all pool LP tokens from the gauge - IGauge(gauge).emergencyWithdraw(); - - // Get the pool LP tokens in strategy - uint256 _lpTokens = IERC20(pool).balanceOf(address(this)); - - // Transfer the pool LP tokens to the pool - IERC20(pool).safeTransfer(pool, _lpTokens); - - // Burn the LP tokens and transfer the wS and OS back to the strategy - IPair(pool).burn(address(this)); - } - - /** - * @dev Swap exact amount of tokens for another token against the pool. - * @param _amountIn Amount of tokens to swap into the pool. - * @param _tokenIn Address of the token going into the pool. - * @param _tokenOut Address of the token being swapped out of the pool. - */ - function _swapExactTokensForTokens( - uint256 _amountIn, - address _tokenIn, - address _tokenOut - ) internal { - // Transfer in tokens to the pool - IERC20(_tokenIn).safeTransfer(pool, _amountIn); - - // Calculate how much out tokens we get from the swap - uint256 amountOut = IPair(pool).getAmountOut(_amountIn, _tokenIn); - - // Safety check that we are dealing with the correct pool tokens - require( - (_tokenIn == ws && _tokenOut == os) || - (_tokenIn == os && _tokenOut == ws), - "Unsupported swap" - ); - - // Work out the correct order of the amounts for the pool - (uint256 amount0, uint256 amount1) = _tokenIn == ws - ? (uint256(0), amountOut) - : (amountOut, 0); - - // Perform the swap on the pool - IPair(pool).swap(amount0, amount1, address(this), new bytes(0)); - - // The slippage protection against the amount out is indirectly done - // via the improvePoolBalance - } - - /// @dev Calculate the value of a LP position in a SwapX stable pool - /// if the pool was balanced. - /// @param _lpTokens Amount of LP tokens in the SwapX pool - /// @return value The wS value of the LP tokens when the pool is balanced - function _lpValue(uint256 _lpTokens) internal view returns (uint256 value) { - // Get total supply of LP tokens - uint256 totalSupply = IPair(pool).totalSupply(); - if (totalSupply == 0) return 0; - - // Get the current reserves of the pool - (uint256 wsReserves, uint256 osReserves, ) = IPair(pool).getReserves(); - - // Calculate the invariant of the pool assuming both tokens have 18 decimals. - // k is scaled to 18 decimals. - uint256 k = _invariant(wsReserves, osReserves); - - // If x = y, let’s denote x = y = z (where z is the common reserve value) - // Substitute z into the invariant: - // k = z^3 * z + z * z^3 - // k = 2 * z^4 - // Going back the other way to calculate the common reserve value z - // z = (k / 2) ^ (1/4) - // the total value of the pool when x = y is 2 * z, which is 2 * (k / 2) ^ (1/4) - uint256 zSquared = sqrt((k * 1e18) / 2); // 18 + 18 = 36 decimals becomes 18 decimals after sqrt - uint256 z = sqrt(zSquared * 1e18); // 18 + 18 = 36 decimals becomes 18 decimals after sqrt - uint256 totalValueOfPool = 2 * z; - - // lp value = lp tokens * value of pool / total supply - value = (_lpTokens * totalValueOfPool) / totalSupply; - } - - /** - * @dev Compute the invariant for a SwapX stable pool. - * This assumed both x and y tokens are to 18 decimals which is checked in the constructor. - * invariant: k = x^3 * y + x * y^3 - * @dev This implementation is copied from SwapX's Pair contract. - * @param _x The amount of Wrapped S (wS) tokens in the pool - * @param _y The amount of the OS tokens in the pool - * @return k The invariant of the SwapX stable pool - */ - function _invariant(uint256 _x, uint256 _y) - internal - pure - returns (uint256 k) - { - uint256 _a = (_x * _y) / PRECISION; - uint256 _b = ((_x * _x) / PRECISION + (_y * _y) / PRECISION); - // slither-disable-next-line divide-before-multiply - k = (_a * _b) / PRECISION; - } - - /** - * @dev Checks that the protocol is solvent, protecting from a rogue Strategist / Guardian that can - * keep rebalancing the pool in both directions making the protocol lose a tiny amount of - * funds each time. - * - * Protocol must be at least SOLVENCY_THRESHOLD (99,8 %) backed in order for the rebalances to - * function. - */ - function _solvencyAssert() internal view { - uint256 _totalVaultValue = IVault(vaultAddress).totalValue(); - uint256 _totalSupply = IERC20(os).totalSupply(); - - if ( - _totalSupply > 0 && - _totalVaultValue.divPrecisely(_totalSupply) < SOLVENCY_THRESHOLD - ) { - revert("Protocol insolvent"); - } - } - - /*************************************** - Setters - ****************************************/ - - /** - * @notice Set the maximum deviation from the OS/wS peg (1e18) before deposits are reverted. - * @param _maxDepeg the OS/wS price from peg (1e18) in 18 decimals. - * eg 0.01e18 or 1e16 is 1% which is 100 basis points. - */ - function setMaxDepeg(uint256 _maxDepeg) external onlyGovernor { - maxDepeg = _maxDepeg; - - emit MaxDepegUpdated(_maxDepeg); - } - - /*************************************** - Approvals - ****************************************/ - - /** - * @notice Approve the spending of all assets by their corresponding pool tokens, - * if for some reason is it necessary. - */ - function safeApproveAllTokens() - external - override - onlyGovernor - nonReentrant - { - _approveBase(); - } - - // solhint-disable-next-line no-unused-vars - function _abstractSetPToken(address _asset, address _pToken) - internal - override + constructor(BaseStrategyConfig memory _baseConfig, address _gauge) + StableSwapAMMStrategy(_baseConfig, _gauge) {} - - function _approveBase() internal { - // Approve SwapX gauge contract to transfer SwapX pool LP tokens - // This is needed for deposits of SwapX pool LP tokens into the gauge. - // slither-disable-next-line unused-return - IPair(pool).approve(address(gauge), type(uint256).max); - } } diff --git a/contracts/deploy/deployActions.js b/contracts/deploy/deployActions.js index 7de245e46c..07c5d4c619 100644 --- a/contracts/deploy/deployActions.js +++ b/contracts/deploy/deployActions.js @@ -809,13 +809,6 @@ const getPlumeContracts = async () => { }; const deploySonicSwapXAMOStrategyImplementation = async () => { - const { deployerAddr } = await getNamedAccounts(); - const sDeployer = await ethers.provider.getSigner(deployerAddr); - - const cSonicSwapXAMOStrategyProxy = await ethers.getContract( - "SonicSwapXAMOStrategyProxy" - ); - const cOSonicProxy = await ethers.getContract("OSonicProxy"); const cOSonicVaultProxy = await ethers.getContract("OSonicVaultProxy"); // Deploy Sonic SwapX AMO Strategy implementation @@ -823,11 +816,25 @@ const deploySonicSwapXAMOStrategyImplementation = async () => { "SonicSwapXAMOStrategy", [ [addresses.sonic.SwapXWSOS.pool, cOSonicVaultProxy.address], - cOSonicProxy.address, - addresses.sonic.wS, addresses.sonic.SwapXWSOS.gauge, ] ); + + return dSonicSwapXAMOStrategy; +}; + +const deploySonicSwapXAMOStrategyImplementationAndInitialize = async () => { + const { deployerAddr } = await getNamedAccounts(); + const sDeployer = await ethers.provider.getSigner(deployerAddr); + + const cSonicSwapXAMOStrategyProxy = await ethers.getContract( + "SonicSwapXAMOStrategyProxy" + ); + + // Deploy Sonic SwapX AMO Strategy implementation + const dSonicSwapXAMOStrategy = + await deploySonicSwapXAMOStrategyImplementation(); + const cSonicSwapXAMOStrategy = await ethers.getContractAt( "SonicSwapXAMOStrategy", cSonicSwapXAMOStrategyProxy.address @@ -851,6 +858,49 @@ const deploySonicSwapXAMOStrategyImplementation = async () => { return cSonicSwapXAMOStrategy; }; +const deployOETHSupernovaAMOStrategyImplementation = async () => { + const { deployerAddr } = await getNamedAccounts(); + const sDeployer = await ethers.provider.getSigner(deployerAddr); + + const cOETHSupernovaAMOStrategyProxy = await ethers.getContract( + "OETHSupernovaAMOProxy" + ); + const cOETHVaultProxy = await ethers.getContract("OETHVaultProxy"); + + // Deploy OETH Supernova AMO Strategy implementation that will serve + // OETH Supernova AMO + const dSupernovaAMOStrategy = await deployWithConfirmation( + "OETHSupernovaAMOStrategy", + [ + [addresses.mainnet.SupernovaOETHWETH.pool, cOETHVaultProxy.address], + addresses.mainnet.SupernovaOETHWETH.gauge, + ] + ); + + const cOETHSupernovaAMOStrategy = await ethers.getContractAt( + "OETHSupernovaAMOStrategy", + cOETHSupernovaAMOStrategyProxy.address + ); + + // Initialize OETH Supernova AMO Strategy implementation + const depositPriceRange = parseUnits("0.01", 18); // 1% or 100 basis points + const initData = cOETHSupernovaAMOStrategy.interface.encodeFunctionData( + "initialize(address[],uint256)", + [[addresses.mainnet.supernovaToken], depositPriceRange] + ); + await withConfirmation( + // prettier-ignore + cOETHSupernovaAMOStrategyProxy + .connect(sDeployer)["initialize(address,address,bytes)"]( + dSupernovaAMOStrategy.address, + addresses.mainnet.Timelock, + initData + ) + ); + + return cOETHSupernovaAMOStrategy; +}; + const getCreate2ProxiesFilePath = async () => { const networkName = isFork || isForkTest || isCI ? "localhost" : await getNetworkName(); @@ -1208,6 +1258,8 @@ module.exports = { deployBaseAerodromeAMOStrategyImplementation, getPlumeContracts, deploySonicSwapXAMOStrategyImplementation, + deploySonicSwapXAMOStrategyImplementationAndInitialize, + deployOETHSupernovaAMOStrategyImplementation, deployProxyWithCreateX, deployCrossChainMasterStrategyImpl, deployCrossChainRemoteStrategyImpl, diff --git a/contracts/deploy/mainnet/172_oeth_vault_upgrade.js b/contracts/deploy/mainnet/172_oeth_vault_upgrade.js deleted file mode 100644 index 89ddb5a97e..0000000000 --- a/contracts/deploy/mainnet/172_oeth_vault_upgrade.js +++ /dev/null @@ -1,54 +0,0 @@ -const addresses = require("../../utils/addresses"); -const { deploymentWithGovernanceProposal } = require("../../utils/deploy"); - -module.exports = deploymentWithGovernanceProposal( - { - deployName: "172_oeth_vault_upgrade", - forceDeploy: false, - //forceSkip: true, - reduceQueueTime: true, - deployerIsProposer: false, - proposalId: "", - }, - async ({ deployWithConfirmation }) => { - // Deployer Actions - // ---------------- - - // 1. Deploy new OETH Vault Core and Admin implementations - const dVaultAdmin = await deployWithConfirmation( - "OETHVault", - [addresses.mainnet.WETH], - undefined, - true - ); - - // 2. Connect to the OETH Vault as its governor via the proxy - const cVaultProxy = await ethers.getContract("OETHVaultProxy"); - const cVault = await ethers.getContractAt("IVault", cVaultProxy.address); - - // 3. Connect to the Compounding Staking Strategy Proxy to set it as default strategy - const defaultStrategy = await ethers.getContract( - "CompoundingStakingSSVStrategyProxy" - ); - - // Governance Actions - // ---------------- - return { - name: "Upgrade OETH Vault to new Core and Admin implementations", - actions: [ - // 1. Upgrade the OETH Vault proxy to the new core vault implementation - { - contract: cVaultProxy, - signature: "upgradeTo(address)", - args: [dVaultAdmin.address], - }, - // 2. Set OETH/WETH AMO as default strategy - { - contract: cVault, - signature: "setDefaultStrategy(address)", - args: [defaultStrategy.address], - }, - ], - }; - } -); diff --git a/contracts/deploy/mainnet/179_vault_upgrade_supernova_AMO.js b/contracts/deploy/mainnet/179_vault_upgrade_supernova_AMO.js new file mode 100644 index 0000000000..616616fb97 --- /dev/null +++ b/contracts/deploy/mainnet/179_vault_upgrade_supernova_AMO.js @@ -0,0 +1,81 @@ +const addresses = require("../../utils/addresses"); +const { + deploymentWithGovernanceProposal, + deployWithConfirmation, +} = require("../../utils/deploy"); +const { + deployOETHSupernovaAMOStrategyImplementation, +} = require("../deployActions"); + +module.exports = deploymentWithGovernanceProposal( + { + deployName: "179_vault_upgrade_supernova_AMO", + }, + async ({ ethers }) => { + // 1. Deploy new OETH Vault Core and Admin implementations + const dVaultAdmin = await deployWithConfirmation( + "OETHVault", + [addresses.mainnet.WETH], + undefined, + true + ); + + // 2. Connect to the OETH Vault as its governor via the proxy + const cOETHVaultProxy = await ethers.getContract("OETHVaultProxy"); + const cVault = await ethers.getContractAt( + "IVault", + cOETHVaultProxy.address + ); + + // 3. Connect to the Compounding Staking Strategy Proxy to set it as default strategy + const defaultStrategy = await ethers.getContract( + "CompoundingStakingSSVStrategyProxy" + ); + + // 4. Deploy Supernova AMO Strategy implementation + await deployWithConfirmation("OETHSupernovaAMOProxy"); + const cOETHSupernovaAMOProxy = await ethers.getContract( + "OETHSupernovaAMOProxy" + ); + + // Deploy Supernova AMO Strategy implementation + const cSupernovaAMOStrategy = + await deployOETHSupernovaAMOStrategyImplementation(); + + return { + name: "Upgrade OETH Vault to new Core and Admin implementations and deploy Supernova AMO Strategy", + actions: [ + // 1. Upgrade the OETH Vault proxy to the new core vault implementation + { + contract: cOETHVaultProxy, + signature: "upgradeTo(address)", + args: [dVaultAdmin.address], + }, + // 2. Set Compounding Staking Strategy as default strategy + { + contract: cVault, + signature: "setDefaultStrategy(address)", + args: [defaultStrategy.address], + }, + // 3. Approve new strategy on the Vault + { + contract: cVault, + signature: "approveStrategy(address)", + args: [cOETHSupernovaAMOProxy.address], + }, + // 4. Add strategy to mint whitelist + { + contract: cVault, + signature: "addStrategyToMintWhitelist(address)", + args: [cOETHSupernovaAMOProxy.address], + }, + // 5. Set the Harvester on the Supernova AMO strategy + { + contract: cSupernovaAMOStrategy, + signature: "setHarvesterAddress(address)", + args: [addresses.multichainStrategist], + }, + ], + }; + } +); diff --git a/contracts/deploy/sonic/027_upgrade_swapx.js b/contracts/deploy/sonic/027_upgrade_swapx.js new file mode 100644 index 0000000000..8341e8ee9f --- /dev/null +++ b/contracts/deploy/sonic/027_upgrade_swapx.js @@ -0,0 +1,33 @@ +const { deployOnSonic } = require("../../utils/deploy-l2"); +const { + deploySonicSwapXAMOStrategyImplementation, +} = require("../deployActions"); + +// This is just used to confirm that the Refactoring SwapX AMO strategy into a generalized Algebra strategy is working +// as expected. This doesn't need to be deployed on Sonic. It should stay in to not break the behavior tests. +module.exports = deployOnSonic( + { + deployName: "027_upgrade_swapx", + forceSkip: false, + }, + async ({ ethers }) => { + const cSonicSwapXAMOStrategyProxy = await ethers.getContract( + "SonicSwapXAMOStrategyProxy" + ); + + // Deploy Sonic SwapX AMO Strategy implementation + const cSonicSwapXAMOImpl = + await deploySonicSwapXAMOStrategyImplementation(); + + return { + actions: [ + // 1. Upgrade SwapX AMO Strategy + { + contract: cSonicSwapXAMOStrategyProxy, + signature: "upgradeTo(address)", + args: [cSonicSwapXAMOImpl.address], + }, + ], + }; + } +); diff --git a/contracts/docs/OETHSupernovaAMOStrategyHierarchy.svg b/contracts/docs/OETHSupernovaAMOStrategyHierarchy.svg new file mode 100644 index 0000000000..6141e001eb --- /dev/null +++ b/contracts/docs/OETHSupernovaAMOStrategyHierarchy.svg @@ -0,0 +1,75 @@ + + + + + + +UmlClassDiagram + + + +40 + +<<Abstract>> +Governable +../contracts/governance/Governable.sol + + + +392 + +OETHSupernovaAMOStrategy +../contracts/strategies/algebra/OETHSupernovaAMOStrategy.sol + + + +393 + +StableSwapAMMStrategy +../contracts/strategies/algebra/StableSwapAMMStrategy.sol + + + +392->393 + + + + + +246 + +<<Abstract>> +InitializableAbstractStrategy +../contracts/utils/InitializableAbstractStrategy.sol + + + +393->246 + + + + + +245 + +<<Abstract>> +Initializable +../contracts/utils/Initializable.sol + + + +246->40 + + + + + +246->245 + + + + + diff --git a/contracts/docs/OETHSupernovaAMOStrategyInteractions.svg b/contracts/docs/OETHSupernovaAMOStrategyInteractions.svg new file mode 100644 index 0000000000..e52f46975c --- /dev/null +++ b/contracts/docs/OETHSupernovaAMOStrategyInteractions.svg @@ -0,0 +1,211 @@ + + + + + + +UmlClassDiagram + + + +54 + +<<Interface>> +IBasicToken + + + +88 + +<<Interface>> +IVault + + + +286 + +<<Interface>> +IGauge + + + +287 + +<<Interface>> +IPair + + + +392 + +OETHSupernovaAMOStrategy + + + +393 + +StableSwapAMMStrategy + + + +392->393 + + + + + +393->54 + + +decimals + + + +393->88 + + +asset +burnForStrategy +mint +mintForStrategy +oToken +strategistAddr +totalValue + + + +393->286 + + +TOKEN +balanceOf +deposit +emergency +emergencyWithdraw +getReward +totalSupply +withdraw + + + +393->287 + + +approve +balanceOf +burn +decimals +getAmountOut +getReserves +isStable +mint +skim +swap +token0 +token1 +totalSupply + + + +246 + +<<Abstract>> +InitializableAbstractStrategy + + + +393->246 + + + + + +250 + +<<Library>> +StableMath + + + +393->250 + + +divPrecisely + + + +1366 + +<<Interface>> +IERC20 + + + +393->1366 + + +approve +balanceOf +totalSupply + + + +1790 + +<<Library>> +SafeERC20 + + + +393->1790 + + +safeTransfer + + + +1391 + +<<Library>> +SafeCast + + + +393->1391 + + +toInt256 + + + +246->88 + + +strategistAddr + + + +246->1366 + + +balanceOf + + + +246->1790 + + +safeTransfer + + + +1790->1366 + + +allowance + + + diff --git a/contracts/docs/OETHSupernovaAMOStrategySquashed.svg b/contracts/docs/OETHSupernovaAMOStrategySquashed.svg new file mode 100644 index 0000000000..dbbfff8a29 --- /dev/null +++ b/contracts/docs/OETHSupernovaAMOStrategySquashed.svg @@ -0,0 +1,124 @@ + + + + + + +UmlClassDiagram + + + +392 + +OETHSupernovaAMOStrategy +../contracts/strategies/algebra/OETHSupernovaAMOStrategy.sol + +Private: +   initialized: bool <<Initializable>> +   initializing: bool <<Initializable>> +   ______gap: uint256[50] <<Initializable>> +   governorPosition: bytes32 <<Governable>> +   pendingGovernorPosition: bytes32 <<Governable>> +   reentryStatusPosition: bytes32 <<Governable>> +   _deprecated_platformAddress: address <<InitializableAbstractStrategy>> +   _deprecated_vaultAddress: address <<InitializableAbstractStrategy>> +   _deprecated_rewardTokenAddress: address <<InitializableAbstractStrategy>> +   _deprecated_rewardLiquidationThreshold: uint256 <<InitializableAbstractStrategy>> +   _reserved: int256[98] <<InitializableAbstractStrategy>> +Internal: +   assetsMapped: address[] <<InitializableAbstractStrategy>> +Public: +   _NOT_ENTERED: uint256 <<Governable>> +   _ENTERED: uint256 <<Governable>> +   platformAddress: address <<InitializableAbstractStrategy>> +   vaultAddress: address <<InitializableAbstractStrategy>> +   assetToPToken: mapping(address=>address) <<InitializableAbstractStrategy>> +   harvesterAddress: address <<InitializableAbstractStrategy>> +   rewardTokenAddresses: address[] <<InitializableAbstractStrategy>> +   SOLVENCY_THRESHOLD: uint256 <<StableSwapAMMStrategy>> +   PRECISION: uint256 <<StableSwapAMMStrategy>> +   asset: address <<StableSwapAMMStrategy>> +   oToken: address <<StableSwapAMMStrategy>> +   pool: address <<StableSwapAMMStrategy>> +   gauge: address <<StableSwapAMMStrategy>> +   oTokenPoolIndex: uint256 <<StableSwapAMMStrategy>> +   maxDepeg: uint256 <<StableSwapAMMStrategy>> + +Internal: +    _governor(): (governorOut: address) <<Governable>> +    _pendingGovernor(): (pendingGovernor: address) <<Governable>> +    _setGovernor(newGovernor: address) <<Governable>> +    _setPendingGovernor(newGovernor: address) <<Governable>> +    _changeGovernor(_newGovernor: address) <<Governable>> +    _initialize(_rewardTokenAddresses: address[], _assets: address[], _pTokens: address[]) <<InitializableAbstractStrategy>> +    _collectRewardTokens() <<InitializableAbstractStrategy>> +    _setPTokenAddress(_asset: address, _pToken: address) <<InitializableAbstractStrategy>> +    _abstractSetPToken(_asset: address, _pToken: address) <<StableSwapAMMStrategy>> +    _deposit(_assetAmount: uint256): (oTokenDepositAmount: uint256, lpTokens: uint256) <<StableSwapAMMStrategy>> +    _calcTokensToMint(_assetAmount: uint256): (oTokenAmount: uint256) <<StableSwapAMMStrategy>> +    _calcTokensToBurn(_assetAmount: uint256): (lpTokens: uint256) <<StableSwapAMMStrategy>> +    _depositToPoolAndGauge(_assetAmount: uint256, _oTokenAmount: uint256): (lpTokens: uint256) <<StableSwapAMMStrategy>> +    _withdrawFromGaugeAndPool(_lpTokens: uint256) <<StableSwapAMMStrategy>> +    _emergencyWithdrawFromGaugeAndPool() <<StableSwapAMMStrategy>> +    _swapExactTokensForTokens(_amountIn: uint256, _tokenIn: address, _tokenOut: address) <<StableSwapAMMStrategy>> +    _lpValue(_lpTokens: uint256): (value: uint256) <<StableSwapAMMStrategy>> +    _invariant(_x: uint256, _y: uint256): (k: uint256) <<StableSwapAMMStrategy>> +    _solvencyAssert() <<StableSwapAMMStrategy>> +    _getPoolReserves(): (assetReserves: uint256, oTokenReserves: uint256) <<StableSwapAMMStrategy>> +    _approveBase() <<StableSwapAMMStrategy>> +External: +    transferGovernance(_newGovernor: address) <<onlyGovernor>> <<Governable>> +    claimGovernance() <<Governable>> +    collectRewardTokens() <<onlyHarvester, nonReentrant>> <<StableSwapAMMStrategy>> +    setRewardTokenAddresses(_rewardTokenAddresses: address[]) <<onlyGovernor>> <<InitializableAbstractStrategy>> +    getRewardTokenAddresses(): address[] <<InitializableAbstractStrategy>> +    setPTokenAddress(_asset: address, _pToken: address) <<onlyGovernor>> <<InitializableAbstractStrategy>> +    removePToken(_assetIndex: uint256) <<onlyGovernor>> <<InitializableAbstractStrategy>> +    setHarvesterAddress(_harvesterAddress: address) <<onlyGovernor>> <<InitializableAbstractStrategy>> +    safeApproveAllTokens() <<onlyGovernor, nonReentrant>> <<StableSwapAMMStrategy>> +    deposit(_asset: address, _assetAmount: uint256) <<onlyVault, nonReentrant, skimPool, nearBalancedPool>> <<StableSwapAMMStrategy>> +    depositAll() <<onlyVault, nonReentrant, skimPool, nearBalancedPool>> <<StableSwapAMMStrategy>> +    withdraw(_recipient: address, _asset: address, _assetAmount: uint256) <<onlyVault, nonReentrant, skimPool>> <<StableSwapAMMStrategy>> +    withdrawAll() <<onlyVaultOrGovernor, nonReentrant, skimPool>> <<StableSwapAMMStrategy>> +    checkBalance(_asset: address): (balance: uint256) <<StableSwapAMMStrategy>> +    initialize(_rewardTokenAddresses: address[], _maxDepeg: uint256) <<onlyGovernor, initializer>> <<StableSwapAMMStrategy>> +    swapAssetsToPool(_assetAmount: uint256) <<onlyStrategist, nonReentrant, improvePoolBalance, skimPool>> <<StableSwapAMMStrategy>> +    swapOTokensToPool(_oTokenAmount: uint256) <<onlyStrategist, nonReentrant, improvePoolBalance, skimPool>> <<StableSwapAMMStrategy>> +    setMaxDepeg(_maxDepeg: uint256) <<onlyGovernor>> <<StableSwapAMMStrategy>> +Public: +    <<event>> PendingGovernorshipTransfer(previousGovernor: address, newGovernor: address) <<Governable>> +    <<event>> GovernorshipTransferred(previousGovernor: address, newGovernor: address) <<Governable>> +    <<event>> PTokenAdded(_asset: address, _pToken: address) <<InitializableAbstractStrategy>> +    <<event>> PTokenRemoved(_asset: address, _pToken: address) <<InitializableAbstractStrategy>> +    <<event>> Deposit(_asset: address, _pToken: address, _amount: uint256) <<InitializableAbstractStrategy>> +    <<event>> Withdrawal(_asset: address, _pToken: address, _amount: uint256) <<InitializableAbstractStrategy>> +    <<event>> RewardTokenCollected(recipient: address, rewardToken: address, amount: uint256) <<InitializableAbstractStrategy>> +    <<event>> RewardTokenAddressesUpdated(_oldAddresses: address[], _newAddresses: address[]) <<InitializableAbstractStrategy>> +    <<event>> HarvesterAddressesUpdated(_oldHarvesterAddress: address, _newHarvesterAddress: address) <<InitializableAbstractStrategy>> +    <<event>> SwapOTokensToPool(oTokenMinted: uint256, assetDepositAmount: uint256, oTokenDepositAmount: uint256, lpTokens: uint256) <<StableSwapAMMStrategy>> +    <<event>> SwapAssetsToPool(assetSwapped: uint256, lpTokens: uint256, oTokenBurnt: uint256) <<StableSwapAMMStrategy>> +    <<event>> MaxDepegUpdated(maxDepeg: uint256) <<StableSwapAMMStrategy>> +    <<modifier>> initializer() <<Initializable>> +    <<modifier>> onlyGovernor() <<Governable>> +    <<modifier>> nonReentrant() <<Governable>> +    <<modifier>> onlyGovernorOrStrategist() <<InitializableAbstractStrategy>> +    <<modifier>> onlyVault() <<InitializableAbstractStrategy>> +    <<modifier>> onlyHarvester() <<InitializableAbstractStrategy>> +    <<modifier>> onlyVaultOrGovernor() <<InitializableAbstractStrategy>> +    <<modifier>> onlyVaultOrGovernorOrStrategist() <<InitializableAbstractStrategy>> +    <<modifier>> onlyStrategist() <<StableSwapAMMStrategy>> +    <<modifier>> skimPool() <<StableSwapAMMStrategy>> +    <<modifier>> nearBalancedPool() <<StableSwapAMMStrategy>> +    <<modifier>> improvePoolBalance() <<StableSwapAMMStrategy>> +    governor(): address <<Governable>> +    isGovernor(): bool <<Governable>> +    constructor(_config: BaseStrategyConfig) <<InitializableAbstractStrategy>> +    transferToken(_asset: address, _amount: uint256) <<onlyGovernor>> <<InitializableAbstractStrategy>> +    supportsAsset(_asset: address): bool <<StableSwapAMMStrategy>> +    constructor(_baseConfig: BaseStrategyConfig, _gauge: address) <<OETHSupernovaAMOStrategy>> + + + diff --git a/contracts/docs/OETHSupernovaAMOStrategyStorage.svg b/contracts/docs/OETHSupernovaAMOStrategyStorage.svg new file mode 100644 index 0000000000..605400b729 --- /dev/null +++ b/contracts/docs/OETHSupernovaAMOStrategyStorage.svg @@ -0,0 +1,129 @@ + + + + + + +StorageDiagram + + + +3 + +OETHSupernovaAMOStrategy <<Contract>> + +slot + +0 + +1-50 + +51 + +52 + +53 + +54 + +55 + +56 + +57 + +58 + +59-156 + +157 + +type: <inherited contract>.variable (bytes) + +unallocated (30) + +bool: Initializable.initializing (1) + +bool: Initializable.initialized (1) + +uint256[50]: Initializable.______gap (1600) + +unallocated (12) + +address: InitializableAbstractStrategy._deprecated_platformAddress (20) + +unallocated (12) + +address: InitializableAbstractStrategy._deprecated_vaultAddress (20) + +mapping(address=>address): InitializableAbstractStrategy.assetToPToken (32) + +address[]: InitializableAbstractStrategy.assetsMapped (32) + +unallocated (12) + +address: InitializableAbstractStrategy._deprecated_rewardTokenAddress (20) + +uint256: InitializableAbstractStrategy._deprecated_rewardLiquidationThreshold (32) + +unallocated (12) + +address: InitializableAbstractStrategy.harvesterAddress (20) + +address[]: InitializableAbstractStrategy.rewardTokenAddresses (32) + +int256[98]: InitializableAbstractStrategy._reserved (3136) + +uint256: StableSwapAMMStrategy.maxDepeg (32) + + + +1 + +address[]: assetsMapped <<Array>> +0x4a11f94e20a93c79f6ec743a1954ec4fc2c08429ae2122118bf234b2185c81b8 + +offset + +0 + +type: variable (bytes) + +unallocated (12) + +address (20) + + + +3:8->1 + + + + + +2 + +address[]: rewardTokenAddresses <<Array>> +0xa2999d817b6757290b50e8ecf3fa939673403dd35c97de392fdb343b4015ce9e + +offset + +0 + +type: variable (bytes) + +unallocated (12) + +address (20) + + + +3:13->2 + + + + + diff --git a/contracts/docs/generate.sh b/contracts/docs/generate.sh index 924a67b2db..0aa95283f0 100644 --- a/contracts/docs/generate.sh +++ b/contracts/docs/generate.sh @@ -65,6 +65,12 @@ sol2uml .. -v -hv -hf -he -hs -hl -hi -i prettier-plugin-solidity -b BaseCurveAM sol2uml .. -s -d 0 -b BaseCurveAMOStrategy -i prettier-plugin-solidity -o BaseCurveAMOStrategySquashed.svg sol2uml storage .. -c BaseCurveAMOStrategy -i prettier-plugin-solidity -o BaseCurveAMOStrategyStorage.svg --hideExpand ______gap,_reserved,__gap +# contracts/strategies/algebra +sol2uml .. -v -hv -hf -he -hs -hl -hi -i prettier-plugin-solidity -b OETHSupernovaAMOStrategy -o OETHSupernovaAMOStrategyHierarchy.svg +sol2uml .. -v -hv -hf -he -hs -hn -d 2 -i prettier-plugin-solidity -b OETHSupernovaAMOStrategy -o OETHSupernovaAMOStrategyInteractions.svg +sol2uml .. -s -d 0 -b OETHSupernovaAMOStrategy -i prettier-plugin-solidity -o OETHSupernovaAMOStrategySquashed.svg +sol2uml storage .. -c OETHSupernovaAMOStrategy -i prettier-plugin-solidity -o OETHSupernovaAMOStrategyStorage.svg --hideExpand ______gap,_reserved + # contracts/strategies/sonic sol2uml .. -v -hv -hf -he -hs -hl -hi -b SonicStakingStrategy -o SonicStakingStrategyHierarchy.svg sol2uml .. -s -d 0 -b SonicStakingStrategy -o SonicStakingStrategySquashed.svg diff --git a/contracts/test/_fixture-sonic.js b/contracts/test/_fixture-sonic.js index 41ff1b2364..f58567d3ea 100644 --- a/contracts/test/_fixture-sonic.js +++ b/contracts/test/_fixture-sonic.js @@ -1,6 +1,6 @@ const hre = require("hardhat"); const { ethers } = hre; -const { parseUnits } = ethers.utils; +const { parseUnits, formatUnits } = ethers.utils; const mocha = require("mocha"); const hhHelpers = require("@nomicfoundation/hardhat-network-helpers"); @@ -237,7 +237,8 @@ async function swapXAMOFixture( ) { const fixture = await defaultSonicFixture(); - const { oSonic, oSonicVault, rafael, nick, strategist, wS } = fixture; + const { oSonic, oSonicVault, rafael, nick, strategist, governor, wS } = + fixture; let swapXAMOStrategy, swapXPool, swapXGauge, swpx; @@ -271,23 +272,66 @@ async function swapXAMOFixture( // Calculate how much to mint based on the wS in the vault, // the withdrawal queue, and the wS to be sent to the strategy - const wsBalance = await wS.balanceOf(oSonicVault.address); + let wsBalance = await wS.balanceOf(oSonicVault.address); + const autoAllocateThreshold = await oSonicVault.autoAllocateThreshold(); const queue = await oSonicVault.withdrawalQueueMetadata(); const available = wsBalance.add(queue.claimed).sub(queue.queued); const mintAmount = wsAmount.sub(available).mul(10); + log( + `To deposit ${formatUnits(wsAmount)} wS to the strategy, ${formatUnits( + mintAmount + )} wS needs to be minted to the vault` + ); + log( + `Vault has ${formatUnits(wsBalance)} wS in balance and ${formatUnits( + available + )} wS available considering async withdrawals` + ); + if (mintAmount.gt(0)) { + log(`Minting ${formatUnits(mintAmount)} wS to the vault and vault`); + // Approve the Vault to transfer wS await wS.connect(nick).approve(oSonicVault.address, mintAmount); + const disableAutoAllocate = autoAllocateThreshold.lt(mintAmount); + + // disable auto allocate for the next mint + if (disableAutoAllocate) { + log( + `Mint would trigger auto allocate. Disabling auto allocate for the next mint` + ); + await oSonicVault + .connect(governor) + .setAutoAllocateThreshold(mintAmount.add(1)); + } // Mint OS with wS // This will sit in the vault, not the strategy await oSonicVault.connect(nick).mint(mintAmount); + + // revert the auto allocate setting + if (disableAutoAllocate) { + log( + `Setting auto allocate back to the previous value: ${formatUnits( + autoAllocateThreshold + )} wS` + ); + await oSonicVault + .connect(governor) + .setAutoAllocateThreshold(autoAllocateThreshold); + } } - // Add ETH to the Metapool + // Add wS (Wrapped S) to a Sonic SwapX pool if (config?.depositToStrategy) { - // The strategist deposits the WETH to the AMO strategy + wsBalance = await wS.balanceOf(oSonicVault.address); + log( + `Depositing ${formatUnits( + wsAmount + )} wS to the strategy. Vault ${formatUnits(wsBalance)} wS balance` + ); + // The strategist deposits wS (Wrapped S) to the AMO strategy await oSonicVault .connect(strategist) .depositToStrategy(swapXAMOStrategy.address, [wS.address], [wsAmount]); diff --git a/contracts/test/_fixture.js b/contracts/test/_fixture.js index 28aba6a6e8..bd8b7be135 100644 --- a/contracts/test/_fixture.js +++ b/contracts/test/_fixture.js @@ -36,6 +36,7 @@ const merklDistributorAbi = require("./abi/merklDistributor.json"); const curveXChainLiquidityGaugeAbi = require("./abi/curveXChainLiquidityGauge.json"); const curveStableSwapNGAbi = require("./abi/curveStableSwapNG.json"); const { defaultAbiCoder, parseUnits } = require("ethers/lib/utils"); +const { formatUnits } = ethers.utils; const { impersonateAndFund } = require("../utils/signers"); const log = require("../utils/logger")("test:fixtures"); @@ -1263,6 +1264,168 @@ async function instantRebaseVaultFixture(tokenName) { return fixture; } +async function supernovaOETHAMOFixture( + config = { + assetMintAmount: 0, + depositToStrategy: false, + balancePool: false, + poolAddWethAmount: 0, + poolAddOethAmount: 0, + } +) { + const fixture = await defaultFixture(); + const { oeth, oethVault, weth, josh, strategist } = fixture; + + if (!isFork) { + throw new Error("supernovaOETHAMOFixture is only supported on fork tests"); + } + + const cfg = { + assetMintAmount: config?.assetMintAmount || 0, + depositToStrategy: config?.depositToStrategy || false, + balancePool: config?.balancePool || false, + poolAddWethAmount: config?.poolAddWethAmount || 0, + poolAddOethAmount: config?.poolAddOethAmount || 0, + }; + + const cOETHSupernovaAMOProxy = await ethers.getContract( + "OETHSupernovaAMOProxy" + ); + const cOETHSupernovaAMOStrategy = await ethers.getContractAt( + "OETHSupernovaAMOStrategy", + cOETHSupernovaAMOProxy.address + ); + + const supernovaPool = await ethers.getContractAt( + "IPair", + await cOETHSupernovaAMOStrategy.pool() + ); + const supernovaGauge = await ethers.getContractAt( + "IGauge", + await cOETHSupernovaAMOStrategy.gauge() + ); + const supernovaRewardToken = await ethers.getContractAt( + erc20Abi, + addresses.mainnet.supernovaToken + ); + + // Impersonate the OETH Vault to call strategy deposit/withdraw methods directly in tests. + const oethVaultSigner = await impersonateAndFund(oethVault.address); + const oethVaultGovernor = await impersonateAndFund( + await oethVault.governor() + ); + + // Ensure the test actor has enough WETH to mint OETH and manipulate pool balances. + await setERC20TokenBalance(josh.address, weth, oethUnits("1000000000"), hre); + await resetAllowance(weth, josh, oethVault.address); + + // Supernova deployment creates a fresh empty pool, seed it once for AMO tests. + let seedAmount = parseUnits("150"); + if ((await supernovaPool.totalSupply()).lt(seedAmount.mul(2))) { + await oethVault.connect(josh).mint(seedAmount.mul(2)); + await weth.connect(josh).transfer(supernovaPool.address, seedAmount); + await oeth.connect(josh).transfer(supernovaPool.address, seedAmount); + await supernovaPool.connect(josh).mint(josh.address); + } + + // Mint some OETH using WETH if configured. + if (cfg.assetMintAmount > 0) { + const wethAmount = parseUnits(cfg.assetMintAmount.toString()); + await oethVault.connect(josh).rebase(); + await oethVault.connect(josh).allocate(); + + let wethBalance = await weth.balanceOf(oethVault.address); + const autoAllocateThreshold = await oethVault.autoAllocateThreshold(); + const queue = await oethVault.withdrawalQueueMetadata(); + const available = wethBalance.add(queue.claimed).sub(queue.queued); + const mintAmount = wethAmount.sub(available); + + if (mintAmount.gt(0)) { + await weth.connect(josh).approve(oethVault.address, mintAmount); + + const disableAutoAllocate = autoAllocateThreshold.lt(mintAmount); + if (disableAutoAllocate) { + await oethVault + .connect(oethVaultGovernor) + .setAutoAllocateThreshold(mintAmount.add(1)); + } + + // This mints OETH and keeps backing WETH in the vault. + await oethVault.connect(josh).mint(mintAmount); + + if (disableAutoAllocate) { + await oethVault + .connect(oethVaultGovernor) + .setAutoAllocateThreshold(autoAllocateThreshold); + } + } + + if (cfg.depositToStrategy) { + wethBalance = await weth.balanceOf(oethVault.address); + log( + `Depositing ${formatUnits( + wethAmount + )} WETH to Supernova AMO strategy. Vault has ${formatUnits( + wethBalance + )} WETH` + ); + await oethVault + .connect(strategist) + .depositToStrategy( + cOETHSupernovaAMOStrategy.address, + [weth.address], + [wethAmount] + ); + } + } + + if (cfg.balancePool) { + const { _reserve0, _reserve1 } = await supernovaPool.getReserves(); + const oTokenPoolIndex = + (await supernovaPool.token0()) === oeth.address ? 0 : 1; + const assetReserves = oTokenPoolIndex === 0 ? _reserve1 : _reserve0; + const oTokenReserves = oTokenPoolIndex === 0 ? _reserve0 : _reserve1; + + const diff = parseInt( + assetReserves.sub(oTokenReserves).div(oethUnits("1")).toString() + ); + + if (diff > 0) { + cfg.poolAddOethAmount += diff; + } else if (diff < 0) { + cfg.poolAddWethAmount += -diff; + } + } + + // Add WETH to the pool directly. + if (cfg.poolAddWethAmount > 0) { + log(`Adding ${cfg.poolAddWethAmount} WETH to the pool`); + const wethAmount = parseUnits(cfg.poolAddWethAmount.toString(), 18); + await weth.connect(josh).transfer(supernovaPool.address, wethAmount); + } + + // Add OETH to the pool directly. + if (cfg.poolAddOethAmount > 0) { + log(`Adding ${cfg.poolAddOethAmount} OETH to the pool`); + const oethAmount = parseUnits(cfg.poolAddOethAmount.toString(), 18); + await weth.connect(josh).approve(oethVault.address, oethAmount); + await oethVault.connect(josh).mint(oethAmount); + await oeth.connect(josh).transfer(supernovaPool.address, oethAmount); + } + + // Force reserves to match balances. + await supernovaPool.sync(); + + return { + ...fixture, + oethVaultSigner, + supernovaRewardToken, + supernovaPool, + supernovaGauge, + supernovaAMOStrategy: cOETHSupernovaAMOStrategy, + }; +} + // Unit test cross chain fixture where both contracts are deployed on the same chain for the // purposes of unit testing async function crossChainFixtureUnit() { @@ -1641,4 +1804,5 @@ module.exports = { autoWithdrawalModuleFixture, crossChainFixtureUnit, crossChainFixture, + supernovaOETHAMOFixture, }; diff --git a/contracts/test/behaviour/algebraAmoStrategy.js b/contracts/test/behaviour/algebraAmoStrategy.js new file mode 100644 index 0000000000..65bb5756e2 --- /dev/null +++ b/contracts/test/behaviour/algebraAmoStrategy.js @@ -0,0 +1,2137 @@ +const { expect } = require("chai"); +const { formatUnits, parseUnits } = require("ethers/lib/utils"); +const { impersonateAndFund } = require("../../utils/signers"); +const addresses = require("../../utils/addresses"); +const { setERC20TokenBalance } = require("../_fund"); +const { isCI } = require("../helpers"); + +const log = require("../../utils/logger")("test:fork:algebra:amo"); + +const defaultScenarioConfig = { + attackerFrontRun: { + moderateAssetIn: "20000", + largeAssetIn: "10000000", + largeOTokenIn: "10000000", + }, + poolImbalance: { + lotMoreOToken: { addOToken: 1000000 }, + littleMoreOToken: { addOToken: 5000 }, + lotMoreAsset: { addAsset: 2000000 }, + littleMoreAsset: { addAsset: 20000 }, + }, + bootstrapPool: { + smallAssetBootstrapIn: "5000", + mediumAssetBootstrapIn: "20000", + largeAssetBootstrapIn: "5000000", + }, + mintValues: { + extraSmall: "0.1", + extraSmallPlus: "0.2", + small: "1", + medium: "2", + }, + smallPoolShare: { + bootstrapAssetSwapIn: "10000000", + bigLiquidityAsset: "1000000", + oTokenBuffer: "2000000", + stressSwapOToken: "1005000", + stressSwapAsset: "2000000", + stressSwapAssetAlt: "1006000", + }, + rebalanceProbe: { + frontRun: { + depositAmount: "200000", + failedDepositAmount: "5000", + failedDepositAllAmount: "5000", + assetTiltWithdrawAmount: "4000", + oTokenTiltWithdrawAmount: "200", + }, + lotMoreOToken: { + failedDepositAmount: "5000", + partialWithdrawAmount: "4000", + smallSwapAssetsToPool: "3", + largeSwapAssetsToPool: "3000", + nearMaxSwapAssetsToPool: "4400", + excessiveSwapAssetsToPool: "2000000", + disallowedSwapOTokensToPool: "0.001", + }, + littleMoreOToken: { + depositAmount: "12000", + partialWithdrawAmount: "1000", + smallSwapAssetsToPool: "3", + excessiveSwapAssetsToPool: "5000", + disallowedSwapOTokensToPool: "0.001", + }, + lotMoreAsset: { + failedDepositAmount: "6000", + partialWithdrawAmount: "1000", + smallSwapOTokensToPool: "0.3", + largeSwapOTokensToPool: "5000", + overshootSwapOTokensToPool: "999990", + disallowedSwapAssetsToPool: "0.0001", + }, + littleMoreAsset: { + depositAmount: "18000", + partialWithdrawAmount: "1000", + smallSwapOTokensToPool: "8", + overshootSwapOTokensToPool: "11000", + disallowedSwapAssetsToPool: "0.0001", + }, + }, + insolvent: { + swapOTokensToPool: "10", + }, + harvest: { + collectedBy: "strategist", // "strategist" or "harvester" + }, +}; + +const mergeScenarioConfig = (contextConfig = {}, fixtureConfig = {}) => ({ + attackerFrontRun: { + ...defaultScenarioConfig.attackerFrontRun, + ...(contextConfig.attackerFrontRun || {}), + ...(fixtureConfig.attackerFrontRun || {}), + }, + bootstrapPool: { + ...defaultScenarioConfig.bootstrapPool, + ...(contextConfig.bootstrapPool || {}), + ...(fixtureConfig.bootstrapPool || {}), + }, + mintValues: { + ...defaultScenarioConfig.mintValues, + ...(contextConfig.mintValues || {}), + ...(fixtureConfig.mintValues || {}), + }, + poolImbalance: { + lotMoreOToken: { + ...defaultScenarioConfig.poolImbalance.lotMoreOToken, + ...(contextConfig.poolImbalance?.lotMoreOToken || {}), + ...(fixtureConfig.poolImbalance?.lotMoreOToken || {}), + }, + littleMoreOToken: { + ...defaultScenarioConfig.poolImbalance.littleMoreOToken, + ...(contextConfig.poolImbalance?.littleMoreOToken || {}), + ...(fixtureConfig.poolImbalance?.littleMoreOToken || {}), + }, + lotMoreAsset: { + ...defaultScenarioConfig.poolImbalance.lotMoreAsset, + ...(contextConfig.poolImbalance?.lotMoreAsset || {}), + ...(fixtureConfig.poolImbalance?.lotMoreAsset || {}), + }, + littleMoreAsset: { + ...defaultScenarioConfig.poolImbalance.littleMoreAsset, + ...(contextConfig.poolImbalance?.littleMoreAsset || {}), + ...(fixtureConfig.poolImbalance?.littleMoreAsset || {}), + }, + }, + smallPoolShare: { + ...defaultScenarioConfig.smallPoolShare, + ...(contextConfig.smallPoolShare || {}), + ...(fixtureConfig.smallPoolShare || {}), + }, + rebalanceProbe: { + frontRun: { + ...defaultScenarioConfig.rebalanceProbe.frontRun, + ...(contextConfig.rebalanceProbe?.frontRun || {}), + ...(fixtureConfig.rebalanceProbe?.frontRun || {}), + }, + lotMoreOToken: { + ...defaultScenarioConfig.rebalanceProbe.lotMoreOToken, + ...(contextConfig.rebalanceProbe?.lotMoreOToken || {}), + ...(fixtureConfig.rebalanceProbe?.lotMoreOToken || {}), + }, + littleMoreOToken: { + ...defaultScenarioConfig.rebalanceProbe.littleMoreOToken, + ...(contextConfig.rebalanceProbe?.littleMoreOToken || {}), + ...(fixtureConfig.rebalanceProbe?.littleMoreOToken || {}), + }, + lotMoreAsset: { + ...defaultScenarioConfig.rebalanceProbe.lotMoreAsset, + ...(contextConfig.rebalanceProbe?.lotMoreAsset || {}), + ...(fixtureConfig.rebalanceProbe?.lotMoreAsset || {}), + }, + littleMoreAsset: { + ...defaultScenarioConfig.rebalanceProbe.littleMoreAsset, + ...(contextConfig.rebalanceProbe?.littleMoreAsset || {}), + ...(fixtureConfig.rebalanceProbe?.littleMoreAsset || {}), + }, + }, + insolvent: { + ...defaultScenarioConfig.insolvent, + ...(contextConfig.insolvent || {}), + ...(fixtureConfig.insolvent || {}), + }, + harvest: { + ...defaultScenarioConfig.harvest, + ...(contextConfig.harvest || {}), + ...(fixtureConfig.harvest || {}), + }, + fixtureSetup: { + ...defaultScenarioConfig.fixtureSetup, + ...(contextConfig.fixtureSetup || {}), + ...(fixtureConfig.fixtureSetup || {}), + }, +}); + +const toUnitAmount = (value) => + value && value._isBigNumber ? value : parseUnits(value.toString()); +/** + * + * @param {*} context a function that returns a fixture with the additional properties: + * - strategy: the strategy to test + * @example + shouldBehaveLikeAlgebraAmoStrategy(() => ({ + assetToken: addresses.sonic.wS, // address of the asset token in the pool + oToken: addresses.sonic.os, // address of the oToken in the pool + rewardToken: fixture.swpx, // address of the reward token + amoStrategy: strategy contract, // address of the strategy + pool: pool contract + gauge: addresses.sonic.SwapXWSOS.gauge, // address of the gauge + governor: addresses.sonic.timelock, // address of the governor + timelock: addresses.sonic.timelock, // address of the timelock + strategist: addresses.sonic.strategist, // address of the strategist + nick: addresses.sonic.nick, // nick's address + oTokenPoolIndex, // index of the oToken in the pool + vaultSigner: fixture.oSonicVaultSigner, // address of the vault signer + vault: fixture.oSonicVault, // address of the vault + harvester: fixture.harvester, // address of the harvester + scenarioConfig // verious hardcoded values for the test + })); + */ +const shouldBehaveLikeAlgebraAmoStrategy = (contextFunction) => { + describe("ForkTest: Algebra AMO Strategy", async function () { + // Retry up to 3 times on CI + this.retries(isCI ? 3 : 0); + let fixture, context; + const getScenarioConfig = () => + mergeScenarioConfig(context?.scenarioConfig, fixture?.scenarioConfig); + describe("post deployment", () => { + beforeEach(async () => { + context = await contextFunction(); + fixture = await context.loadFixture(); + }); + it("Should have constants and immutables set", async () => { + const { amoStrategy, assetToken, oToken, pool, gauge, governor } = + fixture; + + expect(await amoStrategy.SOLVENCY_THRESHOLD()).to.equal( + parseUnits("0.998", 18) + ); + expect(await amoStrategy.asset()).to.equal(assetToken.address); + expect(await amoStrategy.oToken()).to.equal(oToken.address); + expect(await amoStrategy.pool()).to.equal(pool.address); + expect(await amoStrategy.gauge()).to.equal(gauge.address); + expect(await amoStrategy.governor()).to.equal(governor.address); + expect(await amoStrategy.supportsAsset(assetToken.address)).to.true; + expect(await amoStrategy.maxDepeg()).to.equal(parseUnits("0.01")); + }); + it("Should be able to check balance", async () => { + const { assetToken, nick, amoStrategy } = fixture; + + const balance = await amoStrategy.checkBalance(assetToken.address); + log(`check balance ${balance}`); + expect(balance).gte(0); + + // This uses a transaction to call a view function so the gas usage can be reported. + const tx = await amoStrategy + .connect(nick) + .populateTransaction.checkBalance(assetToken.address); + await nick.sendTransaction(tx); + }); + it("Only Governor can approve all tokens", async () => { + const { timelock, strategist, nick, vaultSigner, amoStrategy, pool } = + fixture; + + expect(await amoStrategy.connect(timelock).isGovernor()).to.equal(true); + + // Timelock can approve all tokens + const tx = await amoStrategy.connect(timelock).safeApproveAllTokens(); + await expect(tx).to.emit(pool, "Approval"); + + for (const signer of [strategist, nick, vaultSigner]) { + const tx = amoStrategy.connect(signer).safeApproveAllTokens(); + await expect(tx).to.be.revertedWith("Caller is not the Governor"); + } + }); + it("Only Governor can set the max depeg", async () => { + const { timelock, strategist, nick, vaultSigner, amoStrategy } = + fixture; + + expect(await amoStrategy.connect(timelock).isGovernor()).to.equal(true); + + // Timelock can update + const newMaxDepeg = parseUnits("0.02"); + const tx = await amoStrategy.connect(timelock).setMaxDepeg(newMaxDepeg); + await expect(tx) + .to.emit(amoStrategy, "MaxDepegUpdated") + .withArgs(newMaxDepeg); + + expect(await amoStrategy.maxDepeg()).to.equal(newMaxDepeg); + + for (const signer of [strategist, nick, vaultSigner]) { + const tx = amoStrategy.connect(signer).setMaxDepeg(newMaxDepeg); + await expect(tx).to.be.revertedWith("Caller is not the Governor"); + } + }); + it("Governor should fail to set max depeg too small (1bp)", async () => { + const { timelock, amoStrategy } = fixture; + + const tx = amoStrategy + .connect(timelock) + .setMaxDepeg(parseUnits("0.0001")); + await expect(tx).to.be.revertedWith("Invalid max depeg range"); + }); + it("Governor should fail to set max depeg too large (1100bp)", async () => { + const { timelock, amoStrategy } = fixture; + + const tx = amoStrategy + .connect(timelock) + .setMaxDepeg(parseUnits("0.11")); + await expect(tx).to.be.revertedWith("Invalid max depeg range"); + }); + }); + + describe("with asset token in the vault", () => { + beforeEach(async () => { + context = await contextFunction(); + fixture = await context.loadFixture({ + assetMintAmount: + getScenarioConfig().bootstrapPool.largeAssetBootstrapIn, + depositToStrategy: false, + balancePool: true, + }); + }); + it("Vault should deposit asset token to AMO strategy", async function () { + await assertDeposit(toUnitAmount(getScenarioConfig().mintValues.small)); + }); + it("Only vault can deposit asset token to AMO strategy", async function () { + const { + amoStrategy, + vaultSigner, + strategist, + timelock, + nick, + assetToken, + } = fixture; + + const depositAmount = toUnitAmount( + getScenarioConfig().mintValues.extraSmall + ); + await assetToken + .connect(vaultSigner) + .transfer(amoStrategy.address, depositAmount); + + for (const signer of [strategist, timelock, nick]) { + const tx = amoStrategy + .connect(signer) + .deposit(assetToken.address, depositAmount); + + await expect(tx).to.revertedWith("Caller is not the Vault"); + } + }); + it("Only vault can deposit all asset tokens to AMO strategy", async function () { + const { + amoStrategy, + pool, + vaultSigner, + strategist, + timelock, + nick, + assetToken, + } = fixture; + + const depositAmount = toUnitAmount( + getScenarioConfig().mintValues.extraSmall + ); + await assetToken + .connect(vaultSigner) + .transfer(amoStrategy.address, depositAmount); + + for (const signer of [strategist, timelock, nick]) { + const tx = amoStrategy.connect(signer).depositAll(); + + await expect(tx).to.revertedWith("Caller is not the Vault"); + } + + const tx = await amoStrategy.connect(vaultSigner).depositAll(); + await expect(tx) + .to.emit(amoStrategy, "Deposit") + .withNamedArgs({ _asset: assetToken.address, _pToken: pool.address }); + }); + }); + + describe("with the strategy having OToken and asset token in a balanced pool", () => { + beforeEach(async () => { + context = await contextFunction(); + fixture = await context.loadFixture({ + assetMintAmount: + getScenarioConfig().bootstrapPool.largeAssetBootstrapIn, + depositToStrategy: true, + balancePool: true, + }); + }); + it("Vault should deposit asset token", async function () { + await assertDeposit( + toUnitAmount(getScenarioConfig().mintValues.medium) + ); + }); + it("Vault should be able to withdraw all", async () => { + await assertWithdrawAll(); + }); + it("Vault should be able to withdraw all in SwapX Emergency", async () => { + const { amoStrategy, gauge, vaultSigner } = fixture; + + const gaugeOwner = await gauge.owner(); + const ownerSigner = await impersonateAndFund(gaugeOwner); + await gauge.connect(ownerSigner).activateEmergencyMode(); + await assertWithdrawAll(); + + // Try again when the strategy is empty + await amoStrategy.connect(vaultSigner).withdrawAll(); + }); + it("Should fail to deposit zero asset token", async () => { + const { amoStrategy, vaultSigner, assetToken } = fixture; + + const tx = amoStrategy + .connect(vaultSigner) + .deposit(assetToken.address, 0); + + await expect(tx).to.be.revertedWith("Must deposit something"); + }); + it("Should fail to deposit oToken", async () => { + const { amoStrategy, vaultSigner, oToken } = fixture; + + const tx = amoStrategy + .connect(vaultSigner) + .deposit(oToken.address, parseUnits("1")); + + await expect(tx).to.be.revertedWith("Unsupported asset"); + }); + it("Should fail to withdraw zero asset token", async () => { + const { amoStrategy, vaultSigner, vault, assetToken } = fixture; + + const tx = amoStrategy + .connect(vaultSigner) + .withdraw(vault.address, assetToken.address, 0); + + await expect(tx).to.be.revertedWith("Must withdraw something"); + }); + it("Should fail to withdraw oToken", async () => { + const { amoStrategy, vaultSigner, oToken, vault } = fixture; + + const tx = amoStrategy + .connect(vaultSigner) + .withdraw(vault.address, oToken.address, parseUnits("1")); + + await expect(tx).to.be.revertedWith("Unsupported asset"); + }); + it("Should fail to withdraw to a user", async () => { + const { amoStrategy, vaultSigner, assetToken, nick } = fixture; + + const tx = amoStrategy + .connect(vaultSigner) + .withdraw(nick.address, assetToken.address, parseUnits("1")); + + await expect(tx).to.be.revertedWith("Only withdraw to vault allowed"); + }); + it("Vault should be able to withdraw all from empty strategy", async () => { + const { amoStrategy, vaultSigner } = fixture; + await assertWithdrawAll(); + + // Now try again after all the assets have already been withdrawn + const tx = await amoStrategy.connect(vaultSigner).withdrawAll(); + + // Check emitted events + await expect(tx).to.not.emit(amoStrategy, "Withdrawal"); + }); + it("Vault should be able to partially withdraw", async () => { + await assertWithdrawPartial( + toUnitAmount(getScenarioConfig().mintValues.small) + ); + }); + it("Only vault can withdraw asset token from AMO strategy", async function () { + const { amoStrategy, vault, strategist, timelock, nick, assetToken } = + fixture; + + for (const signer of [strategist, timelock, nick]) { + const tx = amoStrategy + .connect(signer) + .withdraw( + vault.address, + assetToken.address, + toUnitAmount(getScenarioConfig().mintValues.extraSmall) + ); + + await expect(tx).to.revertedWith("Caller is not the Vault"); + } + }); + it("Only vault and governor can withdraw all from AMO strategy", async function () { + const { amoStrategy, strategist, timelock, nick } = fixture; + + for (const signer of [strategist, nick]) { + const tx = amoStrategy.connect(signer).withdrawAll(); + + await expect(tx).to.revertedWith( + "Caller is not the Vault or Governor" + ); + } + + // Governor can withdraw all + const tx = amoStrategy.connect(timelock).withdrawAll(); + await expect(tx).to.emit(amoStrategy, "Withdrawal"); + }); + it("Harvester can collect rewards", async function () { + const { harvester, nick, amoStrategy, gauge, rewardToken, strategist } = + fixture; + + const rewardTokenBalanceBefore = await rewardToken.balanceOf( + strategist.address + ); + + // Send some SWPx rewards to the gauge + const distributorAddress = await gauge.DISTRIBUTION(); + const distributorSigner = await impersonateAndFund(distributorAddress); + const rewardAmount = toUnitAmount(getScenarioConfig().mintValues.small); + await setERC20TokenBalance( + distributorAddress, + rewardToken, + rewardAmount + ); + await gauge + .connect(distributorSigner) + .notifyRewardAmount(rewardToken.address, rewardAmount); + + // Harvest the rewards + let tx; + if (getScenarioConfig().harvest.collectedBy === "strategist") { + tx = await amoStrategy.connect(strategist).collectRewardTokens(); + } else if (getScenarioConfig().harvest.collectedBy === "harvester") { + // prettier-ignore + tx = await harvester + .connect(nick)["harvestAndTransfer(address)"](amoStrategy.address); + } else { + throw new Error( + `Invalid collectedBy value: ${ + getScenarioConfig().harvest.collectedBy + }` + ); + } + + await expect(tx).to.emit(amoStrategy, "RewardTokenCollected"); + + const rewardTokenBalanceAfter = await rewardToken.balanceOf( + strategist.address + ); + log( + `Rewards collected ${formatUnits( + rewardTokenBalanceAfter.sub(rewardTokenBalanceBefore) + )}` + ); + expect(rewardTokenBalanceAfter).to.gt(rewardTokenBalanceBefore); + }); + it("Attacker front-run deposit within range by adding asset token to the pool", async function () { + const { nick, oToken, vaultSigner, amoStrategy, assetToken } = fixture; + + const attackerAssetTokenBalanceBefore = await assetToken.balanceOf( + nick.address + ); + const assetTokenAmountIn = toUnitAmount( + getScenarioConfig().attackerFrontRun.moderateAssetIn + ); + + const dataBeforeSwap = await snapData(); + logSnapData( + dataBeforeSwap, + `\nBefore attacker swaps ${formatUnits( + assetTokenAmountIn + )} asset token into the pool for OToken` + ); + + // Attacker swaps a lot of asset token for OToken in the pool + // This drops the pool's asset token/OToken price and increases the OToken/asset token price + const oTokenAmountOut = await poolSwapTokensIn( + assetToken, + assetTokenAmountIn + ); + + const depositAmount = toUnitAmount( + getScenarioConfig().rebalanceProbe.frontRun.depositAmount + ); + + const dataBeforeDeposit = await snapData(); + logSnapData( + dataBeforeDeposit, + `\nAfter attacker tilted pool and before strategist deposits ${formatUnits( + depositAmount + )} asset token` + ); + + // Vault deposits wS to the strategy + await ensureVaultHasAssets(depositAmount); + await assetToken + .connect(vaultSigner) + .transfer(amoStrategy.address, depositAmount); + await amoStrategy + .connect(vaultSigner) + .deposit(assetToken.address, depositAmount); + + const dataAfterDeposit = await snapData(); + logSnapData( + dataAfterDeposit, + `\nAfter deposit of ${formatUnits( + depositAmount + )} asset token to strategy and before attacker swaps ${formatUnits( + oTokenAmountOut + )} OToken back into the pool for asset token` + ); + await logProfit(dataBeforeSwap); + + // Attacker swaps the OS back for wS + await poolSwapTokensIn(oToken, oTokenAmountOut); + + const dataAfterFinalSwap = await snapData(); + logSnapData( + dataAfterFinalSwap, + `\nAfter attacker swaps ${formatUnits( + oTokenAmountOut + )} OToken back into the pool for asset token` + ); + await logProfit(dataBeforeSwap); + + const attackerWsBalanceAfter = await assetToken.balanceOf(nick.address); + log( + `Attacker's profit ${formatUnits( + attackerWsBalanceAfter.sub(attackerAssetTokenBalanceBefore) + )} asset token` + ); + }); + + describe("When attacker front-run by adding a lot of asset token to the pool", () => { + let attackerAssetBalanceBefore; + let dataBeforeSwap; + let oTokenAmountOut; + beforeEach(async function () { + context = await contextFunction(); + fixture = await context.loadFixture({ + assetMintAmount: + getScenarioConfig().rebalanceProbe.frontRun + .tiltSeedWithdrawAmount, + depositToStrategy: true, + }); + const { nick, assetToken } = fixture; + + attackerAssetBalanceBefore = await assetToken.balanceOf(nick.address); + const assetTokenAmountIn = toUnitAmount( + getScenarioConfig().attackerFrontRun.largeAssetIn + ); + + dataBeforeSwap = await snapData(); + logSnapData( + dataBeforeSwap, + `\nBefore attacker swaps ${formatUnits( + assetTokenAmountIn + )} asset token into the pool for OToken` + ); + + // Attacker swaps a lot of asset token for OToken in the pool + // This drops the pool's asset token/OToken price and increases the OToken/asset token price + oTokenAmountOut = await poolSwapTokensIn( + assetToken, + assetTokenAmountIn + ); + }); + it("Strategist fails to deposit to strategy", async () => { + await assertFailedDeposit( + toUnitAmount( + getScenarioConfig().rebalanceProbe.frontRun.failedDepositAmount + ), + "price out of range" + ); + }); + it("Strategist fails to deposit all to strategy", async () => { + await assertFailedDepositAll( + toUnitAmount( + getScenarioConfig().rebalanceProbe.frontRun.failedDepositAllAmount + ), + "price out of range" + ); + }); + it("Strategist should withdraw from strategy with a profit", async () => { + const { nick, oToken, vault, vaultSigner, amoStrategy, assetToken } = + fixture; + const withdrawAmount = toUnitAmount( + getScenarioConfig().rebalanceProbe.frontRun.assetTiltWithdrawAmount + ); + + const dataBeforeWithdraw = await snapData(); + logSnapData( + dataBeforeWithdraw, + `\nBefore strategist withdraw ${formatUnits( + withdrawAmount + )} asset token` + ); + + const tx = await amoStrategy + .connect(vaultSigner) + .withdraw(vault.address, assetToken.address, withdrawAmount); + + const dataAfterWithdraw = await snapData(); + logSnapData( + dataAfterWithdraw, + `\nAfter withdraw and before attacker swaps ${formatUnits( + oTokenAmountOut + )} OToken back into the pool for asset token` + ); + await logProfit(dataBeforeSwap); + + // Get how much OS was burnt + const receipt = await tx.wait(); + const redeemEvent = receipt.events.find( + (e) => e.event === "Withdrawal" && e.args._asset === oToken.address + ); + log(`\nWithdraw burnt ${formatUnits(redeemEvent.args._amount)} OS`); + + // Attacker swaps the OS back for wS + await poolSwapTokensIn(oToken, oTokenAmountOut); + + const dataAfterFinalSwap = await snapData(); + logSnapData( + dataAfterFinalSwap, + "\nAfter attacker swaps OToken into the pool for the asset token" + ); + const profit = await logProfit(dataBeforeSwap); + expect(profit, "vault profit").to.gt(0); + + const attackerWsBalanceAfter = await assetToken.balanceOf( + nick.address + ); + log( + `Attacker's profit/loss ${formatUnits( + attackerWsBalanceAfter.sub(attackerAssetBalanceBefore) + )} wS` + ); + }); + }); + describe("When attacker front-run by adding a lot of OToken to the pool", () => { + const attackerBalanceBefore = {}; + let dataBeforeSwap; + let assetAmountOut; + beforeEach(async function () { + context = await contextFunction(); + fixture = await context.loadFixture({ + assetMintAmount: + getScenarioConfig().rebalanceProbe.frontRun + .tiltSeedWithdrawAmount, + depositToStrategy: true, + }); + const { nick, oToken, vault, assetToken } = fixture; + + const oTokenAmountIn = toUnitAmount( + getScenarioConfig().attackerFrontRun.largeOTokenIn + ); + + // Mint OToken using asset token + await assetToken.connect(nick).approve(vault.address, oTokenAmountIn); + await vault.connect(nick).mint(oTokenAmountIn); + + attackerBalanceBefore.oToken = await oToken.balanceOf(nick.address); + attackerBalanceBefore.assetToken = await assetToken.balanceOf( + nick.address + ); + + dataBeforeSwap = await snapData(); + logSnapData( + dataBeforeSwap, + `\nBefore attacker swaps ${formatUnits( + oTokenAmountIn + )} OToken into the pool for asset token` + ); + + // Attacker swaps a lot of OToken for the asset token in the pool + // This increases the pool's asset/OToken price and decreases the OToken/asset price + assetAmountOut = await poolSwapTokensIn(oToken, oTokenAmountIn); + }); + it("Strategist fails to deposit to strategy", async () => { + await assertFailedDeposit( + toUnitAmount( + getScenarioConfig().rebalanceProbe.frontRun.failedDepositAmount + ), + "price out of range" + ); + }); + it("Strategist fails to deposit all to strategy", async () => { + await assertFailedDepositAll( + toUnitAmount( + getScenarioConfig().rebalanceProbe.frontRun.failedDepositAllAmount + ), + "price out of range" + ); + }); + it("Strategist should withdraw from strategy with a profit", async () => { + const { nick, oToken, vault, vaultSigner, amoStrategy, assetToken } = + fixture; + const withdrawAmount = toUnitAmount( + getScenarioConfig().rebalanceProbe.frontRun.oTokenTiltWithdrawAmount + ); + + const dataBeforeWithdraw = await snapData(); + logSnapData( + dataBeforeWithdraw, + `\nBefore strategist withdraw ${formatUnits( + withdrawAmount + )} asset token` + ); + + const tx = await amoStrategy + .connect(vaultSigner) + .withdraw(vault.address, assetToken.address, withdrawAmount); + + const dataAfterWithdraw = await snapData(); + logSnapData( + dataAfterWithdraw, + `\nAfter withdraw and before attacker swaps ${formatUnits( + assetAmountOut + )} asset token back into the pool for OToken` + ); + await logProfit(dataBeforeSwap); + + // Get how much OS was burnt + const receipt = await tx.wait(); + const redeemEvent = receipt.events.find( + (e) => e.event === "Withdrawal" && e.args._asset === oToken.address + ); + log(`\nWithdraw burnt ${formatUnits(redeemEvent.args._amount)} OS`); + + // Attacker swaps the asset token back for OToken + await poolSwapTokensIn(assetToken, assetAmountOut); + + const dataAfterFinalSwap = await snapData(); + logSnapData( + dataAfterFinalSwap, + "\nAfter attacker swaps asset token into the pool for OToken" + ); + const profit = await logProfit(dataBeforeSwap); + expect(profit, "vault profit").to.gt(0); + + const attackerBalanceAfter = {}; + attackerBalanceAfter.oToken = await oToken.balanceOf(nick.address); + attackerBalanceAfter.assetToken = await assetToken.balanceOf( + nick.address + ); + log( + `Attacker's profit/loss ${formatUnits( + attackerBalanceAfter.oToken.sub(attackerBalanceBefore.oToken) + )} OToken and ${formatUnits( + attackerBalanceAfter.assetToken.sub( + attackerBalanceBefore.assetToken + ) + )} asset token` + ); + }); + }); + }); + + describe("with a lot more OToken in the pool", () => { + beforeEach(async function () { + context = await contextFunction(); + fixture = await context.loadFixture({ + assetMintAmount: + getScenarioConfig().bootstrapPool.smallAssetBootstrapIn, + depositToStrategy: true, + balancePool: true, + poolAddOTokenAmount: + getScenarioConfig().poolImbalance.lotMoreOToken.addOToken, + }); + }); + it("Vault should fail to deposit asset token to AMO strategy", async function () { + await assertFailedDeposit( + toUnitAmount( + getScenarioConfig().rebalanceProbe.lotMoreOToken.failedDepositAmount + ), + "price out of range" + ); + }); + it("Vault should be able to withdraw all", async () => { + await assertWithdrawAll(); + }); + it("Vault should be able to partially withdraw", async () => { + await assertWithdrawPartial( + toUnitAmount( + getScenarioConfig().rebalanceProbe.lotMoreOToken + .partialWithdrawAmount + ) + ); + }); + it("Strategist should swap a little assets to the pool", async () => { + await assertSwapAssetsToPool( + toUnitAmount( + getScenarioConfig().rebalanceProbe.lotMoreOToken + .smallSwapAssetsToPool + ) + ); + }); + it("Strategist should swap enough asset token to get the pool close to balanced", async () => { + const { assetReserves, oTokenReserves } = await getPoolReserves(); + // 5% of the extra oToken in the pool + const oTokenAmount = oTokenReserves.sub(assetReserves).mul(5).div(100); + const assetAmount = oTokenAmount.mul(assetReserves).div(oTokenReserves); + log(`oToken amount: ${formatUnits(oTokenAmount)}`); + log(`asset amount: ${formatUnits(assetAmount)}`); + + await assertSwapAssetsToPool(assetAmount); + }); + it("Strategist should swap a lot of assets to the pool", async () => { + await assertSwapAssetsToPool( + toUnitAmount( + getScenarioConfig().rebalanceProbe.lotMoreOToken + .largeSwapAssetsToPool + ) + ); + }); + it("Strategist should swap most of the asset token owned by the strategy", async () => { + // TODO calculate how much asset token should be swapped to get the pool balanced + await assertSwapAssetsToPool( + toUnitAmount( + getScenarioConfig().rebalanceProbe.lotMoreOToken + .nearMaxSwapAssetsToPool + ) + ); + }); + it("Strategist should fail to add more asset token than owned by the strategy", async () => { + const { amoStrategy, strategist } = fixture; + + const tx = amoStrategy + .connect(strategist) + .swapAssetsToPool( + toUnitAmount( + getScenarioConfig().rebalanceProbe.lotMoreOToken + .excessiveSwapAssetsToPool + ) + ); + + await expect(tx).to.be.revertedWith("Not enough LP tokens in gauge"); + }); + it("Strategist should fail to add more OToken to the pool", async () => { + const { amoStrategy, strategist } = fixture; + + // Try swapping OToken into the pool. + const tx = amoStrategy + .connect(strategist) + .swapOTokensToPool( + toUnitAmount( + getScenarioConfig().rebalanceProbe.lotMoreOToken + .disallowedSwapOTokensToPool + ) + ); + + await expect(tx).to.be.revertedWith("OTokens balance worse"); + }); + }); + + describe("with a little more OToken in the pool", () => { + beforeEach(async function () { + context = await contextFunction(); + fixture = await context.loadFixture({ + assetMintAmount: + getScenarioConfig().bootstrapPool.mediumAssetBootstrapIn, + depositToStrategy: true, + balancePool: true, + poolAddOTokenAmount: + getScenarioConfig().poolImbalance.littleMoreOToken.addOToken, + }); + }); + it("Vault should deposit asset token to AMO strategy", async function () { + await assertDeposit( + toUnitAmount( + getScenarioConfig().rebalanceProbe.littleMoreOToken.depositAmount + ) + ); + }); + it("Vault should be able to withdraw all", async () => { + await assertWithdrawAll(); + }); + it("Vault should be able to partially withdraw", async () => { + await assertWithdrawPartial( + toUnitAmount( + getScenarioConfig().rebalanceProbe.littleMoreOToken + .partialWithdrawAmount + ) + ); + }); + it("Strategist should swap a little assets to the pool", async () => { + await assertSwapAssetsToPool( + toUnitAmount( + getScenarioConfig().rebalanceProbe.littleMoreOToken + .smallSwapAssetsToPool + ) + ); + }); + it("Strategist should swap enough asset token to get the pool close to balanced", async () => { + const { assetReserves, oTokenReserves } = await getPoolReserves(); + // 50% of the extra OToken in the pool gets close to balanced + const oTokenAmount = oTokenReserves.sub(assetReserves).mul(50).div(100); + const assetAmount = oTokenAmount.mul(assetReserves).div(oTokenReserves); + + await assertSwapAssetsToPool(assetAmount); + }); + it("Strategist should fail to add too much asset token to the pool", async () => { + const { amoStrategy, strategist } = fixture; + + const dataBefore = await snapData(); + await logSnapData(dataBefore, "Before swapping assets to the pool"); + + // Try swapping too much asset token in. + const tx = amoStrategy + .connect(strategist) + .swapAssetsToPool( + toUnitAmount( + getScenarioConfig().rebalanceProbe.littleMoreOToken + .excessiveSwapAssetsToPool + ) + ); + + await expect(tx).to.be.revertedWith("Assets overshot peg"); + }); + it("Strategist should fail to add zero asset token to the pool", async () => { + const { amoStrategy, strategist } = fixture; + + const tx = amoStrategy.connect(strategist).swapAssetsToPool(0); + + await expect(tx).to.be.revertedWith("Must swap something"); + }); + it("Strategist should fail to add more OToken to the pool", async () => { + const { amoStrategy, strategist } = fixture; + + const tx = amoStrategy + .connect(strategist) + .swapOTokensToPool( + toUnitAmount( + getScenarioConfig().rebalanceProbe.littleMoreOToken + .disallowedSwapOTokensToPool + ) + ); + + await expect(tx).to.be.revertedWith("OTokens balance worse"); + }); + }); + + describe("with a lot more asset token in the pool", () => { + beforeEach(async function () { + context = await contextFunction(); + fixture = await context.loadFixture({ + assetMintAmount: + getScenarioConfig().bootstrapPool.smallAssetBootstrapIn, + depositToStrategy: true, + balancePool: true, + poolAddAssetAmount: + getScenarioConfig().poolImbalance.lotMoreAsset.addAsset, + }); + }); + it("Vault should fail to deposit asset token to strategy", async function () { + await assertFailedDeposit( + toUnitAmount( + getScenarioConfig().rebalanceProbe.lotMoreAsset.failedDepositAmount + ), + "price out of range" + ); + }); + it("Vault should be able to withdraw all", async () => { + await assertWithdrawAll(); + }); + it("Vault should be able to partially withdraw", async () => { + await assertWithdrawPartial( + toUnitAmount( + getScenarioConfig().rebalanceProbe.lotMoreAsset + .partialWithdrawAmount + ) + ); + }); + it("Strategist should swap a little OToken to the pool", async () => { + await assertSwapOTokensToPool( + toUnitAmount( + getScenarioConfig().rebalanceProbe.lotMoreAsset + .smallSwapOTokensToPool + ) + ); + }); + it("Strategist should swap a lot of OToken to the pool", async () => { + await assertSwapOTokensToPool( + toUnitAmount( + getScenarioConfig().rebalanceProbe.lotMoreAsset + .largeSwapOTokensToPool + ) + ); + }); + it("Strategist should get the pool close to balanced", async () => { + const { assetReserves, oTokenReserves } = await getPoolReserves(); + // 32% of the extra asset token in the pool gets pretty close to balanced + const oTokenAmount = assetReserves.sub(oTokenReserves).mul(32).div(100); + + await assertSwapOTokensToPool(oTokenAmount); + }); + it("Strategist should fail to add so much OToken that it overshoots", async () => { + const { amoStrategy, strategist } = fixture; + + const dataBefore = await snapData(); + await logSnapData(dataBefore, "Before swapping OToken to the pool"); + + const tx = amoStrategy + .connect(strategist) + .swapOTokensToPool( + toUnitAmount( + getScenarioConfig().rebalanceProbe.lotMoreAsset + .overshootSwapOTokensToPool + ) + ); + + await expect(tx).to.be.revertedWith("OTokens overshot peg"); + }); + it("Strategist should fail to add more asset token to the pool", async () => { + const { amoStrategy, strategist } = fixture; + + const tx = amoStrategy + .connect(strategist) + .swapAssetsToPool( + toUnitAmount( + getScenarioConfig().rebalanceProbe.lotMoreAsset + .disallowedSwapAssetsToPool + ) + ); + + await expect(tx).to.be.revertedWith("Assets balance worse"); + }); + }); + + describe("with a little more asset token in the pool", () => { + beforeEach(async function () { + context = await contextFunction(); + fixture = await context.loadFixture({ + assetMintAmount: + getScenarioConfig().bootstrapPool.mediumAssetBootstrapIn, + depositToStrategy: true, + balancePool: true, + poolAddAssetAmount: + getScenarioConfig().poolImbalance.littleMoreAsset.addAsset, + }); + }); + it("Vault should deposit asset token to AMO strategy", async function () { + await assertDeposit( + toUnitAmount( + getScenarioConfig().rebalanceProbe.littleMoreAsset.depositAmount + ) + ); + }); + it("Vault should be able to withdraw all", async () => { + await assertWithdrawAll(); + }); + it("Vault should be able to partially withdraw", async () => { + await assertWithdrawPartial( + toUnitAmount( + getScenarioConfig().rebalanceProbe.littleMoreAsset + .partialWithdrawAmount + ) + ); + }); + it("Strategist should swap a little OToken to the pool", async () => { + await assertSwapOTokensToPool( + toUnitAmount( + getScenarioConfig().rebalanceProbe.littleMoreAsset + .smallSwapOTokensToPool + ) + ); + }); + it("Strategist should get the pool close to balanced", async () => { + const { assetReserves, oTokenReserves } = await getPoolReserves(); + // 50% of the extra asset token in the pool gets pretty close to balanced + const oTokenAmount = assetReserves.sub(oTokenReserves).mul(50).div(100); + + await assertSwapOTokensToPool(oTokenAmount); + }); + it("Strategist should fail to add zero OToken to the pool", async () => { + const { amoStrategy, strategist } = fixture; + + const tx = amoStrategy.connect(strategist).swapOTokensToPool(0); + + await expect(tx).to.be.revertedWith("Must swap something"); + }); + it("Strategist should fail to add too much OToken to the pool", async () => { + const { amoStrategy, strategist } = fixture; + + const tx = amoStrategy + .connect(strategist) + .swapOTokensToPool( + toUnitAmount( + getScenarioConfig().rebalanceProbe.littleMoreAsset + .overshootSwapOTokensToPool + ) + ); + + await expect(tx).to.be.revertedWith("OTokens overshot peg"); + }); + it("Strategist should fail to add more asset token to the pool", async () => { + const { amoStrategy, strategist } = fixture; + + const tx = amoStrategy + .connect(strategist) + .swapAssetsToPool( + toUnitAmount( + getScenarioConfig().rebalanceProbe.littleMoreAsset + .disallowedSwapAssetsToPool + ) + ); + + await expect(tx).to.be.revertedWith("Assets balance worse"); + }); + }); + + describe("with the strategy owning a small percentage of the pool", () => { + let dataBefore; + + beforeEach(async function () { + context = await contextFunction(); + fixture = await context.loadFixture({ + assetMintAmount: + getScenarioConfig().bootstrapPool.smallAssetBootstrapIn, + depositToStrategy: true, + balancePool: true, + }); + + const { nick, oToken, pool, assetToken } = fixture; + + // Other users add a lot more liquidity to the pool. + const bigAmount = toUnitAmount( + getScenarioConfig().smallPoolShare.bigLiquidityAsset + ); + // Acquire OToken by swapping asset token in, then add balanced-like liquidity + // while preserving enough OToken for the swap-heavy tests that follow. + await poolSwapTokensIn( + assetToken, + toUnitAmount(getScenarioConfig().smallPoolShare.bootstrapAssetSwapIn) + ); + const oTokenBalance = await oToken.balanceOf(nick.address); + const oTokenBufferForTests = toUnitAmount( + getScenarioConfig().smallPoolShare.oTokenBuffer + ); + const oTokenToPool = oTokenBalance.sub(oTokenBufferForTests); + expect(oTokenToPool).to.gt(0); + + await assetToken.connect(nick).transfer(pool.address, bigAmount); + await oToken.connect(nick).transfer(pool.address, oTokenToPool); + await pool.connect(nick).mint(nick.address); + + dataBefore = await snapData(); + await logSnapData(dataBefore); + }); + + it("A lot of OToken is swapped into the pool", async () => { + const { oToken, amoStrategy, assetToken } = fixture; + + // Swap OToken into the pool and asset token out. + await poolSwapTokensIn( + oToken, + toUnitAmount(getScenarioConfig().smallPoolShare.stressSwapOToken) + ); + await logSnapData( + await snapData(), + "\nAfter swapping OToken into the pool" + ); + + expect( + await amoStrategy.checkBalance(assetToken.address), + "Strategy's check balance" + ).to.withinRange( + dataBefore.stratBalance, + dataBefore.stratBalance.add(1) + ); + + // Swap asset token into the pool and OToken out. + await poolSwapTokensIn( + assetToken, + toUnitAmount(getScenarioConfig().smallPoolShare.stressSwapAsset) + ); + await logSnapData( + await snapData(), + "\nAfter swapping asset token into the pool" + ); + + expect( + await amoStrategy.checkBalance(assetToken.address), + "Strategy's check balance" + ).to.withinRange( + dataBefore.stratBalance, + dataBefore.stratBalance.add(2) + ); + }); + + it("A lot of asset token is swapped into the pool", async () => { + const { amoStrategy, oToken, assetToken } = fixture; + + // Swap asset token into the pool and OToken out. + await poolSwapTokensIn( + assetToken, + toUnitAmount(getScenarioConfig().smallPoolShare.stressSwapAssetAlt) + ); + await logSnapData( + await snapData(), + "\nAfter swapping asset token into the pool" + ); + + expect( + await amoStrategy.checkBalance(assetToken.address), + "Strategy's check balance" + ).to.withinRange( + dataBefore.stratBalance, + dataBefore.stratBalance.add(2) + ); + + // Swap OToken into the pool and asset token out. + await poolSwapTokensIn( + oToken, + toUnitAmount(getScenarioConfig().smallPoolShare.stressSwapOToken) + ); + await logSnapData( + await snapData(), + "\nAfter swapping OToken into the pool" + ); + + expect( + await amoStrategy.checkBalance(assetToken.address), + "Strategy's check balance" + ).to.withinRange( + dataBefore.stratBalance, + dataBefore.stratBalance.add(2) + ); + }); + }); + + describe("with an insolvent vault", () => { + beforeEach(async () => { + context = await contextFunction(); + fixture = await context.loadFixture({ + assetMintAmount: + getScenarioConfig().bootstrapPool.largeAssetBootstrapIn, + depositToStrategy: false, + }); + + const { vault, vaultSigner, amoStrategy, assetToken } = fixture; + + // Deposit a little to the strategy. + const littleAmount = toUnitAmount( + getScenarioConfig().mintValues.extraSmallPlus + ); + await assetToken + .connect(vaultSigner) + .transfer(amoStrategy.address, littleAmount); + await amoStrategy + .connect(vaultSigner) + .deposit(assetToken.address, littleAmount); + + const totalAssets = await vault.totalValue(); + // Calculate a 0.21% (21 basis points) loss. + const lossAmount = totalAssets.mul(21).div(10000); + await assetToken + .connect(vaultSigner) + .transfer(addresses.dead, lossAmount); + + const insolventSnapshot = await snapData(); + logSnapData( + insolventSnapshot, + `Snapshot of the protocol when it is insolvent` + ); + + expect( + await assetToken.balanceOf(vault.address), + "Must have enough asset token in vault to make insolvent" + ).to.gte(lossAmount); + }); + + it("Should fail to deposit", async () => { + const { vaultSigner, amoStrategy, assetToken } = fixture; + + // Vault calls deposit on the strategy. + const depositAmount = toUnitAmount( + getScenarioConfig().mintValues.extraSmall + ); + await assetToken + .connect(vaultSigner) + .transfer(amoStrategy.address, depositAmount); + const tx = amoStrategy + .connect(vaultSigner) + .deposit(assetToken.address, depositAmount); + + await expect(tx).to.be.revertedWith("Protocol insolvent"); + }); + + it("Should fail to withdraw", async () => { + const { vault, vaultSigner, amoStrategy, assetToken } = fixture; + + // Vault withdraws from the strategy. + const tx = amoStrategy + .connect(vaultSigner) + .withdraw( + vault.address, + assetToken.address, + toUnitAmount(getScenarioConfig().mintValues.extraSmall) + ); + + await expect(tx).to.be.revertedWith("Protocol insolvent"); + }); + + it("Should withdraw all", async () => { + const { vaultSigner, amoStrategy } = fixture; + + const tx = amoStrategy.connect(vaultSigner).withdrawAll(); + + await expect(tx).to.not.revertedWith("Protocol insolvent"); + }); + + it("Should fail to swap assets to the pool", async () => { + const { amoStrategy, strategist } = fixture; + + const tx = amoStrategy + .connect(strategist) + .swapAssetsToPool( + toUnitAmount(getScenarioConfig().mintValues.extraSmall) + ); + + await expect(tx).to.be.revertedWith("Protocol insolvent"); + }); + + it("Should fail to swap OToken to the pool", async () => { + const { amoStrategy, strategist } = fixture; + + const tx = amoStrategy + .connect(strategist) + .swapOTokensToPool( + toUnitAmount(getScenarioConfig().insolvent.swapOTokensToPool) + ); + + await expect(tx).to.be.revertedWith("Protocol insolvent"); + }); + }); + + const poolSwapTokensIn = async (tokenIn, amountIn) => { + const { nick, pool, assetToken, oTokenPoolIndex } = fixture; + const amountOut = await pool.getAmountOut(amountIn, tokenIn.address); + await tokenIn.connect(nick).transfer(pool.address, amountIn); + + if (tokenIn.address == assetToken.address) { + await pool.swap( + oTokenPoolIndex == 1 ? 0 : amountOut, + oTokenPoolIndex == 1 ? amountOut : 0, + nick.address, + "0x" + ); + } else { + await pool.swap( + oTokenPoolIndex == 0 ? 0 : amountOut, + oTokenPoolIndex == 0 ? amountOut : 0, + nick.address, + "0x" + ); + } + + return amountOut; + }; + + const precision = parseUnits("1", 18); + // Calculate the value of asset token and OToken assuming the pool is balanced + const calcReserveValue = (reserves) => { + const k = calcInvariant(reserves); + + // If x = y, let’s denote x = y = z (where z is the common reserve value) + // Substitute z into the invariant: + // k = z^3 * z + z * z^3 + // k = 2 * z^4 + // Going back the other way to calculate the common reserve value z + // z = (k / 2) ^ (1/4) + // the total value of the pool when x = y is 2 * z, which is 2 * (k / 2) ^ (1/4) + const zSquared = sqrt(k.mul(precision).div(2)); + const z = sqrt(zSquared.mul(precision)); + return z.mul(2); + }; + + const calcInvariant = (reserves) => { + const x = reserves.assetToken; + const y = reserves.oToken; + const a = x.mul(y).div(precision); + const b = x.mul(x).div(precision).add(y.mul(y).div(precision)); + const k = a.mul(b).div(precision); + + return k; + }; + + // Babylonian square root function for Ethers.js BigNumber + function sqrt(value) { + // Convert input to BigNumber if it isn't already + let bn = ethers.BigNumber.from(value); + + // Handle edge cases + if (bn.lt(0)) { + throw new Error("Square root of negative number is not supported"); + } + if (bn.eq(0)) { + return ethers.BigNumber.from(0); + } + + // Initial guess (number / 2) + let guess = bn.div(2); + + // Define precision threshold (in wei scale, 10^-18) + const epsilon = ethers.BigNumber.from("1"); // 1 wei precision + + // Keep refining until we reach desired precision + while (true) { + // Babylonian method: nextGuess = (guess + number/guess) / 2 + // Using mul and div for BigNumber arithmetic + let numerator = guess.add(bn.div(guess)); + let nextGuess = numerator.div(2); + + // Calculate absolute difference + let diff = nextGuess.gt(guess) + ? nextGuess.sub(guess) + : guess.sub(nextGuess); + + // If difference is less than epsilon, we're done + if (diff.lte(epsilon)) { + return nextGuess; + } + + // Update guess for next iteration + guess = nextGuess; + } + } + + const snapData = async () => { + const { vault, amoStrategy, oToken, pool, gauge, assetToken } = fixture; + + const stratBalance = await amoStrategy.checkBalance(assetToken.address); + const oTokenSupply = await oToken.totalSupply(); + const vaultAssets = await vault.totalValue(); + const poolSupply = await pool.totalSupply(); + const { assetReserves, oTokenReserves } = await getPoolReserves(); + const reserves = { assetToken: assetReserves, oToken: oTokenReserves }; + + // Amount of asset token bought from selling 1 oToken + const assetAmount = await pool.getAmountOut( + parseUnits("1"), + oToken.address + ); + // asset/oToken price = assetToken / oToken + const sellPrice = assetAmount; + + // Amount of asset token sold from buying 1 OToken + const oTokenAmount = await pool.getAmountOut( + parseUnits("1"), + assetToken.address + ); + // OToken/asset price = asset / OToken + const buyPrice = parseUnits("1", 36).div(oTokenAmount); + + const k = calcInvariant(reserves); + const stratGaugeBalance = await gauge.balanceOf(amoStrategy.address); + const gaugeSupply = await gauge.totalSupply(); + const vaultAssetBalance = await assetToken.balanceOf(vault.address); + const stratAssetBalance = await assetToken.balanceOf(amoStrategy.address); + + return { + stratBalance, + oTokenSupply, + vaultAssets, + poolSupply, + reserves: { assetToken: assetReserves, oToken: oTokenReserves }, + buyPrice, + sellPrice, + stratGaugeBalance, + gaugeSupply, + vaultAssetBalance, + stratAssetBalance, + k, + }; + }; + + const logSnapData = async (data, message) => { + const totalReserves = data.reserves.assetToken.add(data.reserves.oToken); + const reserversPercentage = { + assetToken: data.reserves.assetToken.mul(10000).div(totalReserves), + oToken: data.reserves.oToken.mul(10000).div(totalReserves), + }; + const gaugePercentage = data.gaugeSupply.eq(0) + ? 0 + : data.stratGaugeBalance.mul(10000).div(data.gaugeSupply); + if (message) { + log(message); + } + log(`Strategy balance : ${formatUnits(data.stratBalance)}`); + log(`oToken supply : ${formatUnits(data.oTokenSupply)}`); + log(`Vault assets : ${formatUnits(data.vaultAssets)}`); + log(`pool supply : ${formatUnits(data.poolSupply)}`); + log( + `reserves assetToken : ${formatUnits( + data.reserves.assetToken + )} ${formatUnits(reserversPercentage.assetToken, 2)}%` + ); + log( + `reserves OToken : ${formatUnits( + data.reserves.oToken + )} ${formatUnits(reserversPercentage.oToken, 2)}%` + ); + log( + `buy price : ${formatUnits(data.buyPrice)} OToken/assetToken` + ); + log( + `sell price : ${formatUnits(data.sellPrice)} OToken/assetToken` + ); + log(`Invariant K : ${formatUnits(data.k)}`); + log( + `strat gauge balance : ${formatUnits( + data.stratGaugeBalance + )} ${formatUnits(gaugePercentage, 2)}%` + ); + log(`gauge supply : ${formatUnits(data.gaugeSupply)}`); + log(`vault asset balance : ${formatUnits(data.vaultAssetBalance)}`); + }; + + const logProfit = async (dataBefore) => { + const { oToken, vault, amoStrategy, assetToken } = fixture; + + const stratBalanceAfter = await amoStrategy.checkBalance( + assetToken.address + ); + const oTokenSupplyAfter = await oToken.totalSupply(); + const vaultAssetsAfter = await vault.totalValue(); + const profit = vaultAssetsAfter + .sub(dataBefore.vaultAssets) + .add(dataBefore.oTokenSupply.sub(oTokenSupplyAfter)); + + log( + `Change strat balance: ${formatUnits( + stratBalanceAfter.sub(dataBefore.stratBalance) + )}` + ); + log( + `Change vault assets : ${formatUnits( + vaultAssetsAfter.sub(dataBefore.vaultAssets) + )}` + ); + log( + `Change oToken supply : ${formatUnits( + oTokenSupplyAfter.sub(dataBefore.oTokenSupply) + )}` + ); + log(`Profit : ${formatUnits(profit)}`); + + return profit; + }; + + const assertChangedData = async (dataBefore, delta) => { + const { oToken, vault, amoStrategy, gauge, assetToken } = fixture; + + if (delta.stratBalance != undefined) { + const expectedStratBalance = dataBefore.stratBalance.add( + delta.stratBalance + ); + log(`Expected strategy balance: ${formatUnits(expectedStratBalance)}`); + + expect( + await amoStrategy.checkBalance(assetToken.address) + ).to.withinRange( + expectedStratBalance.sub(15), + expectedStratBalance.add(15), + "Strategy's check balance" + ); + } + + if (delta.oTokenSupply != undefined) { + const expectedSupply = dataBefore.oTokenSupply.add(delta.oTokenSupply); + expect(await oToken.totalSupply(), "oToken total supply").to.equal( + expectedSupply + ); + } + + // Check Vault's asset token balance + if (delta.vaultAssetBalance != undefined) { + expect(await assetToken.balanceOf(vault.address)).to.equal( + dataBefore.vaultAssetBalance.add(delta.vaultAssetBalance), + "Vault's assetToken balance" + ); + } + + // Check the pool's reserves + if (delta.reserves != undefined) { + const { assetReserves, oTokenReserves } = await getPoolReserves(); + + // If the asset reserves delta is a function, call it to check the asset token reserves + if (typeof delta.reserves.assetToken == "function") { + // Call test function to check the asset token reserves + delta.reserves.assetToken(assetReserves); + } else { + expect(assetReserves, "assetToken reserves").to.equal( + dataBefore.reserves.assetToken.add(delta.reserves.assetToken) + ); + } + // Check oToken reserves delta + expect(oTokenReserves, "oToken reserves").to.equal( + dataBefore.reserves.oToken.add(delta.reserves.oToken) + ); + } + + if (delta.stratGaugeBalance) { + // Check the strategy's gauge balance + const expectedStratGaugeBalance = dataBefore.stratGaugeBalance.add( + delta.stratGaugeBalance + ); + expect(await gauge.balanceOf(amoStrategy.address)).to.withinRange( + expectedStratGaugeBalance.sub(1), + expectedStratGaugeBalance.add(1), + "Strategy's gauge balance" + ); + } + }; + + async function assertDeposit(assetDepositAmount) { + const { + nick, + amoStrategy, + oToken, + vault, + pool, + vaultSigner, + assetToken, + } = fixture; + + await assetToken.connect(nick).approve(vault.address, assetDepositAmount); + await vault.connect(nick).mint(assetDepositAmount); + + const dataBefore = await snapData(); + await logSnapData(dataBefore, "\nBefore depositing asset to strategy"); + + const { lpMintAmount, oTokenMintAmount } = await calcOTokenMintAmount( + assetDepositAmount + ); + + // Vault transfers asset to strategy + await assetToken + .connect(vaultSigner) + .transfer(amoStrategy.address, assetDepositAmount); + // Vault calls deposit on the strategy + const tx = await amoStrategy + .connect(vaultSigner) + .deposit(assetToken.address, assetDepositAmount); + + await logSnapData( + await snapData(), + `\nAfter depositing ${formatUnits( + assetDepositAmount + )} asset to strategy` + ); + await logProfit(dataBefore); + + // Check emitted events + await expect(tx) + .to.emit(amoStrategy, "Deposit") + .withArgs(assetToken.address, pool.address, assetDepositAmount); + await expect(tx) + .to.emit(amoStrategy, "Deposit") + .withArgs(oToken.address, pool.address, oTokenMintAmount); + + // Calculate the value of the asset token and oToken added to the pool if the pool was balanced + const depositValue = calcReserveValue({ + assetToken: assetDepositAmount, + oToken: oTokenMintAmount, + }); + log(`Value of deposit: ${formatUnits(depositValue)}`); + + await assertChangedData(dataBefore, { + stratBalance: depositValue, + oTokenSupply: oTokenMintAmount, + reserves: { assetToken: assetDepositAmount, oToken: oTokenMintAmount }, + vaultAssetBalance: assetDepositAmount.mul(-1), + gaugeSupply: lpMintAmount, + }); + + expect( + await pool.balanceOf(amoStrategy.address), + "Strategy's pool LP balance" + ).to.equal(0); + expect( + await oToken.balanceOf(amoStrategy.address), + "Strategy's OToken balance" + ).to.equal(0); + } + + async function ensureVaultHasAssets(requiredAmount) { + const { vault, assetToken } = fixture; + const vaultBalance = await assetToken.balanceOf(vault.address); + if (vaultBalance.gte(requiredAmount)) { + return; + } + await setERC20TokenBalance(vault.address, assetToken, requiredAmount); + } + + async function assertFailedDeposit(assetDepositAmount, errorMessage) { + const { assetToken, vaultSigner, amoStrategy } = fixture; + + const dataBefore = await snapData(); + await logSnapData( + dataBefore, + "\nBefore depositing asset token to strategy" + ); + + await ensureVaultHasAssets(assetDepositAmount); + + // Vault transfers wS to strategy + await assetToken + .connect(vaultSigner) + .transfer(amoStrategy.address, assetDepositAmount); + + // Vault calls deposit on the strategy + const tx = amoStrategy + .connect(vaultSigner) + .deposit(assetToken.address, assetDepositAmount); + + await expect(tx, "deposit to strategy").to.be.revertedWith(errorMessage); + } + + async function assertFailedDepositAll(assetDepositAmount, errorMessage) { + const { assetToken, vaultSigner, amoStrategy } = fixture; + + const dataBefore = await snapData(); + await logSnapData(dataBefore, "\nBefore depositing all wS to strategy"); + + await ensureVaultHasAssets(assetDepositAmount); + + // Vault transfers wS to strategy + await assetToken + .connect(vaultSigner) + .transfer(amoStrategy.address, assetDepositAmount); + + // Vault calls depositAll on the strategy + const tx = amoStrategy.connect(vaultSigner).depositAll(); + + await expect(tx, "depositAll to strategy").to.be.revertedWith( + errorMessage + ); + } + + async function assertWithdrawAll() { + const { amoStrategy, pool, oToken, vaultSigner, assetToken } = fixture; + + const dataBefore = await snapData(); + await logSnapData(dataBefore); + + const { oTokenBurnAmount, assetTokenWithdrawAmount } = + await calcWithdrawAllAmounts(); + + // Now try to withdraw all the wS from the strategy + const tx = await amoStrategy.connect(vaultSigner).withdrawAll(); + + await logSnapData(await snapData(), "\nAfter full withdraw"); + await logProfit(dataBefore); + + // Check emitted events + await expect(tx) + .to.emit(amoStrategy, "Withdrawal") + .withArgs(assetToken.address, pool.address, assetTokenWithdrawAmount); + await expect(tx) + .to.emit(amoStrategy, "Withdrawal") + .withArgs(oToken.address, pool.address, oTokenBurnAmount); + + // Calculate the value of the asset token and oToken removed from the pool if the pool was balanced + const withdrawValue = calcReserveValue({ + assetToken: assetTokenWithdrawAmount, + oToken: oTokenBurnAmount, + }); + + await assertChangedData(dataBefore, { + stratBalance: withdrawValue.mul(-1), + oTokenSupply: oTokenBurnAmount.mul(-1), + reserves: { + assetToken: assetTokenWithdrawAmount.mul(-1), + oToken: oTokenBurnAmount.mul(-1), + }, + vaultAssetBalance: assetTokenWithdrawAmount, + stratGaugeBalance: dataBefore.stratGaugeBalance.mul(-1), + }); + + expect( + assetTokenWithdrawAmount.add(oTokenBurnAmount), + "asset token withdraw and oToken burn >= strategy balance" + ).to.gte(dataBefore.stratBalance); + + expect( + await pool.balanceOf(amoStrategy.address), + "Strategy's pool LP balance" + ).to.equal(0); + expect( + await oToken.balanceOf(amoStrategy.address), + "Strategy's oToken balance" + ).to.equal(0); + } + + async function assertWithdrawPartial(assetTokenWithdrawAmount) { + const { amoStrategy, oToken, pool, vault, vaultSigner, assetToken } = + fixture; + + const dataBefore = await snapData(); + + const { lpBurnAmount, oTokenBurnAmount } = await calcOTokenWithdrawAmount( + assetTokenWithdrawAmount + ); + + // Now try to withdraw the asset token from the strategy + const tx = await amoStrategy + .connect(vaultSigner) + .withdraw(vault.address, assetToken.address, assetTokenWithdrawAmount); + + await logSnapData( + await snapData(), + `\nAfter withdraw of ${formatUnits(assetTokenWithdrawAmount)}` + ); + await logProfit(dataBefore); + + // Check emitted events + await expect(tx) + .to.emit(amoStrategy, "Withdrawal") + .withArgs(assetToken.address, pool.address, assetTokenWithdrawAmount); + await expect(tx).to.emit(amoStrategy, "Withdrawal").withNamedArgs({ + _asset: oToken.address, + _pToken: pool.address, + }); + + // Calculate the value of the asset token and OToken removed from the pool if the pool was balanced + const withdrawValue = calcReserveValue({ + assetToken: assetTokenWithdrawAmount, + oToken: oTokenBurnAmount, + }); + + await assertChangedData(dataBefore, { + stratBalance: withdrawValue.mul(-1), + oTokenSupply: oTokenBurnAmount.mul(-1), + reserves: { + assetToken: (actualAssetTokenReserve) => { + const expectedAssetTokenReserves = + dataBefore.reserves.assetToken.sub(assetTokenWithdrawAmount); + + expect(actualAssetTokenReserve).to.withinRange( + expectedAssetTokenReserves.sub(50), + expectedAssetTokenReserves, + "asset token reserves" + ); + }, + oToken: oTokenBurnAmount.mul(-1), + }, + vaultAssetBalance: assetTokenWithdrawAmount, + gaugeSupply: lpBurnAmount.mul(-1), + }); + + expect( + await pool.balanceOf(amoStrategy.address), + "Strategy's pool LP balance" + ).to.equal(0); + expect( + await oToken.balanceOf(amoStrategy.address), + "Strategy's OToken balance" + ).to.equal(0); + } + + async function assertSwapAssetsToPool(assetAmount) { + const { oToken, amoStrategy, pool, strategist, assetToken } = fixture; + + const dataBefore = await snapData(); + await logSnapData( + dataBefore, + `Before swapping ${formatUnits(assetAmount)} asset token into the pool` + ); + + const { + lpBurnAmount: expectedLpBurnAmount, + oTokenBurnAmount: oTokenBurnAmount1, + } = await calcOTokenWithdrawAmount(assetAmount); + // TODO this is not accurate as the liquidity needs to be removed first + const oTokenBurnAmount2 = await pool.getAmountOut( + assetAmount, + assetToken.address + ); + const oTokenBurnAmount = oTokenBurnAmount1.add(oTokenBurnAmount2); + + // Swap asset token to the pool and burn the received OToken from the pool + const tx = await amoStrategy + .connect(strategist) + .swapAssetsToPool(assetAmount); + + await logSnapData( + await snapData(), + "\nAfter swapping assets to the pool" + ); + await logProfit(dataBefore); + + // Check emitted event + await expect(tx).to.emittedEvent("SwapAssetsToPool", [ + (actualAssetTokenAmount) => { + expect(actualAssetTokenAmount).to.withinRange( + assetAmount.sub(1), + assetAmount.add(1), + "SwapAssetsToPool event asset token amount" + ); + }, + expectedLpBurnAmount, + (actualOTokenBurnAmount) => { + // TODO this can be tightened once oTokenBurnAmount is more accurately calculated + expect(actualOTokenBurnAmount).to.approxEqualTolerance( + oTokenBurnAmount, + 10, + "SwapAssetsToPool event oTokenBurnt" + ); + }, + ]); + + await assertChangedData( + dataBefore, + { + // stratBalance: osBurnAmount.mul(-1), + vaultAssetBalance: 0, + stratGaugeBalance: 0, + }, + fixture + ); + + expect( + await pool.balanceOf(amoStrategy.address), + "Strategy's pool LP balance" + ).to.equal(0); + expect( + await oToken.balanceOf(amoStrategy.address), + "Strategy's oToken balance" + ).to.equal(0); + } + + async function assertSwapOTokensToPool(oTokenAmount) { + const { oToken, pool, amoStrategy, strategist } = fixture; + + const dataBefore = await snapData(); + await logSnapData(dataBefore, "Before swapping OTokens to the pool"); + + // Mint OToken and swap into the pool, then mint more OToken to add with the swapped out asset token. + const tx = await amoStrategy + .connect(strategist) + .swapOTokensToPool(oTokenAmount); + + // Check emitted event + await expect(tx) + .emit(amoStrategy, "SwapOTokensToPool") + .withNamedArgs({ oTokenMinted: oTokenAmount }); + + await logSnapData( + await snapData(), + "\nAfter swapping OTokens to the pool" + ); + await logProfit(dataBefore); + + await assertChangedData( + dataBefore, + { + // stratBalance: osBurnAmount.mul(-1), + vaultAssetBalance: 0, + stratGaugeBalance: 0, + }, + fixture + ); + + expect( + await pool.balanceOf(amoStrategy.address), + "Strategy's pool LP balance" + ).to.equal(0); + expect( + await oToken.balanceOf(amoStrategy.address), + "Strategy's OToken balance" + ).to.equal(0); + } + + // Calculate the minted OS amount for a deposit + async function calcOTokenMintAmount(assetDepositAmount) { + const { pool } = fixture; + + const { assetReserves, oTokenReserves } = await getPoolReserves(); + + const oTokenMintAmount = assetDepositAmount + .mul(oTokenReserves) + .div(assetReserves); + log(`OToken mint amount : ${formatUnits(oTokenMintAmount)}`); + + const lpTotalSupply = await pool.totalSupply(); + const lpMintAmount = assetDepositAmount + .mul(lpTotalSupply) + .div(assetReserves); + + return { lpMintAmount, oTokenMintAmount }; + } + + async function getPoolReserves() { + const { pool, oTokenPoolIndex } = fixture; + + let assetReserves, oTokenReserves; + // Get the reserves of the pool + const { _reserve0, _reserve1 } = await pool.getReserves(); + + assetReserves = oTokenPoolIndex === 0 ? _reserve1 : _reserve0; + oTokenReserves = oTokenPoolIndex === 0 ? _reserve0 : _reserve1; + + return { assetReserves, oTokenReserves }; + } + // Calculate the amount of oToken burnt from a withdraw + async function calcOTokenWithdrawAmount(assetTokenWithdrawAmount) { + const { pool } = fixture; + + const { assetReserves, oTokenReserves } = await getPoolReserves(); + + // lp tokens to burn = asset token withdrawn * total LP supply / asset token pool balance + const totalLpSupply = await pool.totalSupply(); + const lpBurnAmount = assetTokenWithdrawAmount + .mul(totalLpSupply) + .div(assetReserves) + .add(1); + // OToken to burn = LP tokens to burn * OToken reserves / total LP supply + const oTokenBurnAmount = lpBurnAmount + .mul(oTokenReserves) + .div(totalLpSupply); + + log(`OToken burn amount : ${formatUnits(oTokenBurnAmount)}`); + + return { lpBurnAmount, oTokenBurnAmount }; + } + + // Calculate the OToken and asset token amounts from a withdrawAll + async function calcWithdrawAllAmounts() { + const { amoStrategy, gauge, pool } = fixture; + + // Get the reserves of the pool + const { assetReserves, oTokenReserves } = await getPoolReserves(); + const strategyLpAmount = await gauge.balanceOf(amoStrategy.address); + const totalLpSupply = await pool.totalSupply(); + + // asset token to withdraw = asset token pool balance * strategy LP amount / total pool LP amount + const assetTokenWithdrawAmount = assetReserves + .mul(strategyLpAmount) + .div(totalLpSupply); + // OS to burn = OS pool balance * strategy LP amount / total pool LP amount + const oTokenBurnAmount = oTokenReserves + .mul(strategyLpAmount) + .div(totalLpSupply); + + log( + `asset token withdraw amount : ${formatUnits(assetTokenWithdrawAmount)}` + ); + log(`oToken burn amount : ${formatUnits(oTokenBurnAmount)}`); + + return { + assetTokenWithdrawAmount, + oTokenBurnAmount, + }; + } + }); +}; + +module.exports = { + shouldBehaveLikeAlgebraAmoStrategy, +}; diff --git a/contracts/test/strategies/oeth-supernova-amo.mainnet.fork-test.js b/contracts/test/strategies/oeth-supernova-amo.mainnet.fork-test.js new file mode 100644 index 0000000000..cee4c21b40 --- /dev/null +++ b/contracts/test/strategies/oeth-supernova-amo.mainnet.fork-test.js @@ -0,0 +1,134 @@ +const { supernovaOETHAMOFixture, createFixtureLoader } = require("../_fixture"); +const { + shouldBehaveLikeAlgebraAmoStrategy, +} = require("../behaviour/algebraAmoStrategy"); + +describe("Mainnet Fork Test: OETH Supernova AMO Strategy", function () { + shouldBehaveLikeAlgebraAmoStrategy(async () => { + const scenarioConfig = { + attackerFrontRun: { + moderateAssetIn: "20", + largeAssetIn: "10000", + largeOTokenIn: "10000", + }, + bootstrapPool: { + smallAssetBootstrapIn: "50", + mediumAssetBootstrapIn: "200", + largeAssetBootstrapIn: "500000", + }, + mintValues: { + extraSmall: "0.1", + extraSmallPlus: "0.2", + small: "1", + medium: "2", + }, + poolImbalance: { + lotMoreOToken: { addOToken: 400 }, + littleMoreOToken: { addOToken: 2 }, + lotMoreAsset: { addAsset: 400 }, + littleMoreAsset: { addAsset: 2 }, + }, + smallPoolShare: { + bootstrapAssetSwapIn: "100", + bigLiquidityAsset: "50", + oTokenBuffer: "100", + stressSwapOToken: "30", + stressSwapAsset: "50", + stressSwapAssetAlt: "30", + }, + rebalanceProbe: { + frontRun: { + depositAmount: "200", + failedDepositAmount: "200", + failedDepositAllAmount: "200", + tiltSeedWithdrawAmount: "60", + assetTiltWithdrawAmount: "40", + oTokenTiltWithdrawAmount: "0.001", + }, + lotMoreOToken: { + failedDepositAmount: "200", + partialWithdrawAmount: "40", + smallSwapAssetsToPool: "0.3", + largeSwapAssetsToPool: "30", + nearMaxSwapAssetsToPool: "44", + excessiveSwapAssetsToPool: "2000", + disallowedSwapOTokensToPool: "0.0001", + }, + littleMoreOToken: { + depositAmount: "12", + partialWithdrawAmount: "10", + smallSwapAssetsToPool: "0.3", + excessiveSwapAssetsToPool: "50", + disallowedSwapOTokensToPool: "0.0001", + }, + lotMoreAsset: { + failedDepositAmount: "60", + partialWithdrawAmount: "10", + smallSwapOTokensToPool: "0.03", + largeSwapOTokensToPool: "50", + overshootSwapOTokensToPool: "350", + disallowedSwapAssetsToPool: "0.00001", + }, + littleMoreAsset: { + depositAmount: "18", + partialWithdrawAmount: "10", + smallSwapOTokensToPool: "0.8", + overshootSwapOTokensToPool: "110", + disallowedSwapAssetsToPool: "0.00001", + }, + }, + insolvent: { + swapOTokensToPool: "0.1", + }, + harvest: { + collectedBy: "strategist", + }, + }; + + return { + scenarioConfig, + loadFixture: async ({ + assetMintAmount = 0, + depositToStrategy = false, + balancePool = false, + poolAddAssetAmount = 0, + poolAddOTokenAmount = 0, + } = {}) => { + const fixtureLoader = await createFixtureLoader( + supernovaOETHAMOFixture, + { + assetMintAmount, + depositToStrategy, + balancePool, + poolAddWethAmount: poolAddAssetAmount, + poolAddOethAmount: poolAddOTokenAmount, + } + ); + + const fixture = await fixtureLoader(); + const oTokenPoolIndex = + (await fixture.supernovaPool.token0()) === fixture.oeth.address + ? 0 + : 1; + + return { + assetToken: fixture.weth, + oToken: fixture.oeth, + rewardToken: fixture.supernovaRewardToken, + amoStrategy: fixture.supernovaAMOStrategy, + pool: fixture.supernovaPool, + gauge: fixture.supernovaGauge, + governor: fixture.timelock, + timelock: fixture.timelock, + strategist: fixture.strategist, + nick: fixture.josh, + oTokenPoolIndex, + vaultSigner: fixture.oethVaultSigner, + vault: fixture.oethVault, + harvester: fixture.oethHarvester, + scenarioConfig, + }; + }, + }; + }); +}); diff --git a/contracts/test/strategies/sonic/swapx-amo.sonic.fork-test.js b/contracts/test/strategies/sonic/swapx-amo.sonic.fork-test.js index 5f4acaed0e..a0a4d4e0c6 100644 --- a/contracts/test/strategies/sonic/swapx-amo.sonic.fork-test.js +++ b/contracts/test/strategies/sonic/swapx-amo.sonic.fork-test.js @@ -1,1635 +1,130 @@ -const { expect } = require("chai"); -const { formatUnits, parseUnits } = require("ethers/lib/utils"); - -const { createFixtureLoader } = require("../../_fixture"); const { swapXAMOFixture } = require("../../_fixture-sonic"); -const { isCI } = require("../../helpers"); -const addresses = require("../../../utils/addresses"); -const { impersonateAndFund } = require("../../../utils/signers"); -const { setERC20TokenBalance } = require("../../_fund"); - -const log = require("../../../utils/logger")("test:fork:sonic:swapx:amo"); - -describe("Sonic ForkTest: SwapX AMO Strategy", function () { - // Retry up to 3 times on CI - this.retries(isCI ? 3 : 0); - - let fixture; - - describe("post deployment", () => { - const loadFixture = createFixtureLoader(swapXAMOFixture); - beforeEach(async () => { - fixture = await loadFixture(); - }); - it("Should have constants and immutables set", async () => { - const { swapXAMOStrategy } = fixture; - - expect(await swapXAMOStrategy.SOLVENCY_THRESHOLD()).to.equal( - parseUnits("0.998", 18) - ); - expect(await swapXAMOStrategy.ws()).to.equal(addresses.sonic.wS); - expect(await swapXAMOStrategy.os()).to.equal(addresses.sonic.OSonicProxy); - expect(await swapXAMOStrategy.pool()).to.equal( - addresses.sonic.SwapXWSOS.pool - ); - expect(await swapXAMOStrategy.gauge()).to.equal( - addresses.sonic.SwapXWSOS.gauge - ); - expect(await swapXAMOStrategy.governor()).to.equal( - addresses.sonic.timelock - ); - expect(await swapXAMOStrategy.supportsAsset(addresses.sonic.wS)).to.true; - expect(await swapXAMOStrategy.maxDepeg()).to.equal(parseUnits("0.01")); - }); - it("Should be able to check balance", async () => { - const { wS, nick, swapXAMOStrategy } = fixture; - - const balance = await swapXAMOStrategy.checkBalance(wS.address); - log(`check balance ${balance}`); - expect(balance).gte(0); - - // This uses a transaction to call a view function so the gas usage can be reported. - const tx = await swapXAMOStrategy - .connect(nick) - .populateTransaction.checkBalance(wS.address); - await nick.sendTransaction(tx); - }); - it("Only Governor can approve all tokens", async () => { - const { - timelock, - strategist, - nick, - oSonicVaultSigner, - swapXAMOStrategy, - swapXPool, - } = fixture; - - expect(await swapXAMOStrategy.connect(timelock).isGovernor()).to.equal( - true - ); - - // Timelock can approve all tokens - const tx = await swapXAMOStrategy - .connect(timelock) - .safeApproveAllTokens(); - await expect(tx).to.emit(swapXPool, "Approval"); - - for (const signer of [strategist, nick, oSonicVaultSigner]) { - const tx = swapXAMOStrategy.connect(signer).safeApproveAllTokens(); - await expect(tx).to.be.revertedWith("Caller is not the Governor"); - } - }); - it("Only Governor can set the max depeg", async () => { - const { - timelock, - strategist, - nick, - oSonicVaultSigner, - swapXAMOStrategy, - } = fixture; - - expect(await swapXAMOStrategy.connect(timelock).isGovernor()).to.equal( - true - ); - - // Timelock can update - const newMaxDepeg = parseUnits("0.02"); - const tx = await swapXAMOStrategy - .connect(timelock) - .setMaxDepeg(newMaxDepeg); - await expect(tx) - .to.emit(swapXAMOStrategy, "MaxDepegUpdated") - .withArgs(newMaxDepeg); - - expect(await swapXAMOStrategy.maxDepeg()).to.equal(newMaxDepeg); - - for (const signer of [strategist, nick, oSonicVaultSigner]) { - const tx = swapXAMOStrategy.connect(signer).setMaxDepeg(newMaxDepeg); - await expect(tx).to.be.revertedWith("Caller is not the Governor"); - } - }); - }); - - describe("with wS in the vault", () => { - const loadFixture = createFixtureLoader(swapXAMOFixture, { - wsMintAmount: 5000000, - depositToStrategy: false, - balancePool: true, - }); - beforeEach(async () => { - fixture = await loadFixture(); - }); - it("Vault should deposit wS to AMO strategy", async function () { - await assertDeposit(parseUnits("2000")); - }); - it("Only vault can deposit wS to AMO strategy", async function () { - const { - swapXAMOStrategy, - oSonicVaultSigner, - strategist, - timelock, - nick, - wS, - } = fixture; - - const depositAmount = parseUnits("50"); - await wS - .connect(oSonicVaultSigner) - .transfer(swapXAMOStrategy.address, depositAmount); - - for (const signer of [strategist, timelock, nick]) { - const tx = swapXAMOStrategy - .connect(signer) - .deposit(wS.address, depositAmount); - - await expect(tx).to.revertedWith("Caller is not the Vault"); - } - }); - it("Only vault can deposit all wS to AMO strategy", async function () { - const { - swapXAMOStrategy, - swapXPool, - oSonicVaultSigner, - strategist, - timelock, - nick, - wS, - } = fixture; - - const depositAmount = parseUnits("50"); - await wS - .connect(oSonicVaultSigner) - .transfer(swapXAMOStrategy.address, depositAmount); - - for (const signer of [strategist, timelock, nick]) { - const tx = swapXAMOStrategy.connect(signer).depositAll(); - - await expect(tx).to.revertedWith("Caller is not the Vault"); - } - - const tx = await swapXAMOStrategy.connect(oSonicVaultSigner).depositAll(); - await expect(tx) - .to.emit(swapXAMOStrategy, "Deposit") - .withNamedArgs({ _asset: wS.address, _pToken: swapXPool.address }); - }); - }); - - describe("with the strategy having OS and wS in a balanced pool", () => { - const loadFixture = createFixtureLoader(swapXAMOFixture, { - wsMintAmount: 100000, - depositToStrategy: true, - balancePool: true, - }); - beforeEach(async () => { - fixture = await loadFixture(); - }); - it("Vault should deposit wS", async function () { - await assertDeposit(parseUnits("5000")); - }); - it("Vault should be able to withdraw all", async () => { - await assertWithdrawAll(); - }); - it("Vault should be able to withdraw all in SwapX Emergency", async () => { - const { swapXAMOStrategy, swapXGauge, oSonicVaultSigner } = fixture; - - const gaugeOwner = await swapXGauge.owner(); - const ownerSigner = await impersonateAndFund(gaugeOwner); - await swapXGauge.connect(ownerSigner).activateEmergencyMode(); - await assertWithdrawAll(); - - // Try again when the strategy is empty - await swapXAMOStrategy.connect(oSonicVaultSigner).withdrawAll(); - }); - it("Should fail to deposit zero wS", async () => { - const { swapXAMOStrategy, oSonicVaultSigner, wS } = fixture; - - const tx = swapXAMOStrategy - .connect(oSonicVaultSigner) - .deposit(wS.address, 0); - - await expect(tx).to.be.revertedWith("Must deposit something"); - }); - it("Should fail to deposit OS", async () => { - const { swapXAMOStrategy, oSonicVaultSigner, oSonic } = fixture; - - const tx = swapXAMOStrategy - .connect(oSonicVaultSigner) - .deposit(oSonic.address, parseUnits("1")); - - await expect(tx).to.be.revertedWith("Unsupported asset"); - }); - it("Should fail to withdraw zero wS", async () => { - const { swapXAMOStrategy, oSonicVaultSigner, oSonicVault, wS } = fixture; - - const tx = swapXAMOStrategy - .connect(oSonicVaultSigner) - .withdraw(oSonicVault.address, wS.address, 0); - - await expect(tx).to.be.revertedWith("Must withdraw something"); - }); - it("Should fail to withdraw OS", async () => { - const { swapXAMOStrategy, oSonicVaultSigner, oSonic, oSonicVault } = - fixture; - - const tx = swapXAMOStrategy - .connect(oSonicVaultSigner) - .withdraw(oSonicVault.address, oSonic.address, parseUnits("1")); - - await expect(tx).to.be.revertedWith("Unsupported asset"); - }); - it("Should fail to withdraw to a user", async () => { - const { swapXAMOStrategy, oSonicVaultSigner, wS, nick } = fixture; - - const tx = swapXAMOStrategy - .connect(oSonicVaultSigner) - .withdraw(nick.address, wS.address, parseUnits("1")); - - await expect(tx).to.be.revertedWith("Only withdraw to vault allowed"); - }); - it("Vault should be able to withdraw all from empty strategy", async () => { - const { swapXAMOStrategy, oSonicVaultSigner } = fixture; - await assertWithdrawAll(); - - // Now try again after all the assets have already been withdrawn - const tx = await swapXAMOStrategy - .connect(oSonicVaultSigner) - .withdrawAll(); - - // Check emitted events - await expect(tx).to.not.emit(swapXAMOStrategy, "Withdrawal"); - }); - it("Vault should be able to partially withdraw", async () => { - await assertWithdrawPartial(parseUnits("1000")); - }); - it("Only vault can withdraw wS from AMO strategy", async function () { - const { swapXAMOStrategy, oSonicVault, strategist, timelock, nick, wS } = - fixture; - - for (const signer of [strategist, timelock, nick]) { - const tx = swapXAMOStrategy - .connect(signer) - .withdraw(oSonicVault.address, wS.address, parseUnits("50")); - - await expect(tx).to.revertedWith("Caller is not the Vault"); - } - }); - it("Only vault and governor can withdraw all WETH from AMO strategy", async function () { - const { swapXAMOStrategy, strategist, timelock, nick } = fixture; - - for (const signer of [strategist, nick]) { - const tx = swapXAMOStrategy.connect(signer).withdrawAll(); - - await expect(tx).to.revertedWith("Caller is not the Vault or Governor"); - } - - // Governor can withdraw all - const tx = swapXAMOStrategy.connect(timelock).withdrawAll(); - await expect(tx).to.emit(swapXAMOStrategy, "Withdrawal"); - }); - it("Harvester can collect rewards", async () => { - const { - harvester, - nick, - swapXAMOStrategy, - swapXGauge, - swpx, - strategist, - } = fixture; - - const swpxBalanceBefore = await swpx.balanceOf(strategist.address); - - // Send some SWPx rewards to the gauge - const distributorAddress = await swapXGauge.DISTRIBUTION(); - const distributorSigner = await impersonateAndFund(distributorAddress); - const rewardAmount = parseUnits("1000"); - await setERC20TokenBalance(distributorAddress, swpx, rewardAmount); - await swapXGauge - .connect(distributorSigner) - .notifyRewardAmount(swpx.address, rewardAmount); - - // Harvest the rewards - // prettier-ignore - const tx = await harvester - .connect(nick)["harvestAndTransfer(address)"](swapXAMOStrategy.address); - - await expect(tx).to.emit(swapXAMOStrategy, "RewardTokenCollected"); - - const swpxBalanceAfter = await swpx.balanceOf(strategist.address); - log( - `Rewards collected ${formatUnits( - swpxBalanceAfter.sub(swpxBalanceBefore) - )} SWPx` - ); - expect(swpxBalanceAfter).to.gt(swpxBalanceBefore); - }); - it("Attacker front-run deposit within range by adding wS to the pool", async () => { - const { clement, oSonic, oSonicVaultSigner, swapXAMOStrategy, wS } = - fixture; - - const attackerWsBalanceBefore = await wS.balanceOf(clement.address); - const wsAmountIn = parseUnits("20000"); - - const dataBeforeSwap = await snapData(); - logSnapData( - dataBeforeSwap, - `\nBefore attacker swaps ${formatUnits( - wsAmountIn - )} wS into the pool for OS` - ); - - // Attacker swaps a lot of wS for OS in the pool - // This drops the pool's wS/OS price and increases the OS/wS price - const osAmountOut = await poolSwapTokensIn(wS, wsAmountIn); - - const depositAmount = parseUnits("200000"); - - const dataBeforeDeposit = await snapData(); - logSnapData( - dataBeforeDeposit, - `\nAfter attacker tilted pool and before strategist deposits ${formatUnits( - depositAmount - )} wS` - ); - - // Vault deposits wS to the strategy - await wS - .connect(oSonicVaultSigner) - .transfer(swapXAMOStrategy.address, depositAmount); - await swapXAMOStrategy - .connect(oSonicVaultSigner) - .deposit(wS.address, depositAmount); - - const dataAfterDeposit = await snapData(); - logSnapData( - dataAfterDeposit, - `\nAfter deposit of ${formatUnits( - depositAmount - )} wS to strategy and before attacker swaps ${formatUnits( - osAmountOut - )} OS back into the pool for wS` - ); - await logProfit(dataBeforeSwap); - - // Attacker swaps the OS back for wS - await poolSwapTokensIn(oSonic, osAmountOut); - - const dataAfterFinalSwap = await snapData(); - logSnapData( - dataAfterFinalSwap, - `\nAfter attacker swaps ${formatUnits( - osAmountOut - )} OS back into the pool for wS` - ); - await logProfit(dataBeforeSwap); - - const attackerWsBalanceAfter = await wS.balanceOf(clement.address); - log( - `Attacker's profit ${formatUnits( - attackerWsBalanceAfter.sub(attackerWsBalanceBefore) - )} wS` - ); - }); - describe("When attacker front-run by adding a lot of wS to the pool", () => { - let attackerWsBalanceBefore; - let dataBeforeSwap; - let osAmountOut; - beforeEach(async () => { - const { clement, wS } = fixture; - - attackerWsBalanceBefore = await wS.balanceOf(clement.address); - const wsAmountIn = parseUnits("10000000"); - - dataBeforeSwap = await snapData(); - logSnapData( - dataBeforeSwap, - `\nBefore attacker swaps ${formatUnits( - wsAmountIn - )} wS into the pool for OS` - ); - - // Attacker swaps a lot of wS for OS in the pool - // This drops the pool's wS/OS price and increases the OS/wS price - osAmountOut = await poolSwapTokensIn(wS, wsAmountIn); - }); - it("Strategist fails to deposit to strategy", async () => { - await assertFailedDeposit(parseUnits("5000"), "price out of range"); - }); - it("Strategist fails to deposit all to strategy", async () => { - await assertFailedDepositAll(parseUnits("5000"), "price out of range"); - }); - it("Strategist should withdraw from strategy with a profit", async () => { - const { - clement, - oSonic, - oSonicVault, - oSonicVaultSigner, - swapXAMOStrategy, - wS, - } = fixture; - const withdrawAmount = parseUnits("4000"); - - const dataBeforeWithdraw = await snapData(); - logSnapData( - dataBeforeWithdraw, - `\nBefore strategist withdraw ${formatUnits(withdrawAmount)} wS` - ); - - const tx = await swapXAMOStrategy - .connect(oSonicVaultSigner) - .withdraw(oSonicVault.address, wS.address, withdrawAmount); - - const dataAfterWithdraw = await snapData(); - logSnapData( - dataAfterWithdraw, - `\nAfter withdraw and before attacker swaps ${formatUnits( - osAmountOut - )} OS back into the pool for wS` - ); - await logProfit(dataBeforeSwap); - - // Get how much OS was burnt - const receipt = await tx.wait(); - const redeemEvent = receipt.events.find( - (e) => e.event === "Withdrawal" && e.args._asset === oSonic.address - ); - log(`\nWithdraw burnt ${formatUnits(redeemEvent.args._amount)} OS`); - - // Attacker swaps the OS back for wS - await poolSwapTokensIn(oSonic, osAmountOut); - - const dataAfterFinalSwap = await snapData(); - logSnapData( - dataAfterFinalSwap, - "\nAfter attacker swaps OS into the pool for wS" - ); - const profit = await logProfit(dataBeforeSwap); - expect(profit, "vault profit").to.gt(0); - - const attackerWsBalanceAfter = await wS.balanceOf(clement.address); - log( - `Attacker's profit/loss ${formatUnits( - attackerWsBalanceAfter.sub(attackerWsBalanceBefore) - )} wS` - ); - }); - }); - describe("When attacker front-run by adding a lot of OS to the pool", () => { - const attackerBalanceBefore = {}; - let dataBeforeSwap; - let wsAmountOut; - beforeEach(async () => { - const { clement, oSonic, oSonicVault, wS } = fixture; - - const osAmountIn = parseUnits("10000000"); - // Mint OS using wS - await oSonicVault.connect(clement).mint(osAmountIn); - - attackerBalanceBefore.os = await oSonic.balanceOf(clement.address); - attackerBalanceBefore.ws = await wS.balanceOf(clement.address); - - dataBeforeSwap = await snapData(); - logSnapData( - dataBeforeSwap, - `\nBefore attacker swaps ${formatUnits( - osAmountIn - )} OS into the pool for wS` - ); - - // Attacker swaps a lot of OS for wS in the pool - // This increases the pool's wS/OS price and decreases the OS/wS price - wsAmountOut = await poolSwapTokensIn(oSonic, osAmountIn); - }); - it("Strategist fails to deposit to strategy", async () => { - await assertFailedDeposit(parseUnits("5000"), "price out of range"); - }); - it("Strategist fails to deposit all to strategy", async () => { - await assertFailedDepositAll(parseUnits("5000"), "price out of range"); - }); - it("Strategist should withdraw from strategy with a profit", async () => { - const { - clement, - oSonic, - oSonicVault, - oSonicVaultSigner, - swapXAMOStrategy, - wS, - } = fixture; - const withdrawAmount = parseUnits("200"); - - const dataBeforeWithdraw = await snapData(); - logSnapData( - dataBeforeWithdraw, - `\nBefore strategist withdraw ${formatUnits(withdrawAmount)} wS` - ); - - const tx = await swapXAMOStrategy - .connect(oSonicVaultSigner) - .withdraw(oSonicVault.address, wS.address, withdrawAmount); - - const dataAfterWithdraw = await snapData(); - logSnapData( - dataAfterWithdraw, - `\nAfter withdraw and before attacker swaps ${formatUnits( - wsAmountOut - )} wS back into the pool for OS` - ); - await logProfit(dataBeforeSwap); - - // Get how much OS was burnt - const receipt = await tx.wait(); - const redeemEvent = receipt.events.find( - (e) => e.event === "Withdrawal" && e.args._asset === oSonic.address - ); - log(`\nWithdraw burnt ${formatUnits(redeemEvent.args._amount)} OS`); - - // Attacker swaps the wS back for OS - await poolSwapTokensIn(wS, wsAmountOut); - - const dataAfterFinalSwap = await snapData(); - logSnapData( - dataAfterFinalSwap, - "\nAfter attacker swaps wS into the pool for OS" - ); - const profit = await logProfit(dataBeforeSwap); - expect(profit, "vault profit").to.gt(0); - - const attackerBalanceAfter = {}; - attackerBalanceAfter.os = await oSonic.balanceOf(clement.address); - attackerBalanceAfter.ws = await wS.balanceOf(clement.address); - log( - `Attacker's profit/loss ${formatUnits( - attackerBalanceAfter.os.sub(attackerBalanceBefore.os) - )} OS and ${formatUnits( - attackerBalanceAfter.ws.sub(attackerBalanceBefore.ws) - )} wS` - ); - }); - }); - }); - - describe("with a lot more OS in the pool", () => { - const loadFixture = createFixtureLoader(swapXAMOFixture, { - wsMintAmount: 5000, - depositToStrategy: true, - balancePool: true, - poolAddOSAmount: 1000000, - }); - beforeEach(async () => { - fixture = await loadFixture(); - }); - it("Vault should fail to deposit wS to AMO strategy", async function () { - await assertFailedDeposit(parseUnits("5000"), "price out of range"); - }); - it("Vault should be able to withdraw all", async () => { - await assertWithdrawAll(); - }); - it("Vault should be able to partially withdraw", async () => { - await assertWithdrawPartial(parseUnits("4000")); - }); - it("Strategist should swap a little assets to the pool", async () => { - await assertSwapAssetsToPool(parseUnits("3")); - }); - it("Strategist should swap enough wS to get the pool close to balanced", async () => { - const { swapXPool } = fixture; - const { _reserve0: wsReserves, _reserve1: osReserves } = - await swapXPool.getReserves(); - // 5% of the extra OS - const osAmount = osReserves.sub(wsReserves).mul(5).div(100); - const wsAmount = osAmount.mul(wsReserves).div(osReserves); - log(`OS amount: ${formatUnits(osAmount)}`); - log(`wS amount: ${formatUnits(wsAmount)}`); - - await assertSwapAssetsToPool(wsAmount); - }); - it("Strategist should swap a lot of assets to the pool", async () => { - await assertSwapAssetsToPool(parseUnits("3000")); - }); - it("Strategist should swap most of the wS owned by the strategy", async () => { - // TODO calculate how much wS should be swapped to get the pool balanced - await assertSwapAssetsToPool(parseUnits("4400")); - }); - it("Strategist should fail to add more wS than owned by the strategy", async () => { - const { swapXAMOStrategy, strategist } = fixture; - - const tx = swapXAMOStrategy - .connect(strategist) - .swapAssetsToPool(parseUnits("2000000")); - - await expect(tx).to.be.revertedWith("Not enough LP tokens in gauge"); - }); - it("Strategist should fail to add more OS to the pool", async () => { - const { swapXAMOStrategy, strategist } = fixture; - - // try swapping OS into the pool - const tx = swapXAMOStrategy - .connect(strategist) - .swapOTokensToPool(parseUnits("0.001")); - - await expect(tx).to.be.revertedWith("OTokens balance worse"); - }); - }); - - describe("with a little more OS in the pool", () => { - const loadFixture = createFixtureLoader(swapXAMOFixture, { - wsMintAmount: 20000, - depositToStrategy: true, - balancePool: true, - poolAddOSAmount: 5000, - }); - beforeEach(async () => { - fixture = await loadFixture(); - }); - it("Vault should deposit wS to AMO strategy", async function () { - await assertDeposit(parseUnits("12000")); - }); - it("Vault should be able to withdraw all", async () => { - await assertWithdrawAll(); - }); - it("Vault should be able to partially withdraw", async () => { - await assertWithdrawPartial(parseUnits("1000")); - }); - it("Strategist should swap a little assets to the pool", async () => { - await assertSwapAssetsToPool(parseUnits("3")); - }); - it("Strategist should swap enough wS to get the pool close to balanced", async () => { - const { swapXPool } = fixture; - const { _reserve0: wsReserves, _reserve1: osReserves } = - await swapXPool.getReserves(); - // 50% of the extra OS in the pool gets close to balanced - const osAmount = osReserves.sub(wsReserves).mul(50).div(100); - const wsAmount = osAmount.mul(wsReserves).div(osReserves); - - await assertSwapAssetsToPool(wsAmount); - }); - it("Strategist should fail to add too much wS to the pool", async () => { - const { swapXAMOStrategy, strategist } = fixture; - - const dataBefore = await snapData(); - await logSnapData(dataBefore, "Before swapping assets to the pool"); - - // try the extra OS in the pool - const tx = swapXAMOStrategy - .connect(strategist) - .swapAssetsToPool(parseUnits("5000")); - - await expect(tx).to.be.revertedWith("Assets overshot peg"); - }); - it("Strategist should fail to add zero wS to the pool", async () => { - const { swapXAMOStrategy, strategist } = fixture; - - const tx = swapXAMOStrategy.connect(strategist).swapAssetsToPool(0); - - await expect(tx).to.be.revertedWith("Must swap something"); - }); - it("Strategist should fail to add more OS to the pool", async () => { - const { swapXAMOStrategy, strategist } = fixture; - - // try swapping OS into the pool - const tx = swapXAMOStrategy - .connect(strategist) - .swapOTokensToPool(parseUnits("0.001")); - - await expect(tx).to.be.revertedWith("OTokens balance worse"); - }); - }); - - describe("with a lot more wS in the pool", () => { - const loadFixture = createFixtureLoader(swapXAMOFixture, { - wsMintAmount: 5000, - depositToStrategy: true, - balancePool: true, - poolAddwSAmount: 2000000, - }); - beforeEach(async () => { - fixture = await loadFixture(); - }); - it("Vault should fail to deposit wS to strategy", async function () { - await assertFailedDeposit(parseUnits("6000"), "price out of range"); - }); - it("Vault should be able to withdraw all", async () => { - await assertWithdrawAll(); - }); - it("Vault should be able to partially withdraw", async () => { - await assertWithdrawPartial(parseUnits("1000")); - }); - it("Strategist should swap a little OS to the pool", async () => { - const osAmount = parseUnits("0.3"); - await assertSwapOTokensToPool(osAmount, fixture); - }); - it("Strategist should swap a lot of OS to the pool", async () => { - const osAmount = parseUnits("5000"); - await assertSwapOTokensToPool(osAmount, fixture); - }); - it("Strategist should get the pool close to balanced", async () => { - const { swapXPool } = fixture; - const { _reserve0: wsReserves, _reserve1: osReserves } = - await swapXPool.getReserves(); - // 32% of the extra wS in the pool gets pretty close to balanced - const osAmount = wsReserves.sub(osReserves).mul(32).div(100); - - await assertSwapOTokensToPool(osAmount); - }); - it("Strategist should fail to add so much OS that is overshoots", async () => { - const { swapXAMOStrategy, strategist } = fixture; - - // try swapping wS into the pool - const tx = swapXAMOStrategy - .connect(strategist) - .swapOTokensToPool(parseUnits("999990")); - - await expect(tx).to.be.revertedWith("OTokens overshot peg"); - }); - it("Strategist should fail to add more wS to the pool", async () => { - const { swapXAMOStrategy, strategist } = fixture; - - // try swapping wS into the pool - const tx = swapXAMOStrategy - .connect(strategist) - .swapAssetsToPool(parseUnits("0.0001")); - - await expect(tx).to.be.revertedWith("Assets balance worse"); - }); - }); - - describe("with a little more wS in the pool", () => { - const loadFixture = createFixtureLoader(swapXAMOFixture, { - wsMintAmount: 20000, - depositToStrategy: true, - balancePool: true, - poolAddwSAmount: 20000, - }); - beforeEach(async () => { - fixture = await loadFixture(); - }); - it("Vault should deposit wS to AMO strategy", async function () { - await assertDeposit(parseUnits("18000")); - }); - it("Vault should be able to withdraw all", async () => { - await assertWithdrawAll(); - }); - it("Vault should be able to partially withdraw", async () => { - await assertWithdrawPartial(parseUnits("1000")); - }); - it("Strategist should swap a little OS to the pool", async () => { - const osAmount = parseUnits("8"); - await assertSwapOTokensToPool(osAmount, fixture); - }); - it("Strategist should get the pool close to balanced", async () => { - const { swapXPool } = fixture; - - const { _reserve0: wsReserves, _reserve1: osReserves } = - await swapXPool.getReserves(); - // 50% of the extra wS in the pool gets pretty close to balanced - const osAmount = wsReserves.sub(osReserves).mul(50).div(100); - - await assertSwapOTokensToPool(osAmount, fixture); - }); - it("Strategist should fail to add zero OS to the pool", async () => { - const { swapXAMOStrategy, strategist } = fixture; - - const tx = swapXAMOStrategy.connect(strategist).swapOTokensToPool(0); - - await expect(tx).to.be.revertedWith("Must swap something"); - }); - it("Strategist should fail to add too much OS to the pool", async () => { - const { swapXAMOStrategy, strategist } = fixture; - - // Add OS to the pool - const tx = swapXAMOStrategy - .connect(strategist) - .swapOTokensToPool(parseUnits("11000")); - - await expect(tx).to.be.revertedWith("OTokens overshot peg"); - }); - it("Strategist should fail to add more wS to the pool", async () => { - const { swapXAMOStrategy, strategist } = fixture; - - // try swapping wS into the pool - const tx = swapXAMOStrategy - .connect(strategist) - .swapAssetsToPool(parseUnits("0.0001")); - - await expect(tx).to.be.revertedWith("Assets balance worse"); - }); - }); - - describe("with the strategy owning a small percentage of the pool", () => { - const loadFixture = createFixtureLoader(swapXAMOFixture, { - wsMintAmount: 5000, - depositToStrategy: true, - balancePool: true, - }); - let dataBefore; - beforeEach(async () => { - fixture = await loadFixture(); - - const { clement, wS, oSonic, oSonicVault, swapXPool } = fixture; - - // Other users adds a lot more liquidity to the pool - const bigAmount = parseUnits("1000000"); - // transfer wS to the pool - await wS.connect(clement).transfer(swapXPool.address, bigAmount); - // Mint OS with wS - await oSonicVault.connect(clement).mint(bigAmount.mul(5)); - // transfer OS to the pool - await oSonic.connect(clement).transfer(swapXPool.address, bigAmount); - // mint pool LP tokens - await swapXPool.connect(clement).mint(clement.address); - - dataBefore = await snapData(); - await logSnapData(dataBefore); - }); - it("a lot of OS is swapped into the pool", async () => { - const { oSonic, swapXAMOStrategy, wS } = fixture; - - // Swap OS into the pool and wS out - await poolSwapTokensIn(oSonic, parseUnits("1005000")); - - await logSnapData(await snapData(), "\nAfter swapping OS into the pool"); - - // Assert the strategy's balance - expect( - await swapXAMOStrategy.checkBalance(wS.address), - "Strategy's check balance" - ).to.withinRange(dataBefore.stratBalance, dataBefore.stratBalance.add(1)); - - // Swap wS into the pool and OS out - await poolSwapTokensIn(wS, parseUnits("2000000")); - - await logSnapData(await snapData(), "\nAfter swapping wS into the pool"); - - // Assert the strategy's balance - expect( - await swapXAMOStrategy.checkBalance(wS.address), - "Strategy's check balance" - ).to.withinRange(dataBefore.stratBalance, dataBefore.stratBalance.add(1)); - }); - it("a lot of wS is swapped into the pool", async () => { - const { swapXAMOStrategy, oSonic, wS } = fixture; - - // Swap wS into the pool and OS out - await poolSwapTokensIn(wS, parseUnits("1006000")); - - await logSnapData(await snapData(), "\nAfter swapping wS into the pool"); - - // Assert the strategy's balance - expect( - await swapXAMOStrategy.checkBalance(wS.address), - "Strategy's check balance" - ).to.withinRange(dataBefore.stratBalance, dataBefore.stratBalance.add(1)); - - // Swap OS into the pool and wS out - await poolSwapTokensIn(oSonic, parseUnits("1005000")); - - await logSnapData(await snapData(), "\nAfter swapping OS into the pool"); - - // Assert the strategy's balance - expect( - await swapXAMOStrategy.checkBalance(wS.address), - "Strategy's check balance" - ).to.withinRange(dataBefore.stratBalance, dataBefore.stratBalance.add(1)); - }); - }); - - describe("with an insolvent vault", () => { - const loadFixture = createFixtureLoader(swapXAMOFixture, { - wsMintAmount: 5000000, - depositToStrategy: false, - }); - beforeEach(async () => { - fixture = await loadFixture(); - - const { oSonicVault, oSonicVaultSigner, swapXAMOStrategy, wS } = fixture; - - // Deposit a little to the strategy - const littleAmount = parseUnits("100"); - await wS - .connect(oSonicVaultSigner) - .transfer(swapXAMOStrategy.address, littleAmount); - await swapXAMOStrategy - .connect(oSonicVaultSigner) - .deposit(wS.address, littleAmount); - - const totalAssets = await oSonicVault.totalValue(); - // Calculate a 0.21% (21 basis points) loss - const lossAmount = totalAssets.mul(21).div(10000); - await wS.connect(oSonicVaultSigner).transfer(addresses.dead, lossAmount); - expect( - await wS.balanceOf(oSonicVault.address), - "Must have enough wS in vault to make insolvent" - ).to.gte(lossAmount); - }); - it("Should fail to deposit", async () => { - const { oSonicVaultSigner, swapXAMOStrategy, wS } = fixture; - - // Vault calls deposit on the strategy - const depositAmount = parseUnits("10"); - await wS - .connect(oSonicVaultSigner) - .transfer(swapXAMOStrategy.address, depositAmount); - const tx = swapXAMOStrategy - .connect(oSonicVaultSigner) - .deposit(wS.address, depositAmount); - - await expect(tx).to.be.revertedWith("Protocol insolvent"); - }); - it("Should fail to withdraw", async () => { - const { oSonicVault, oSonicVaultSigner, swapXAMOStrategy, wS } = fixture; - - // Vault withdraws from the strategy - const tx = swapXAMOStrategy - .connect(oSonicVaultSigner) - .withdraw(oSonicVault.address, wS.address, parseUnits("10")); - - await expect(tx).to.be.revertedWith("Protocol insolvent"); - }); - it("Should withdraw all", async () => { - const { oSonicVaultSigner, swapXAMOStrategy } = fixture; - - // Vault withdraw alls from the strategy - const tx = swapXAMOStrategy.connect(oSonicVaultSigner).withdrawAll(); - - await expect(tx).to.not.revertedWith("Protocol insolvent"); - }); - it("Should fail to swap assets to the pool", async () => { - const { swapXAMOStrategy, strategist } = fixture; - - const tx = swapXAMOStrategy - .connect(strategist) - .swapAssetsToPool(parseUnits("10")); - - await expect(tx).to.be.revertedWith("Protocol insolvent"); - }); - it("Should fail to swap OS to the pool", async () => { - const { swapXAMOStrategy, strategist } = fixture; - - const tx = swapXAMOStrategy - .connect(strategist) - .swapOTokensToPool(parseUnits("10")); - - await expect(tx).to.be.revertedWith("Protocol insolvent"); - }); - }); - - const poolSwapTokensIn = async (tokenIn, amountIn) => { - const { clement, swapXPool, wS } = fixture; - const amountOut = await swapXPool.getAmountOut(amountIn, tokenIn.address); - await tokenIn.connect(clement).transfer(swapXPool.address, amountIn); - if (tokenIn.address == wS.address) { - await swapXPool.swap(0, amountOut, clement.address, "0x"); - } else { - await swapXPool.swap(amountOut, 0, clement.address, "0x"); - } - - return amountOut; - }; - - const precision = parseUnits("1", 18); - // Calculate the value of wS and OS tokens assuming the pool is balanced - const calcReserveValue = (reserves) => { - const k = calcInvariant(reserves); - - // If x = y, let’s denote x = y = z (where z is the common reserve value) - // Substitute z into the invariant: - // k = z^3 * z + z * z^3 - // k = 2 * z^4 - // Going back the other way to calculate the common reserve value z - // z = (k / 2) ^ (1/4) - // the total value of the pool when x = y is 2 * z, which is 2 * (k / 2) ^ (1/4) - const zSquared = sqrt(k.mul(precision).div(2)); - const z = sqrt(zSquared.mul(precision)); - return z.mul(2); - }; - - const calcInvariant = (reserves) => { - const x = reserves.ws; - const y = reserves.os; - const a = x.mul(y).div(precision); - const b = x.mul(x).div(precision).add(y.mul(y).div(precision)); - const k = a.mul(b).div(precision); - - return k; - }; - - // Babylonian square root function for Ethers.js BigNumber - function sqrt(value) { - // Convert input to BigNumber if it isn't already - let bn = ethers.BigNumber.from(value); - - // Handle edge cases - if (bn.lt(0)) { - throw new Error("Square root of negative number is not supported"); - } - if (bn.eq(0)) { - return ethers.BigNumber.from(0); - } - - // Initial guess (number / 2) - let guess = bn.div(2); - - // Define precision threshold (in wei scale, 10^-18) - const epsilon = ethers.BigNumber.from("1"); // 1 wei precision - - // Keep refining until we reach desired precision - while (true) { - // Babylonian method: nextGuess = (guess + number/guess) / 2 - // Using mul and div for BigNumber arithmetic - let numerator = guess.add(bn.div(guess)); - let nextGuess = numerator.div(2); - - // Calculate absolute difference - let diff = nextGuess.gt(guess) - ? nextGuess.sub(guess) - : guess.sub(nextGuess); - - // If difference is less than epsilon, we're done - if (diff.lte(epsilon)) { - return nextGuess; - } - - // Update guess for next iteration - guess = nextGuess; - } - } - - const snapData = async () => { - const { oSonicVault, swapXAMOStrategy, oSonic, swapXPool, swapXGauge, wS } = - fixture; - - const stratBalance = await swapXAMOStrategy.checkBalance(wS.address); - const osSupply = await oSonic.totalSupply(); - const vaultAssets = await oSonicVault.totalValue(); - const poolSupply = await swapXPool.totalSupply(); - const { _reserve0: wsReserves, _reserve1: osReserves } = - await swapXPool.getReserves(); - const reserves = { ws: wsReserves, os: osReserves }; - // Amount of wS bought from selling 1 OS - const wsAmount = await swapXPool.getAmountOut( - parseUnits("1"), - oSonic.address - ); - // OS/wS price = wS / OS - const sellPrice = wsAmount; - - // Amount of wS sold from buying 1 OS - const osAmount = await swapXPool.getAmountOut(parseUnits("1"), wS.address); - // OS/wS price = wS / OS - const buyPrice = parseUnits("1", 36).div(osAmount); - - const k = calcInvariant(reserves); - const stratGaugeBalance = await swapXGauge.balanceOf( - swapXAMOStrategy.address - ); - const gaugeSupply = await swapXGauge.totalSupply(); - const vaultWSBalance = await wS.balanceOf(oSonicVault.address); - const stratWSBalance = await wS.balanceOf(swapXAMOStrategy.address); - - return { - stratBalance, - osSupply, - vaultAssets, - poolSupply, - reserves: { ws: wsReserves, os: osReserves }, - buyPrice, - sellPrice, - stratGaugeBalance, - gaugeSupply, - vaultWSBalance, - stratWSBalance, - k, - }; - }; - - const logSnapData = async (data, message) => { - const totalReserves = data.reserves.ws.add(data.reserves.os); - const reserversPercentage = { - ws: data.reserves.ws.mul(10000).div(totalReserves), - os: data.reserves.os.mul(10000).div(totalReserves), - }; - const gaugePercentage = data.gaugeSupply.eq(0) - ? 0 - : data.stratGaugeBalance.mul(10000).div(data.gaugeSupply); - if (message) { - log(message); - } - log(`Strategy balance : ${formatUnits(data.stratBalance)}`); - log(`OS supply : ${formatUnits(data.osSupply)}`); - log(`Vault assets : ${formatUnits(data.vaultAssets)}`); - log(`pool supply : ${formatUnits(data.poolSupply)}`); - log( - `reserves wS : ${formatUnits(data.reserves.ws)} ${formatUnits( - reserversPercentage.ws, - 2 - )}%` - ); - log( - `reserves OS : ${formatUnits(data.reserves.os)} ${formatUnits( - reserversPercentage.os, - 2 - )}%` - ); - log(`buy price : ${formatUnits(data.buyPrice)} OS/wS`); - log(`sell price : ${formatUnits(data.sellPrice)} OS/wS`); - log(`Invariant K : ${formatUnits(data.k)}`); - log( - `strat gauge balance : ${formatUnits( - data.stratGaugeBalance - )} ${formatUnits(gaugePercentage, 2)}%` - ); - log(`gauge supply : ${formatUnits(data.gaugeSupply)}`); - log(`vault wS balance : ${formatUnits(data.vaultWSBalance)}`); - }; - - const logProfit = async (dataBefore) => { - const { oSonic, oSonicVault, swapXAMOStrategy, wS } = fixture; - - const stratBalanceAfter = await swapXAMOStrategy.checkBalance(wS.address); - const osSupplyAfter = await oSonic.totalSupply(); - const vaultAssetsAfter = await oSonicVault.totalValue(); - const profit = vaultAssetsAfter - .sub(dataBefore.vaultAssets) - .add(dataBefore.osSupply.sub(osSupplyAfter)); - - log( - `Change strat balance: ${formatUnits( - stratBalanceAfter.sub(dataBefore.stratBalance) - )}` - ); - log( - `Change vault assets : ${formatUnits( - vaultAssetsAfter.sub(dataBefore.vaultAssets) - )}` - ); - log( - `Change OS supply : ${formatUnits( - osSupplyAfter.sub(dataBefore.osSupply) - )}` - ); - log(`Profit : ${formatUnits(profit)}`); - - return profit; - }; - - const assertChangedData = async (dataBefore, delta) => { - const { oSonic, oSonicVault, swapXAMOStrategy, swapXPool, swapXGauge, wS } = - fixture; - - if (delta.stratBalance != undefined) { - const expectedStratBalance = dataBefore.stratBalance.add( - delta.stratBalance - ); - log(`Expected strategy balance: ${formatUnits(expectedStratBalance)}`); - expect(await swapXAMOStrategy.checkBalance(wS.address)).to.withinRange( - expectedStratBalance.sub(15), - expectedStratBalance.add(15), - "Strategy's check balance" - ); - } - - if (delta.osSupply != undefined) { - const expectedSupply = dataBefore.osSupply.add(delta.osSupply); - expect(await oSonic.totalSupply(), "OSonic total supply").to.equal( - expectedSupply - ); - } - - // Check Vault's wS balance - if (delta.vaultWSBalance != undefined) { - expect(await wS.balanceOf(oSonicVault.address)).to.equal( - dataBefore.vaultWSBalance.add(delta.vaultWSBalance), - "Vault's wS balance" - ); - } - - // Check the pool's reserves - if (delta.reserves != undefined) { - const { _reserve0: wsReserves, _reserve1: osReserves } = - await swapXPool.getReserves(); - - // If the wS reserves delta is a function, call it to check the wS reserves - if (typeof delta.reserves.ws == "function") { - // Call test function to check the wS reserves - delta.reserves.ws(wsReserves); - } else { - expect(wsReserves, "wS reserves").to.equal( - dataBefore.reserves.ws.add(delta.reserves.ws) - ); - } - // Check OS reserves delta - expect(osReserves, "OS reserves").to.equal( - dataBefore.reserves.os.add(delta.reserves.os) - ); - } - - if (delta.stratGaugeBalance) { - // Check the strategy's gauge balance - const expectedStratGaugeBalance = dataBefore.stratGaugeBalance.add( - delta.stratGaugeBalance - ); - expect( - await swapXGauge.balanceOf(swapXAMOStrategy.address) - ).to.withinRange( - expectedStratGaugeBalance.sub(1), - expectedStratGaugeBalance.add(1), - "Strategy's gauge balance" - ); - } - }; - - async function assertDeposit(wsDepositAmount) { - const { - clement, - swapXAMOStrategy, - oSonic, - oSonicVault, - swapXPool, - oSonicVaultSigner, - wS, - } = fixture; - - await oSonicVault.connect(clement).mint(wsDepositAmount); - - const dataBefore = await snapData(); - await logSnapData(dataBefore, "\nBefore depositing wS to strategy"); - - const { lpMintAmount, osMintAmount } = await calcOSMintAmount( - wsDepositAmount - ); - - // Vault transfers wS to strategy - await wS - .connect(oSonicVaultSigner) - .transfer(swapXAMOStrategy.address, wsDepositAmount); - // Vault calls deposit on the strategy - const tx = await swapXAMOStrategy - .connect(oSonicVaultSigner) - .deposit(wS.address, wsDepositAmount); - - await logSnapData( - await snapData(), - `\nAfter depositing ${formatUnits(wsDepositAmount)} wS to strategy` - ); - await logProfit(dataBefore); - - // Check emitted events - await expect(tx) - .to.emit(swapXAMOStrategy, "Deposit") - .withArgs(wS.address, swapXPool.address, wsDepositAmount); - await expect(tx) - .to.emit(swapXAMOStrategy, "Deposit") - .withArgs(oSonic.address, swapXPool.address, osMintAmount); - - // Calculate the value of the wS and OS tokens added to the pool if the pool was balanced - const depositValue = calcReserveValue({ - ws: wsDepositAmount, - os: osMintAmount, - }); - // log(`Value of deposit: ${formatUnits(depositValue)}`); - - await assertChangedData(dataBefore, { - stratBalance: depositValue, - osSupply: osMintAmount, - reserves: { ws: wsDepositAmount, os: osMintAmount }, - vaultWSBalance: wsDepositAmount.mul(-1), - gaugeSupply: lpMintAmount, - }); - - expect( - await swapXPool.balanceOf(swapXAMOStrategy.address), - "Strategy's pool LP balance" - ).to.equal(0); - expect( - await oSonic.balanceOf(swapXAMOStrategy.address), - "Strategy's OS balance" - ).to.equal(0); - } - - async function assertFailedDeposit(wsDepositAmount, errorMessage) { - const { wS, oSonicVaultSigner, swapXAMOStrategy } = fixture; - - const dataBefore = await snapData(); - await logSnapData(dataBefore, "\nBefore depositing wS to strategy"); - - // Vault transfers wS to strategy - await wS - .connect(oSonicVaultSigner) - .transfer(swapXAMOStrategy.address, wsDepositAmount); - - // Vault calls deposit on the strategy - const tx = swapXAMOStrategy - .connect(oSonicVaultSigner) - .deposit(wS.address, wsDepositAmount); - - await expect(tx, "deposit to strategy").to.be.revertedWith(errorMessage); - } - - async function assertFailedDepositAll(wsDepositAmount, errorMessage) { - const { wS, oSonicVaultSigner, swapXAMOStrategy } = fixture; - - const dataBefore = await snapData(); - await logSnapData(dataBefore, "\nBefore depositing all wS to strategy"); - - // Vault transfers wS to strategy - await wS - .connect(oSonicVaultSigner) - .transfer(swapXAMOStrategy.address, wsDepositAmount); - - // Vault calls depositAll on the strategy - const tx = swapXAMOStrategy.connect(oSonicVaultSigner).depositAll(); - - await expect(tx, "depositAll to strategy").to.be.revertedWith(errorMessage); - } - - async function assertWithdrawAll() { - const { swapXAMOStrategy, swapXPool, oSonic, oSonicVaultSigner, wS } = - fixture; - - const dataBefore = await snapData(); - await logSnapData(dataBefore); - - const { osBurnAmount, wsWithdrawAmount } = await calcWithdrawAllAmounts(); - - // Now try to withdraw all the wS from the strategy - const tx = await swapXAMOStrategy.connect(oSonicVaultSigner).withdrawAll(); - - await logSnapData(await snapData(), "\nAfter full withdraw"); - await logProfit(dataBefore); - - // Check emitted events - await expect(tx) - .to.emit(swapXAMOStrategy, "Withdrawal") - .withArgs(wS.address, swapXPool.address, wsWithdrawAmount); - await expect(tx) - .to.emit(swapXAMOStrategy, "Withdrawal") - .withArgs(oSonic.address, swapXPool.address, osBurnAmount); - - // Calculate the value of the wS and OS tokens removed from the pool if the pool was balanced - const withdrawValue = calcReserveValue({ - ws: wsWithdrawAmount, - os: osBurnAmount, - }); - - await assertChangedData(dataBefore, { - stratBalance: withdrawValue.mul(-1), - osSupply: osBurnAmount.mul(-1), - reserves: { ws: wsWithdrawAmount.mul(-1), os: osBurnAmount.mul(-1) }, - vaultWSBalance: wsWithdrawAmount, - stratGaugeBalance: dataBefore.stratGaugeBalance.mul(-1), - }); - - expect( - wsWithdrawAmount.add(osBurnAmount), - "wS withdraw and OS burnt >= strategy balance" - ).to.gte(dataBefore.stratBalance); - - expect( - await swapXPool.balanceOf(swapXAMOStrategy.address), - "Strategy's pool LP balance" - ).to.equal(0); - expect( - await oSonic.balanceOf(swapXAMOStrategy.address), - "Strategy's OS balance" - ).to.equal(0); - } - - async function assertWithdrawPartial(wsWithdrawAmount) { - const { - swapXAMOStrategy, - oSonic, - swapXPool, - oSonicVault, - oSonicVaultSigner, - wS, - } = fixture; - - const dataBefore = await snapData(); - - const { lpBurnAmount, osBurnAmount } = await calcOSWithdrawAmount( - wsWithdrawAmount - ); - - // Now try to withdraw the wS from the strategy - const tx = await swapXAMOStrategy - .connect(oSonicVaultSigner) - .withdraw(oSonicVault.address, wS.address, wsWithdrawAmount); - - await logSnapData( - await snapData(), - `\nAfter withdraw of ${formatUnits(wsWithdrawAmount)}` - ); - await logProfit(dataBefore); - - // Check emitted events - await expect(tx) - .to.emit(swapXAMOStrategy, "Withdrawal") - .withArgs(wS.address, swapXPool.address, wsWithdrawAmount); - await expect(tx).to.emit(swapXAMOStrategy, "Withdrawal").withNamedArgs({ - _asset: oSonic.address, - _pToken: swapXPool.address, - }); - - // Calculate the value of the wS and OS tokens removed from the pool if the pool was balanced - const withdrawValue = calcReserveValue({ - ws: wsWithdrawAmount, - os: osBurnAmount, - }); - - await assertChangedData(dataBefore, { - stratBalance: withdrawValue.mul(-1), - osSupply: osBurnAmount.mul(-1), - reserves: { - ws: (actualWsReserve) => { - const expectedWsReserves = - dataBefore.reserves.ws.sub(wsWithdrawAmount); +const { + shouldBehaveLikeAlgebraAmoStrategy, +} = require("../../behaviour/algebraAmoStrategy"); +const { createFixtureLoader } = require("../../_fixture"); - expect(actualWsReserve).to.withinRange( - expectedWsReserves.sub(50), - expectedWsReserves, - "wS reserves" - ); - }, - os: osBurnAmount.mul(-1), +describe("Sonic Fork Test: SwapX AMO Strategy", function () { + shouldBehaveLikeAlgebraAmoStrategy(async () => { + const scenarioConfig = { + attackerFrontRun: { + moderateAssetIn: "20000", + largeAssetIn: "10000000", + largeOTokenIn: "10000000", }, - vaultWSBalance: wsWithdrawAmount, - gaugeSupply: lpBurnAmount.mul(-1), - }); - - expect( - await swapXPool.balanceOf(swapXAMOStrategy.address), - "Strategy's pool LP balance" - ).to.equal(0); - expect( - await oSonic.balanceOf(swapXAMOStrategy.address), - "Strategy's OS balance" - ).to.equal(0); - } - - async function assertSwapAssetsToPool(wsAmount) { - const { oSonic, swapXAMOStrategy, swapXPool, strategist, wS } = fixture; - - const dataBefore = await snapData(); - await logSnapData( - dataBefore, - `Before swapping ${formatUnits(wsAmount)} wS into the pool` - ); - - const { lpBurnAmount: expectedLpBurnAmount, osBurnAmount: osBurnAmount1 } = - await calcOSWithdrawAmount(wsAmount); - // TODO this is not accurate as the liquidity needs to be removed first - const osBurnAmount2 = await swapXPool.getAmountOut(wsAmount, wS.address); - const osBurnAmount = osBurnAmount1.add(osBurnAmount2); - - // Swap wS to the pool and burn the received OS from the pool - const tx = await swapXAMOStrategy - .connect(strategist) - .swapAssetsToPool(wsAmount); - - await logSnapData(await snapData(), "\nAfter swapping assets to the pool"); - await logProfit(dataBefore); - - // Check emitted event - await expect(tx).to.emittedEvent("SwapAssetsToPool", [ - (actualWsAmount) => { - expect(actualWsAmount).to.withinRange( - wsAmount.sub(1), - wsAmount.add(1), - "SwapAssetsToPool event wsAmount" - ); + bootstrapPool: { + smallAssetBootstrapIn: "5000", + mediumAssetBootstrapIn: "20000", + largeAssetBootstrapIn: "5000000", }, - expectedLpBurnAmount, - (actualOsBurnAmount) => { - // TODO this can be tightened once osBurnAmount is more accurately calculated - expect(actualOsBurnAmount).to.approxEqualTolerance( - osBurnAmount, - 10, - "SwapAssetsToPool event osBurnt" - ); + mintValues: { + extraSmall: "50", + extraSmallPlus: "100", + small: "2000", + medium: "5000", }, - ]); - - await assertChangedData( - dataBefore, - { - // stratBalance: osBurnAmount.mul(-1), - vaultWSBalance: 0, - stratGaugeBalance: 0, + poolImbalance: { + lotMoreOToken: { addOToken: 1000000 }, + littleMoreOToken: { addOToken: 5000 }, + lotMoreAsset: { addAsset: 2000000 }, + littleMoreAsset: { addAsset: 20000 }, }, - fixture - ); - - expect( - await swapXPool.balanceOf(swapXAMOStrategy.address), - "Strategy's pool LP balance" - ).to.equal(0); - expect( - await oSonic.balanceOf(swapXAMOStrategy.address), - "Strategy's OS balance" - ).to.equal(0); - } - - async function assertSwapOTokensToPool(osAmount) { - const { oSonic, swapXPool, swapXAMOStrategy, strategist } = fixture; - - const dataBefore = await snapData(); - await logSnapData(dataBefore, "Before swapping OTokens to the pool"); - - // Mint OS and swap into the pool, then mint more OS to add with the wS swapped out - const tx = await swapXAMOStrategy - .connect(strategist) - .swapOTokensToPool(osAmount); - - // Check emitted event - await expect(tx) - .emit(swapXAMOStrategy, "SwapOTokensToPool") - .withNamedArgs({ osMinted: osAmount }); - - await logSnapData(await snapData(), "\nAfter swapping OTokens to the pool"); - await logProfit(dataBefore); - - await assertChangedData( - dataBefore, - { - // stratBalance: osBurnAmount.mul(-1), - vaultWSBalance: 0, - stratGaugeBalance: 0, + smallPoolShare: { + bootstrapAssetSwapIn: "10000000", + bigLiquidityAsset: "1000000", + oTokenBuffer: "1800000", + stressSwapOToken: "1005000", + stressSwapAsset: "2000000", + stressSwapAssetAlt: "1006000", }, - fixture - ); - - expect( - await swapXPool.balanceOf(swapXAMOStrategy.address), - "Strategy's pool LP balance" - ).to.equal(0); - expect( - await oSonic.balanceOf(swapXAMOStrategy.address), - "Strategy's OS balance" - ).to.equal(0); - } - - // Calculate the minted OS amount for a deposit - async function calcOSMintAmount(wsDepositAmount) { - const { swapXPool } = fixture; - - // Get the reserves of the pool - const { _reserve0: wsReserves, _reserve1: osReserves } = - await swapXPool.getReserves(); - - const osMintAmount = wsDepositAmount.mul(osReserves).div(wsReserves); - log(`OS mint amount : ${formatUnits(osMintAmount)}`); - - const lpTotalSupply = await swapXPool.totalSupply(); - const lpMintAmount = wsDepositAmount.mul(lpTotalSupply).div(wsReserves); - - return { lpMintAmount, osMintAmount }; - } - - // Calculate the amount of OS burnt from a withdraw - async function calcOSWithdrawAmount(wsWithdrawAmount) { - const { swapXPool } = fixture; - - // Get the reserves of the pool - const { _reserve0: wsReserves, _reserve1: osReserves } = - await swapXPool.getReserves(); - - // lp tokens to burn = wS withdrawn * total LP supply / wS pool balance - const totalLpSupply = await swapXPool.totalSupply(); - const lpBurnAmount = wsWithdrawAmount - .mul(totalLpSupply) - .div(wsReserves) - .add(1); - // OS to burn = LP tokens to burn * OS reserves / total LP supply - const osBurnAmount = lpBurnAmount.mul(osReserves).div(totalLpSupply); - - log(`OS burn amount : ${formatUnits(osBurnAmount)}`); - - return { lpBurnAmount, osBurnAmount }; - } - - // Calculate the OS and wS amounts from a withdrawAll - async function calcWithdrawAllAmounts() { - const { swapXAMOStrategy, swapXGauge, swapXPool } = fixture; - - // Get the reserves of the pool - const { _reserve0: wsReserves, _reserve1: osReserves } = - await swapXPool.getReserves(); - const strategyLpAmount = await swapXGauge.balanceOf( - swapXAMOStrategy.address - ); - const totalLpSupply = await swapXPool.totalSupply(); - - // wS to withdraw = wS pool balance * strategy LP amount / total pool LP amount - const wsWithdrawAmount = wsReserves - .mul(strategyLpAmount) - .div(totalLpSupply); - // OS to burn = OS pool balance * strategy LP amount / total pool LP amount - const osBurnAmount = osReserves.mul(strategyLpAmount).div(totalLpSupply); - - log(`wS withdraw amount : ${formatUnits(wsWithdrawAmount)}`); - log(`OS burn amount : ${formatUnits(osBurnAmount)}`); + rebalanceProbe: { + frontRun: { + depositAmount: "200000", + failedDepositAmount: "5000", + failedDepositAllAmount: "5000", + tiltSeedWithdrawAmount: "6000", + assetTiltWithdrawAmount: "4000", + oTokenTiltWithdrawAmount: "200", + }, + lotMoreOToken: { + failedDepositAmount: "5000", + partialWithdrawAmount: "4000", + smallSwapAssetsToPool: "3", + largeSwapAssetsToPool: "3000", + nearMaxSwapAssetsToPool: "4400", + excessiveSwapAssetsToPool: "2000000", + disallowedSwapOTokensToPool: "0.001", + }, + littleMoreOToken: { + depositAmount: "12000", + partialWithdrawAmount: "1000", + smallSwapAssetsToPool: "3", + excessiveSwapAssetsToPool: "5000", + disallowedSwapOTokensToPool: "0.001", + }, + lotMoreAsset: { + failedDepositAmount: "6000", + partialWithdrawAmount: "1000", + smallSwapOTokensToPool: "0.3", + largeSwapOTokensToPool: "5000", + overshootSwapOTokensToPool: "999990", + disallowedSwapAssetsToPool: "0.0001", + }, + littleMoreAsset: { + depositAmount: "18000", + partialWithdrawAmount: "1000", + smallSwapOTokensToPool: "8", + overshootSwapOTokensToPool: "11000", + disallowedSwapAssetsToPool: "0.0001", + }, + }, + insolvent: { + swapOTokensToPool: "10", + }, + harvest: { + collectedBy: "harvester", + }, + }; return { - wsWithdrawAmount, - osBurnAmount, + scenarioConfig, + loadFixture: async ({ + assetMintAmount = 0, + depositToStrategy = false, + balancePool = false, + poolAddAssetAmount = 0, + poolAddOTokenAmount = 0, + } = {}) => { + const fixtureLoader = await createFixtureLoader(swapXAMOFixture, { + wsMintAmount: assetMintAmount, + depositToStrategy, + balancePool, + poolAddwSAmount: poolAddAssetAmount, + poolAddOSAmount: poolAddOTokenAmount, + }); + + const fixture = await fixtureLoader(); + const oTokenPoolIndex = + (await fixture.swapXPool.token0()) === fixture.oSonic.address ? 0 : 1; + + return { + assetToken: fixture.wS, // address of the asset token in the pool + oToken: fixture.oSonic, // address of the oToken in the pool + rewardToken: fixture.swpx, // address of the reward token + amoStrategy: fixture.swapXAMOStrategy, // address of the strategy + pool: fixture.swapXPool, + gauge: fixture.swapXGauge, // address of the gauge + governor: fixture.governor, // address of the governor + timelock: fixture.governor, // address of the timelock + strategist: fixture.strategist, // address of the strategist + nick: fixture.nick, // nick's address + oTokenPoolIndex, // index of the oToken in the pool + vaultSigner: fixture.oSonicVaultSigner, // address of the vault signer + vault: fixture.oSonicVault, // address of the vault + harvester: fixture.harvester, // address of the harvester + scenarioConfig, + }; + }, }; - } + }); }); diff --git a/contracts/utils/addresses.js b/contracts/utils/addresses.js index 79f1889f08..199aa2f28a 100644 --- a/contracts/utils/addresses.js +++ b/contracts/utils/addresses.js @@ -359,6 +359,19 @@ addresses.mainnet.toConsensus.consolidation = addresses.mainnet.toConsensus.withdrawals = "0x00000961Ef480Eb55e80D19ad83579A64c007002"; +// Supernova AMM +addresses.mainnet.supernovaPairFactory = + "0x5aef44edfc5a7edd30826c724ea12d7be15bdc30"; +addresses.mainnet.supernovaGaugeManager = + "0x19a410046Afc4203AEcE5fbFc7A6Ac1a4F517AE2"; +addresses.mainnet.supernovaToken = "0x00Da8466B296E382E5Da2Bf20962D0cB87200c78"; + +addresses.mainnet.SupernovaOETHWETH = {}; +addresses.mainnet.SupernovaOETHWETH.pool = + "0x6c4ced4DE136538D10CD805ff68cdE69a52469Fd"; +addresses.mainnet.SupernovaOETHWETH.gauge = + "0xE9eAc35efB37Bd839413c5b29A26C6B32AdAE1De"; + // Mainnet Merkl addresses.mainnet.CampaignCreator = "0x8BB4C975Ff3c250e0ceEA271728547f3802B36Fd";