diff --git a/contracts/DEXes/UniV3StablesJoint.sol b/contracts/DEXes/UniV3StablesJoint.sol index 0d50f0e..b33bddc 100644 --- a/contracts/DEXes/UniV3StablesJoint.sol +++ b/contracts/DEXes/UniV3StablesJoint.sol @@ -178,15 +178,6 @@ contract UniV3StablesJoint is NoHedgeJoint { return positionInfo.liquidity; } - /* - * @notice - * Function available for vault managers to set the CRV pool to use for swaps - * @param newPool, new CRV pool address to use - */ - function setCRVPool(address newPool) external onlyVaultManagers { - crvPool = newPool; - } - /* * @notice * Function available for vault managers to set the boolean value deciding wether @@ -211,11 +202,15 @@ contract UniV3StablesJoint is NoHedgeJoint { * @notice * Function available for vault managers to set min & max values of the position. If, * for any reason the ticks are not the value they should be, we always have the option - * to re-set them back to the necessary value + * to re-set them back to the necessary value using the force parameter * @param _minTick, lower limit of position - * @param _maxTick, upper limit of position + * @param _minTick, lower limit of position + * @param forceChange, force parameter to ensure this function is not called randomly */ - function setTicksManually(int24 _minTick, int24 _maxTick) external onlyVaultManagers { + function setTicksManually(int24 _minTick, int24 _maxTick, bool forceChange) external onlyVaultManagers { + if ((investedA > 0 || investedB > 0) && !forceChange) { + revert(); + } minTick = _minTick; maxTick = _maxTick; } @@ -316,10 +311,10 @@ contract UniV3StablesJoint is NoHedgeJoint { uint256 amount1Owed, bytes calldata data ) external { + IUniswapV3Pool _pool = IUniswapV3Pool(pool); // Only the pool can use this function - require(msg.sender == pool); // dev: callback only called by pool + require(msg.sender == address(_pool)); // dev: callback only called by pool // Send the required funds to the pool - IUniswapV3Pool _pool = IUniswapV3Pool(pool); IERC20(_pool.token0()).safeTransfer(address(_pool), amount0Owed); IERC20(_pool.token1()).safeTransfer(address(_pool), amount1Owed); } @@ -338,10 +333,10 @@ contract UniV3StablesJoint is NoHedgeJoint { int256 amount1Delta, bytes calldata data ) external { + IUniswapV3Pool _pool = IUniswapV3Pool(pool); // Only the pool can use this function - require(msg.sender == address(pool)); // dev: callback only called by pool + require(msg.sender == address(_pool)); // dev: callback only called by pool - IUniswapV3Pool _pool = IUniswapV3Pool(pool); uint256 amountIn; address tokenIn; @@ -360,17 +355,12 @@ contract UniV3StablesJoint is NoHedgeJoint { /* * @notice - * Function claiming the earned rewards for the joint, sends the tokens to the joint - * contract + * Function used internally to collect the accrued fees by burn 0 of the LP position + * and collecting the owed tokens (only fees as no LP has been burnt) + * @return balance of tokens in the LP (invested amounts) */ function getReward() internal override { - IUniswapV3Pool(pool).collect( - address(this), - minTick, - maxTick, - type(uint128).max, - type(uint128).max - ); + _burnAndCollect(0, minTick, maxTick); } /* @@ -440,8 +430,7 @@ contract UniV3StablesJoint is NoHedgeJoint { * @param amount, amount of liquidity to burn */ function burnLP(uint256 amount) internal override { - IUniswapV3Pool(pool).burn(minTick, maxTick, uint128(amount)); - getReward(); + _burnAndCollect(amount, minTick, maxTick); // If entire position is closed, re-set the min and max ticks IUniswapV3Pool.PositionInfo memory positionInfo = _positionInfo(); if (positionInfo.liquidity == 0){ @@ -455,6 +444,7 @@ contract UniV3StablesJoint is NoHedgeJoint { * Function available to vault managers to burn the LP manually, if for any reason * the ticks have been set to 0 (or any different value from the original LP), we make * sure we can always get out of the position + * This function can be used to only collect fees by passing a 0 amount to burn * @param _amount, amount of liquidity to burn * @param _minTick, lower limit of position * @param _maxTick, upper limit of position @@ -464,22 +454,25 @@ contract UniV3StablesJoint is NoHedgeJoint { int24 _minTick, int24 _maxTick ) external onlyVaultManagers { - IUniswapV3Pool(pool).burn(_minTick, _maxTick, uint128(_amount)); + _burnAndCollect(_amount, _minTick, _maxTick); } /* * @notice - * Function available to vault managers to collect the pending rewards manually, - * if for any reason the ticks have been set to 0 (or any different value from the - * original LP), we make sure we can always get the rewards back + * Function available internally to burn the LP amount specified, for position + * defined by minTick and maxTick specified and collect the owed tokens + * @param _amount, amount of liquidity to burn * @param _minTick, lower limit of position * @param _maxTick, upper limit of position */ - function collectRewardsManually( + function _burnAndCollect( + uint256 _amount, int24 _minTick, int24 _maxTick - ) external onlyVaultManagers { - IUniswapV3Pool(pool).collect( + ) internal { + IUniswapV3Pool _pool = IUniswapV3Pool(pool); + _pool.burn(_minTick, _maxTick, uint128(_amount)); + _pool.collect( address(this), _minTick, _maxTick, @@ -570,7 +563,7 @@ contract UniV3StablesJoint is NoHedgeJoint { // Order of swap bool zeroForOne = _tokenFrom < _tokenTo; - // Use the uniswap helper view to simluate the swapin the uni v3 pool + // Use the uniswap helper view to simulate the swap in the uni v3 pool (int256 _amount0, int256 _amount1, , ) = UniswapHelperViews.simulateSwap( // pool to use IUniswapV3Pool(pool), @@ -615,6 +608,8 @@ contract UniV3StablesJoint is NoHedgeJoint { return int128(1); } else if (_pool.coins(2) == _token) { return int128(2); + } else { + revert(); } } @@ -636,4 +631,31 @@ contract UniV3StablesJoint is NoHedgeJoint { ); return IUniswapV3Pool(pool).positions(key); } + + /* + * @notice + * Function used by governance to swap tokens manually if needed, can be used when closing + * the LP position manually and need some re-balancing before sending funds back to the + * providers + * @param swapPath, path of addresses to swap, should be 2 and always tokenA <> tokenB + * @param swapInAmount, amount of swapPath[0] to swap for swapPath[1] + * @param minOutAmount, minimum amount of want out + * @return swapped amount + */ + function swapTokenForTokenManually( + address[] memory swapPath, + uint256 swapInAmount, + uint256 minOutAmount + ) external onlyGovernance override returns (uint256) { + address _tokenA = tokenA; + address _tokenB = tokenB; + require(swapPath.length == 2); + require(swapPath[0] == _tokenA || swapPath[1] == _tokenA); + require(swapPath[0] == _tokenB || swapPath[1] == _tokenB); + return swap( + swapPath[0], + swapPath[1], + swapInAmount + ); + } } diff --git a/contracts/Joint.sol b/contracts/Joint.sol index c0ac8a6..5f2438c 100644 --- a/contracts/Joint.sol +++ b/contracts/Joint.sol @@ -291,16 +291,9 @@ abstract contract Joint { (uint256 currentBalanceA, uint256 currentBalanceB) = _closePosition(); // 2. SELL REWARDS FOR WANT - tokenAmount[] memory swappedToAmounts = swapRewardTokens(); - for (uint256 i = 0; i < swappedToAmounts.length; i++) { - address rewardSwappedTo = swappedToAmounts[i].token; - uint256 rewardSwapOutAmount = swappedToAmounts[i].amount; - if (rewardSwappedTo == tokenA) { - currentBalanceA = currentBalanceA + rewardSwapOutAmount; - } else if (rewardSwappedTo == tokenB) { - currentBalanceB = currentBalanceB + rewardSwapOutAmount; - } - } + (uint256 rewardsSwappedToA, uint256 rewardsSwappedToB) = swapRewardTokens(); + currentBalanceA += rewardsSwappedToA; + currentBalanceB += rewardsSwappedToB; // 3. REBALANCE PORTFOLIO // Calculate rebalance operation @@ -382,11 +375,11 @@ abstract contract Joint { } // Keepers will claim and sell rewards mid-epoch (otherwise we sell only in the end) - function harvest() external onlyKeepers { + function harvest() external virtual onlyKeepers { getReward(); } - function harvestTrigger() external view returns (bool) { + function harvestTrigger() external view virtual returns (bool) { return balanceOfRewardToken()[0] > minRewardToHarvest; } @@ -632,19 +625,16 @@ abstract contract Joint { * @param token, address of the token to swap from * @return address of the token to swap to */ - function findSwapTo(address token) internal view returns (address) { - if (tokenA == token) { + function findSwapTo(address from_token) internal view returns (address) { + if (tokenA == from_token) { return tokenB; - } else if (tokenB == token) { - return tokenA; - } else if (_isReward(token)) { - if (tokenA == referenceToken || tokenB == referenceToken) { - return referenceToken; - } + } else if (tokenB == from_token) { return tokenA; - } else { - revert("!swapTo"); } + if (tokenA == referenceToken || tokenB == referenceToken) { + return referenceToken; + } + return tokenA; } /* @@ -658,11 +648,13 @@ abstract contract Joint { internal view returns (address[] memory _path) - { + { + address _tokenA = tokenA; + address _tokenB = tokenB; bool isReferenceToken = _token_in == address(referenceToken) || _token_out == address(referenceToken); - bool is_internal = (_token_in == tokenA && _token_out == tokenB) || - (_token_in == tokenB && _token_out == tokenA); + bool is_internal = (_token_in == _tokenA && _token_out == _tokenB) || + (_token_in == _tokenB && _token_out == _tokenA); _path = new address[](isReferenceToken || is_internal ? 2 : 3); _path[0] = _token_in; if (isReferenceToken || is_internal) { @@ -679,36 +671,29 @@ abstract contract Joint { function withdrawLP() internal virtual {} - struct tokenAmount { - address token; - uint256 amount; - } - /* * @notice * Function available internally swapping amounts necessary to swap rewards - * @return tokenAmount array of the swap path followed + * @return amounts exchanged to tokenA and tokenB */ function swapRewardTokens() internal virtual - returns (tokenAmount[] memory) + returns (uint256 swappedToA, uint256 swappedToB) { - tokenAmount[] memory _swapToAmounts = new tokenAmount[]( - rewardTokens.length - ); + address _tokenA = tokenA; + address _tokenB = tokenB; for (uint256 i = 0; i < rewardTokens.length; i++) { address reward = rewardTokens[i]; uint256 _rewardBal = IERC20(reward).balanceOf(address(this)); // If the reward token is either A or B, don't swap - if (reward == tokenA || reward == tokenB || _rewardBal == 0) { - _swapToAmounts[i] = tokenAmount(reward, 0); + if (reward == _tokenA || reward == _tokenB || _rewardBal == 0) { + continue; // If the referenceToken is either A or B, swap rewards against it - } else if (tokenA == referenceToken || tokenB == referenceToken) { - _swapToAmounts[i] = tokenAmount( - referenceToken, - swap(reward, referenceToken, _rewardBal) - ); + } else if (_tokenA == referenceToken) { + swappedToA += swap(reward, referenceToken, _rewardBal); + } else if (_tokenB == referenceToken) { + swappedToB += swap(reward, referenceToken, _rewardBal); } else { // Assume that position has already been liquidated (uint256 ratioA, uint256 ratioB) = getRatios( @@ -717,14 +702,15 @@ abstract contract Joint { investedA, investedB ); - address swapTo = (ratioA >= ratioB) ? tokenB : tokenA; - _swapToAmounts[i] = tokenAmount( - swapTo, - swap(reward, swapTo, _rewardBal) - ); + + if (ratioA >= ratioB) { + swappedToB += swap(reward, _tokenB, _rewardBal); + } else { + swappedToA += swap(reward, _tokenA, _rewardBal); + } } } - return _swapToAmounts; + return (swappedToA, swappedToB); } function swap( @@ -872,13 +858,12 @@ abstract contract Joint { address[] memory swapPath, uint256 swapInAmount, uint256 minOutAmount - ) external onlyGovernance returns (uint256) {} + ) external virtual returns (uint256); /* * @notice * Function available to governance sweeping a specified token but tokenA and B - * @param expectedBalanceA, expected balance of tokenA to receive - * @param expectedBalanceB, expected balance of tokenB to receive + * @param _token, address of the token to sweep */ function sweep(address _token) external onlyGovernance { require(_token != address(tokenA)); diff --git a/tests/conftest.py b/tests/conftest.py index e71981b..f5aed1a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -231,8 +231,8 @@ def tokenA(request, chain): # "YFI", # YFI # "WETH", # WETH # 'LINK', # LINK - # 'USDT', # USDT - 'DAI', # DAI + 'USDT', # USDT + # 'DAI', # DAI # "USDC", # USDC # "WFTM", # "MIM", diff --git a/tests/nohedge/UNIV3_test_manual_operation.py b/tests/nohedge/UNIV3_test_manual_operation.py new file mode 100644 index 0000000..8895889 --- /dev/null +++ b/tests/nohedge/UNIV3_test_manual_operation.py @@ -0,0 +1,226 @@ +from functools import _lru_cache_wrapper +from utils import actions, checks, utils +import pytest +from brownie import Contract, chain + +@pytest.mark.parametrize("swap_from", ["a", "b"]) +def test_return_loose_to_providers_manually( + chain, + tokenA, + tokenB, + vaultA, + vaultB, + providerA, + providerB, + joint, + user, + amountA, + amountB, + RELATIVE_APPROX, + gov, + tokenA_whale, + tokenB_whale, + hedge_type, + dex, + uni_v3_pool, + router, + uniswap_helper_views, + testing_library, + univ3_pool_fee, + joint_to_use, + weth, + swap_from +): + checks.check_run_test("nohedge", hedge_type) + checks.check_run_test("UNIV3", dex) + # Deposit to the vault + actions.user_deposit(user, vaultA, tokenA, amountA) + actions.user_deposit(user, vaultB, tokenB, amountB) + + # Harvest 1: Send funds through the strategy + chain.sleep(1) + actions.gov_start_epoch_univ3(gov, providerA, providerB, joint, vaultA, vaultB, amountA, amountB) + + (initial_amount_A, initial_amount_B) = joint.balanceOfTokensInLP() + + # All balance should be invested + assert tokenA.balanceOf(joint) == 0 + assert tokenB.balanceOf(joint) == 0 + assert joint.pendingRewards() == (0,0) + + # Trade a small amount to generate rewards + token_in = tokenA if swap_from == "a" else tokenB + token_out = tokenB if swap_from == "a" else tokenA + token_in_whale = tokenA_whale if swap_from == "a" else tokenB_whale + token_out_whale = tokenB_whale if swap_from == "a" else tokenA_whale + + sell_amount = 1_000 * (10**token_in.decimals()) + utils.univ3_sell_token(token_in, token_out, router, token_in_whale, sell_amount, univ3_pool_fee) + + # We have generated rewards + pending_rewards = joint.pendingRewards() + assert pending_rewards != (0, 0) + + reward_gains = pending_rewards[0] if pending_rewards[0] > 0 else pending_rewards[1] + # Claim rewards manually + tx = joint.burnLPManually(0, joint.minTick(), joint.maxTick(), {"from": gov}) + if reward_gains == pending_rewards[0]: + assert tx.events["Collect"]["amount0"] == reward_gains + else: + assert tx.events["Collect"]["amount1"] == reward_gains + # Remove liquidity manually + tx = joint.removeLiquidityManually(joint.balanceOfPool(), 0, 0, {"from": gov}) + + # All balance should be in joint + assert tokenA.balanceOf(joint) > 0 + assert tokenB.balanceOf(joint) > 0 + + # Send back to providers + joint.returnLooseToProvidersManually() + assert tokenA.balanceOf(joint) == 0 + assert tokenB.balanceOf(joint) == 0 + + # All tokens accounted for + assert pytest.approx(tokenA.balanceOf(providerA), rel=1e-3) == amountA + assert pytest.approx(tokenB.balanceOf(providerB), rel=1e-3) == amountB + +def test_liquidate_position_manually( + chain, + tokenA, + tokenB, + vaultA, + vaultB, + providerA, + providerB, + joint, + user, + amountA, + amountB, + RELATIVE_APPROX, + gov, + tokenA_whale, + tokenB_whale, + hedge_type, + dex, + uni_v3_pool, + router, + uniswap_helper_views, + testing_library, + univ3_pool_fee, + joint_to_use, + weth, +): + checks.check_run_test("nohedge", hedge_type) + checks.check_run_test("UNIV3", dex) + + # Deposit to the vault + actions.user_deposit(user, vaultA, tokenA, amountA) + actions.user_deposit(user, vaultB, tokenB, amountB) + + # Harvest 1: Send funds through the strategy + chain.sleep(1) + actions.gov_start_epoch_univ3(gov, providerA, providerB, joint, vaultA, vaultB, amountA, amountB) + + (initial_amount_A, initial_amount_B) = joint.balanceOfTokensInLP() + + # CLose position manually + joint.liquidatePositionManually(0, 0) + + actions.gov_end_epoch(gov, providerA, providerB, joint, vaultA, vaultB) + + assert joint.investedA() == 0 + assert joint.investedB() == 0 + + for (vault, strat) in zip([vaultA, vaultB], [providerA, providerB]): + assert vault.strategies(strat)["totalLoss"] >= 0 + assert vault.strategies(strat)["totalGain"] == 0 + assert vault.strategies(strat)["totalDebt"] == 0 + +@pytest.mark.parametrize("swap_from", ["a", "b"]) +@pytest.mark.parametrize("swap_dex", ["uni", "crv"]) +def test_manual_swaps( + chain, + tokenA, + tokenB, + vaultA, + vaultB, + providerA, + providerB, + joint, + user, + amountA, + amountB, + RELATIVE_APPROX, + gov, + tokenA_whale, + tokenB_whale, + hedge_type, + dex, + uni_v3_pool, + router, + uniswap_helper_views, + testing_library, + univ3_pool_fee, + joint_to_use, + weth, + swap_from, + swap_dex +): + checks.check_run_test("nohedge", hedge_type) + checks.check_run_test("UNIV3", dex) + + if swap_dex == "uni": + joint.setUseCRVPool(False, {"from": gov}) + else: + joint.setUseCRVPool(True, {"from": gov}) + + # Deposit to the vault + actions.user_deposit(user, vaultA, tokenA, amountA) + actions.user_deposit(user, vaultB, tokenB, amountB) + + # Harvest 1: Send funds through the strategy + chain.sleep(1) + actions.gov_start_epoch_univ3(gov, providerA, providerB, joint, vaultA, vaultB, amountA, amountB) + + # Trade a small amount to generate rewards + token_in = tokenA if swap_from == "a" else tokenB + token_out = tokenB if swap_from == "a" else tokenA + token_in_whale = tokenA_whale if swap_from == "a" else tokenB_whale + + sell_amount = 1_000 * (10**token_in.decimals()) + utils.univ3_sell_token(token_in, token_out, router, token_in_whale, sell_amount, univ3_pool_fee) + + tx = joint.removeLiquidityManually(joint.balanceOfPool(), 0, 0, {"from": gov}) + + if swap_from == "a": + path = [tokenA, tokenB] + amount = joint.balanceOfA() - joint.investedA() + else: + path = [tokenB, tokenA] + amount = joint.balanceOfB() - joint.investedB() + + joint.swapTokenForTokenManually( + path, + amount, + 0, + {"from": gov} + ) + + # All balance should be in joint + assert pytest.approx(joint.balanceOfA(), rel=RELATIVE_APPROX) == joint.investedA() + assert pytest.approx(joint.balanceOfB(), rel=RELATIVE_APPROX) == joint.investedB() + + # Send back to providers + joint.returnLooseToProvidersManually() + + assert joint.investedA() > 0 + assert joint.investedB() > 0 + + # All tokens accounted for + assert pytest.approx(tokenA.balanceOf(providerA), rel=1e-3) == amountA + assert pytest.approx(tokenB.balanceOf(providerB), rel=1e-3) == amountB + + actions.gov_end_epoch(gov, providerA, providerB, joint, vaultA, vaultB) + + assert joint.investedA() == 0 + assert joint.investedB() == 0 diff --git a/tests/nohedge/UNIV3_test_open_position_and_harvest.py b/tests/nohedge/UNIV3_test_open_position_and_harvest.py index ce78230..a55ac40 100644 --- a/tests/nohedge/UNIV3_test_open_position_and_harvest.py +++ b/tests/nohedge/UNIV3_test_open_position_and_harvest.py @@ -329,7 +329,7 @@ def test_choppy_harvest_UNIV3( if swap_dex == "crv": utils.crv_re_peg_pool(joint.crvPool(), token_out, token_in, token_out_whale, prev_reserve) - actions.gov_start_epoch_univ3(gov, providerA, providerB, joint, vaultA, vaultB, amountA, amountB, keep_dr = False) + actions.gov_start_epoch_univ3(gov, providerA, providerB, joint, vaultA, vaultB, amountA, amountB, keep_dr = False, check=False) # assert 0 print("etas", providerA.estimatedTotalAssets(), providerB.estimatedTotalAssets()) diff --git a/tests/utils/actions.py b/tests/utils/actions.py index 124be6a..36f0d1d 100644 --- a/tests/utils/actions.py +++ b/tests/utils/actions.py @@ -9,7 +9,7 @@ def user_deposit(user, vault, token, amount): vault.deposit(amount, {"from": user}) assert token.balanceOf(vault.address) == amount -def gov_start_epoch_univ3(gov, providerA, providerB, joint, vaultA, vaultB, amountA, amountB, keep_dr=False): +def gov_start_epoch_univ3(gov, providerA, providerB, joint, vaultA, vaultB, amountA, amountB, keep_dr=False, check=True): # the first harvest sends funds (tokenA) to joint contract and waits for tokenB funds # the second harvest sends funds (tokenB) to joint contract AND invests them (if there is enough TokenA) providerA.harvest({"from": gov}) @@ -19,7 +19,8 @@ def gov_start_epoch_univ3(gov, providerA, providerB, joint, vaultA, vaultB, amou vaultA.updateStrategyDebtRatio(providerA, 0, {"from": gov}) vaultB.updateStrategyDebtRatio(providerB, 0, {"from": gov}) - checks.epoch_started_univ3(providerA, providerB, joint, amountA, amountB) + if check: + checks.epoch_started_univ3(providerA, providerB, joint, amountA, amountB) def gov_start_epoch(gov, providerA, providerB, joint, vaultA, vaultB, amountA, amountB): # the first harvest sends funds (tokenA) to joint contract and waits for tokenB funds diff --git a/tests/utils/utils.py b/tests/utils/utils.py index ce54e24..00fddfa 100644 --- a/tests/utils/utils.py +++ b/tests/utils/utils.py @@ -245,7 +245,7 @@ def crv_ensure_bad_trade(crv_pool, token_in, token_out, token_in_whale): reserve_token_from = crv_pool.balances(index_from) reserve_token_to = crv_pool.balances(index_to) - sell_amount = reserve_token_to / 2 + sell_amount = reserve_token_to * 0.8 sell_amount = sell_amount / (10**token_out.decimals()) * (10**token_in.decimals()) token_in.approve(crv_pool, 0, {"from": token_in_whale})